<?php
/**
 * Copyright © Magento, Inc. All rights reserved.
 * See COPYING.txt for license details.
 */

declare(strict_types=1);

namespace Magento\User\Model\ResourceModel;

use Laminas\Validator\Callback;
use Laminas\Validator\ValidatorInterface;
use Magento\Authorization\Model\Acl\Role\Group as RoleGroup;
use Magento\Authorization\Model\Acl\Role\User as RoleUser;
use Magento\Authorization\Model\UserContextInterface;
use Magento\Framework\Acl\Data\CacheInterface;
use Magento\Framework\App\ObjectManager;
use Magento\Framework\Exception\LocalizedException;
use Magento\Framework\Model\AbstractModel;
use Magento\User\Model\Backend\Config\ObserverConfig;
use Magento\User\Model\User as ModelUser;
use Magento\Framework\Encryption\EncryptorInterface;

/**
 * ACL user resource
 *
 * @SuppressWarnings(PHPMD.CouplingBetweenObjects)
 * @api
 * @since 100.0.2
 */
class User extends \Magento\Framework\Model\ResourceModel\Db\AbstractDb
{
    /**
     * Role model
     *
     * @var \Magento\Authorization\Model\RoleFactory
     */
    protected $_roleFactory;

    /**
     * @var \Magento\Framework\Stdlib\DateTime
     */
    protected $dateTime;

    /**
     * @var CacheInterface
     */
    private $aclDataCache;

    /**
     * @var ObserverConfig|null
     */
    private $observerConfig;

    /**
     * @var EncryptorInterface|null
     */
    private $encryptor;

    /**
     * Construct
     *
     * @param \Magento\Framework\Model\ResourceModel\Db\Context $context
     * @param \Magento\Authorization\Model\RoleFactory $roleFactory
     * @param \Magento\Framework\Stdlib\DateTime $dateTime
     * @param string $connectionName
     * @param CacheInterface $aclDataCache
     * @param ObserverConfig|null $observerConfig
     * @param EncryptorInterface|null $encryptor
     */
    public function __construct(
        \Magento\Framework\Model\ResourceModel\Db\Context $context,
        \Magento\Authorization\Model\RoleFactory $roleFactory,
        \Magento\Framework\Stdlib\DateTime $dateTime,
        $connectionName = null,
        CacheInterface $aclDataCache = null,
        ObserverConfig $observerConfig = null,
        EncryptorInterface $encryptor = null
    ) {
        parent::__construct($context, $connectionName);
        $this->_roleFactory = $roleFactory;
        $this->dateTime = $dateTime;
        $this->aclDataCache = $aclDataCache ?: ObjectManager::getInstance()->get(CacheInterface::class);
        $this->observerConfig = $observerConfig ?: ObjectManager::getInstance()->get(ObserverConfig::class);
        $this->encryptor = $encryptor ?? ObjectManager::getInstance()->get(EncryptorInterface::class);
    }

    /**
     * Define main table
     *
     * @return void
     */
    protected function _construct()
    {
        $this->_init('admin_user', 'user_id');
    }

    /**
     * Initialize unique fields
     *
     * @return $this
     */
    protected function _initUniqueFields()
    {
        $this->_uniqueFields = [
            ['field' => 'email', 'title' => __('Email')],
            ['field' => 'username', 'title' => __('User Name')],
        ];
        return $this;
    }

    /**
     * Authenticate user by $username and $password
     *
     * @param ModelUser $user
     * @return $this
     */
    public function recordLogin(ModelUser $user)
    {
        $connection = $this->getConnection();

        $data = [
            'logdate' => (new \DateTime())->format(\Magento\Framework\Stdlib\DateTime::DATETIME_PHP_FORMAT),
            'lognum' => $user->getLognum() + 1,
        ];
        
        $user->setLogdate($data['logdate']);
        $user->setLognum($data['lognum']);

        $condition = ['user_id = ?' => (int)$user->getUserId()];

        $connection->update($this->getMainTable(), $data, $condition);

        return $this;
    }

    /**
     * Load data by specified username
     *
     * @param string $username
     * @return array
     */
    public function loadByUsername($username)
    {
        $connection = $this->getConnection();

        $select = $connection->select()->from($this->getMainTable())->where('username=:username');

        $binds = ['username' => $username];

        return $connection->fetchRow($select, $binds);
    }

    /**
     * Check if user is assigned to any role
     *
     * @param int|ModelUser $user
     * @return null|array
     */
    public function hasAssigned2Role($user)
    {
        if (is_numeric($user)) {
            $userId = $user;
        } elseif ($user instanceof AbstractModel) {
            $userId = $user->getUserId();
        } else {
            return null;
        }

        if ($userId > 0) {
            $connection = $this->getConnection();

            $select = $connection->select();
            $select->from($this->getTable('authorization_role'))
                ->where('parent_id > :parent_id')
                ->where('user_id = :user_id')
                ->where('user_type = :user_type');

            $binds = ['parent_id' => 0, 'user_id' => $userId,
                      'user_type' => UserContextInterface::USER_TYPE_ADMIN
            ];

            return $connection->fetchAll($select, $binds);
        } else {
            return null;
        }
    }

    /**
     * @inheritDoc
     */
    protected function _beforeSave(AbstractModel $user)
    {
        if ($user->hasRoleId()) {
            $user->setReloadAclFlag(1);
        }
        if ($user->getData('rp_token')) {
            $rpToken = $user->getData('rp_token');
            $user->setRpToken($this->encryptor->encrypt($rpToken));
        }

        return parent::_beforeSave($user);
    }

    /**
     * Unserialize user extra data after user save
     *
     * @param AbstractModel $user
     * @return $this
     */
    protected function _afterSave(AbstractModel $user)
    {
        $user->setExtra($this->getSerializer()->unserialize($user->getExtra()));
        if ($user->hasRoleId()) {
            $this->_clearUserRoles($user);
            $this->_createUserRole($user->getRoleId(), $user);
        }
        if ($user->getData('rp_token')) {
            $rpToken = $user->getData('rp_token');
            $user->setRpToken($this->encryptor->decrypt($rpToken));
        }
        return $this;
    }

    /**
     * Clear all user-specific roles of provided user
     *
     * @param ModelUser $user
     * @return void
     */
    public function _clearUserRoles(ModelUser $user)
    {
        $conditions = ['user_id = ?' => (int)$user->getId(), 'user_type = ?' => UserContextInterface::USER_TYPE_ADMIN];
        $this->getConnection()->delete($this->getTable('authorization_role'), $conditions);
    }

    /**
     * Create role for provided user of provided type
     *
     * @param int $parentId
     * @param ModelUser $user
     * @return void
     */
    protected function _createUserRole($parentId, ModelUser $user)
    {
        if ($parentId > 0) {
            /** @var \Magento\Authorization\Model\Role $parentRole */
            $parentRole = $this->_roleFactory->create()->load($parentId);
            if ($parentRole->getId()) {
                $data = new \Magento\Framework\DataObject(
                    [
                        'parent_id' => $parentRole->getId(),
                        'tree_level' => $parentRole->getTreeLevel() + 1,
                        'sort_order' => 0,
                        'role_type' => RoleUser::ROLE_TYPE,
                        'user_id' => $user->getId(),
                        'user_type' => UserContextInterface::USER_TYPE_ADMIN,
                        'role_name' => $user->getFirstName(),
                    ]
                );

                $insertData = $this->_prepareDataForTable($data, $this->getTable('authorization_role'));
                $this->getConnection()->insert($this->getTable('authorization_role'), $insertData);
                $this->aclDataCache->clean();
            }
        }
    }

    /**
     * Unserialize user extra data after user load
     *
     * @param AbstractModel $user
     * @return $this
     */
    protected function _afterLoad(AbstractModel $user)
    {
        if (is_string($user->getExtra())) {
            $user->setExtra($this->getSerializer()->unserialize($user->getExtra()));
        }
        if ($user->getData('rp_token')) {
            $rpToken = $user->getData('rp_token');
            $user->setRpToken($this->encryptor->decrypt($rpToken));
        }
        return parent::_afterLoad($user);
    }

    /**
     * Delete user role record with user
     *
     * @param AbstractModel $user
     * @return bool
     * @throws LocalizedException
     */
    public function delete(AbstractModel $user)
    {
        $uid = $user->getId();
        if (!$uid) {
            return false;
        }

        $this->_beforeDelete($user);
        $connection = $this->getConnection();
        $connection->beginTransaction();
        try {
            $connection->delete($this->getMainTable(), ['user_id = ?' => $uid]);
            $connection->delete(
                $this->getTable('authorization_role'),
                ['user_id = ?' => $uid, 'user_type = ?' => UserContextInterface::USER_TYPE_ADMIN]
            );
        } catch (LocalizedException $e) {
            $connection->rollBack();

            return false;
        }

        $connection->commit();
        $this->_afterDelete($user);

        return true;
    }

    /**
     * Get user roles
     *
     * @param AbstractModel $user
     * @return array
     */
    public function getRoles(AbstractModel $user)
    {
        if (!$user->getId()) {
            return [];
        }

        $table = $this->getTable('authorization_role');
        $connection = $this->getConnection();

        $select = $connection->select()->from(
            $table,
            []
        )->joinLeft(
            ['ar' => $table],
            "(ar.role_id = {$table}.parent_id and ar.role_type = '" . RoleGroup::ROLE_TYPE . "')",
            ['role_id']
        )->where(
            "{$table}.user_id = :user_id"
        )->where(
            "{$table}.user_type = :user_type"
        );

        $binds = ['user_id' => (int)$user->getId(),
                  'user_type' => UserContextInterface::USER_TYPE_ADMIN
        ];

        $roles = $connection->fetchCol($select, $binds);

        if ($roles) {
            return $roles;
        }

        return [];
    }

    /**
     * Delete user role
     *
     * @param AbstractModel $user
     * @return $this
     */
    public function deleteFromRole(AbstractModel $user)
    {
        if ($user->getUserId() <= 0) {
            return $this;
        }
        if ($user->getRoleId() <= 0) {
            return $this;
        }

        $dbh = $this->getConnection();

        $condition = [
            'user_id = ?' => (int)$user->getId(),
            'parent_id = ?' => (int)$user->getRoleId(),
            'user_type = ?' => UserContextInterface::USER_TYPE_ADMIN
        ];

        $dbh->delete($this->getTable('authorization_role'), $condition);
        return $this;
    }

    /**
     * Check if role user exists
     *
     * @param AbstractModel $user
     * @return array
     */
    public function roleUserExists(AbstractModel $user)
    {
        if ($user->getUserId() > 0) {
            $roleTable = $this->getTable('authorization_role');

            $dbh = $this->getConnection();

            $binds = [
                'parent_id' => $user->getRoleId(),
                'user_id' => $user->getUserId(),
                'user_type' => UserContextInterface::USER_TYPE_ADMIN
            ];

            $select = $dbh->select()->from($roleTable)
                ->where('parent_id = :parent_id')
                ->where('user_type = :user_type')
                ->where('user_id = :user_id');

            return $dbh->fetchCol($select, $binds);
        } else {
            return [];
        }
    }

    /**
     * Check if user exists
     *
     * @param AbstractModel $user
     * @return array
     */
    public function userExists(AbstractModel $user)
    {
        $connection = $this->getConnection();
        $select = $connection->select();

        $binds = [
            'username' => $user->getUsername(),
            'email' => $user->getEmail(),
            'user_id' => (int)$user->getId(),
        ];

        $select->from(
            $this->getMainTable()
        )->where(
            '(username = :username OR email = :email)'
        )->where(
            'user_id <> :user_id'
        );

        return $connection->fetchRow($select, $binds);
    }

    /**
     * Whether a user's identity is confirmed
     *
     * @param AbstractModel $user
     * @return bool
     */
    public function isUserUnique(AbstractModel $user)
    {
        return !$this->userExists($user);
    }

    /**
     * Save user extra data
     *
     * @param AbstractModel $object
     * @param string $data
     * @return $this
     */
    public function saveExtra($object, $data)
    {
        if ($object->getId()) {
            $this->getConnection()->update(
                $this->getMainTable(),
                ['extra' => $data],
                ['user_id = ?' => (int)$object->getId()]
            );
        }

        return $this;
    }

    /**
     * Retrieve the total user count bypassing any filters applied to collections
     *
     * @return int
     */
    public function countAll()
    {
        $connection = $this->getConnection();
        $select = $connection->select();
        $select->from($this->getMainTable(), 'COUNT(*)');
        $result = (int)$connection->fetchOne($select);
        return $result;
    }

    /**
     * Add validation rules to be applied before saving an entity
     *
     * @return ValidatorInterface $validator
     */
    public function getValidationRulesBeforeSave()
    {
        $userIdentity = new Callback([$this, 'isUserUnique']);
        $userIdentity->setMessage(
            __('A user with the same user name or email already exists.'),
            Callback::INVALID_VALUE
        );

        return $userIdentity;
    }

    /**
     * Update role users ACL
     *
     * @param \Magento\Authorization\Model\Role $role
     * @return bool
     */
    public function updateRoleUsersAcl(\Magento\Authorization\Model\Role $role)
    {
        $connection = $this->getConnection();
        $users = $role->getRoleUsers();
        $rowsCount = 0;

        if (count($users) > 0) {
            $bind = ['reload_acl_flag' => 1];
            $where = ['user_id IN(?)' => $users];
            $rowsCount = $connection->update($this->getTable('admin_user'), $bind, $where);
        }

        return $rowsCount > 0;
    }

    /**
     * Unlock specified user record(s)
     *
     * @param int|int[] $userIds
     * @return int number of affected rows
     */
    public function unlock($userIds)
    {
        if (!is_array($userIds)) {
            $userIds = [$userIds];
        }
        return $this->getConnection()->update(
            $this->getMainTable(),
            ['failures_num' => 0, 'first_failure' => null, 'lock_expires' => null],
            $this->getIdFieldName() . ' IN (' . $this->getConnection()->quote($userIds) . ')'
        );
    }

    /**
     * Lock specified user record(s)
     *
     * @param int|int[] $userIds
     * @param int $exceptId
     * @param int $lifetime
     * @return int number of affected rows
     */
    public function lock($userIds, $exceptId, $lifetime)
    {
        if (!is_array($userIds)) {
            $userIds = [$userIds];
        }
        $exceptId = (int)$exceptId;
        return $this->getConnection()->update(
            $this->getMainTable(),
            ['lock_expires' => $this->dateTime->formatDate(time() + $lifetime)],
            "{$this->getIdFieldName()} IN (" . $this->getConnection()->quote(
                $userIds
            ) . ")\n            AND {$this->getIdFieldName()} <> {$exceptId}"
        );
    }

    /**
     * Increment failures count along with updating lock expire and first failure dates
     *
     * @param ModelUser $user
     * @param int|bool $setLockExpires
     * @param int|bool $setFirstFailure
     * @return void
     */
    public function updateFailure($user, $setLockExpires = false, $setFirstFailure = false)
    {
        $update = ['failures_num' => new \Zend_Db_Expr('failures_num + 1')];
        if (false !== $setFirstFailure) {
            $update['first_failure'] = $this->dateTime->formatDate($setFirstFailure);
            $update['failures_num'] = 1;
        }
        if (false !== $setLockExpires) {
            $update['lock_expires'] = $this->dateTime->formatDate($setLockExpires);
        }
        $this->getConnection()->update(
            $this->getMainTable(),
            $update,
            $this->getConnection()->quoteInto("{$this->getIdFieldName()} = ?", $user->getId())
        );
    }

    /**
     * Purge and get remaining old password hashes
     *
     * @param ModelUser $user
     * @param int $retainLimit
     * @return array
     */
    public function getOldPasswords($user, $retainLimit = 4)
    {
        $userId = (int)$user->getId();
        $table = $this->getTable('admin_passwords');

        // purge expired passwords, except those which should be retained
        $retainPasswordIds = $this->getConnection()->fetchCol(
            $this->getConnection()
                ->select()
                ->from($table, 'password_id')
                ->where('user_id = :user_id')
                ->order('password_id ' . \Magento\Framework\DB\Select::SQL_DESC)
                ->limit($retainLimit),
            [':user_id' => $userId]
        );
        $where = [
            'user_id = ?' => $userId,
            'last_updated <= ?' => time() - $this->observerConfig->getAdminPasswordLifetime()
        ];
        if ($retainPasswordIds) {
            $where['password_id NOT IN (?)'] = $retainPasswordIds;
        }
        $this->getConnection()->delete($table, $where);

        // get all remaining passwords
        return $this->getConnection()->fetchCol(
            $this->getConnection()
                ->select()
                ->from($table, 'password_hash')
                ->where('user_id = :user_id'),
            [':user_id' => $userId]
        );
    }

    /**
     * Remember a password hash for further usage
     *
     * @param ModelUser $user
     * @param string $passwordHash
     * @param int $lifetime deprecated, password expiration date doesn't save anymore,
     *        it is calculated in runtime based on password created date and lifetime config value
     * @return void
     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
     *
     * @see \Magento\User\Model\Backend\Config\ObserverConfig::_isLatestPasswordExpired()
     */
    public function trackPassword($user, $passwordHash, $lifetime = 0)
    {
        $this->getConnection()->insert(
            $this->getTable('admin_passwords'),
            [
                'user_id' => $user->getId(),
                'password_hash' => $passwordHash,
                'last_updated' => time()
            ]
        );
    }

    /**
     * Get latest password for specified user id
     *
     * Possible false positive when password was changed several times with different lifetime configuration
     *
     * @param int $userId
     * @return array
     */
    public function getLatestPassword($userId)
    {
        return $this->getConnection()->fetchRow(
            $this->getConnection()
                ->select()
                ->from($this->getTable('admin_passwords'))
                ->where('user_id = :user_id')
                ->order('password_id ' . \Magento\Framework\DB\Select::SQL_DESC)
                ->limit(1),
            [':user_id' => $userId]
        );
    }
}
