<?php

namespace Mnv\Core\Auth;

use Mnv\Core\Utilities\Base64\Base64;
use Mnv\Core\Utilities\Cookie\Cookie;
use Mnv\Core\Utilities\Cookie\Session;

use Mnv\Core\Auth\Exceptions\UnknownIdException;
use Mnv\Core\Auth\Errors\UsernameRequiredError;
use Mnv\Core\Auth\Exceptions\ValidatePasswordException;

use Mnv\Core\Auth\Exceptions\NotLoggedInException;
use Mnv\Core\Auth\Exceptions\InvalidEmailException;
use Mnv\Core\Auth\Exceptions\TokenExpiredException;
use Mnv\Core\Auth\Exceptions\InvalidPasswordException;
use Mnv\Core\Auth\Exceptions\TooManyRequestsException;
use Mnv\Core\Auth\Exceptions\UnknownUsernameException;
use Mnv\Core\Auth\Exceptions\EmailNotVerifiedException;
use Mnv\Core\Auth\Exceptions\AttemptCancelledException;
use Mnv\Core\Auth\Exceptions\UserAlreadyExistsException;
use Mnv\Core\Auth\Exceptions\DuplicateUsernameException;
use Mnv\Core\Auth\Exceptions\InvalidSelectorTokenPairException;

use Mnv\Core\Auth\Errors\AuthError;
use Mnv\Core\Auth\Errors\DatabaseError;
use Mnv\Core\Auth\Exceptions\AuthException;
use Mnv\Core\Auth\Errors\MissingCallbackError;
use Mnv\Core\Auth\Errors\HeadersAlreadySentError;
use Mnv\Core\Auth\Exceptions\ResetDisabledException;
use Mnv\Core\Auth\Exceptions\ConfirmationRequestNotFound;

use Mnv\Core\Database\Throwable\Error;
use Mnv\Core\Database\Throwable\IntegrityConstraintViolationException;
use Mnv\Models\Users\UserTypes;

class Administration extends AdminManager
{
    const COOKIE_PREFIXES = [ Cookie::PREFIX_SECURE, Cookie::PREFIX_HOST ];
    const COOKIE_CONTENT_SEPARATOR = '~';

    /** @var mixed|string|null текущий IP-адрес пользователя */
    private $ipAddress;
    /** @var bool должно ли регулирование быть включено (например, в производстве) или отключено (например, во время разработки) */
    private bool $throttling;
    /** @var int интервал в секундах, по истечении которого необходимо повторно синхронизировать данные сеанса с его официальным источником в базе данных */
    private int $sessionResyncInterval;
    /** @var string имя файла cookie, используемого для функции "запомнить меня" */
    private string $rememberCookieName;

    /**
     * Administration constructor.
     *
     * @param string|null $ipAddress  (optional) IP-адрес, который следует использовать вместо параметра по умолчанию (если таковой имеется), например, при использовании прокси-сервера
     * @param bool|null $throttling (optional) следует ли включать регулирование (например, в процессе производства) или отключать (например, во время разработки)
     * @param int|null $sessionResyncInterval (optional) интервал в секундах, по истечении которого необходимо повторно синхронизировать данные сеанса с их авторитетным источником в базе данных
     * @throws AuthError
     * @throws IntegrityConstraintViolationException
     */
    public function __construct(string $ipAddress = null, bool $throttling = null, int $sessionResyncInterval = null)
    {

        $this->ipAddress = !empty($ipAddress) ? $ipAddress : ($_SERVER['REMOTE_ADDR'] ?? null);
        $this->throttling = !isset($throttling) || $throttling;
        $this->sessionResyncInterval = isset($sessionResyncInterval) ? ((int) $sessionResyncInterval) : (60 * 5);
        $this->rememberCookieName = self::createRememberCookieName();

        $this->initSessionIfNecessary();
        $this->enhanceHttpSecurity();

        $this->processRememberDirective();
        $this->resyncSessionIfNecessary();
    }

    /** Инициализирует сеанс и устанавливает правильную конфигурацию */
    private function initSessionIfNecessary()
    {
        if (\session_status() === \PHP_SESSION_NONE) {
            // использовать файлы cookie для хранения идентификаторов сеансов
            \ini_set('session.use_cookies', 1);
            // использовать только файлы cookie (не отправлять идентификаторы сеанса в URL-адресах)
            \ini_set('session.use_only_cookies', 1);
            // не отправлять идентификаторы сеанса в URL-адресах
            \ini_set('session.use_trans_sid', 0);

            // запустить сеанс (запрашивает запись cookie на клиенте)
            @Session::start();
        }
    }

    /** Повышает безопасность приложения по протоколу HTTP(S), задавая определенные заголовки. */
    private function enhanceHttpSecurity()
    {
        // удалить раскрытие версии PHP (по крайней мере, где это возможно)
        \header_remove('X-Powered-By');

        // если пользователь вошел в систему
        if ($this->isLoggedIn()) {
            // предотвратить кликджекинг
            \header('X-Frame-Options: sameorigin');
            // предотвращение прослушивания контента (сниффинг MIME)
            \header('X-Content-Type-Options: nosniff');

            // отключить кеширование потенциально конфиденциальных данных
            \header('Cache-Control: no-store, no-cache, must-revalidate', true);
            \header('Expires: Thu, 19 Nov 1981 00:00:00 GMT', true);
            \header('Pragma: no-cache', true);
        }
    }

    /**
     * Проверяет наличие установленной директивы "запомнить меня" и обрабатывает автоматический вход (при необходимости)
     */
    private function processRememberDirective()
    {
        // если пользователь еще не вошел в систему

        if (!$this->isLoggedIn()) {

            // если в настоящее время нет файла cookie для функции «запомнить меня»
            if (!isset($_COOKIE[$this->rememberCookieName])) {
                // если был обнаружен старый файл cookie для этой функции от версий v1.x.x до v6.x.x
                if (isset($_COOKIE['auth_remember'])) {
                    // вместо этого используйте значение из этого старого файла cookie
                    $_COOKIE[$this->rememberCookieName] = $_COOKIE['auth_remember'];
                }
            }

            // если запоминающийся cookie установлен
            if (isset($_COOKIE[$this->rememberCookieName])) {
                // предполагать, что файл cookie и его содержимое недействительны, пока не будет доказано обратное
                $valid = false;

                // разделить содержимое файла cookie на селектор и токен
                $parts = \explode(self::COOKIE_CONTENT_SEPARATOR, $_COOKIE[$this->rememberCookieName], 2);

                // если были найдены и селектор, и токен
                if (!empty($parts[0]) && !empty($parts[1])) {
                    $rememberData = connect()->table('users_remembered')
                        ->join('users','user', '=', 'userId')
                        ->select('user, token, expires, email, loginName, status, accessLevel, force_logout')
                        ->where('selector','=', $parts[0])
                        ->get('array');


                    if (!empty($rememberData)) {
                        if ($rememberData['expires'] >= \time()) {
                            if (\password_verify($parts[1], $rememberData['token'])) {
                                // cookie и его содержимое теперь доказали свою действительность
                                $valid = true;

                                $this->onLoginSuccessful($rememberData['user'], $rememberData['email'], $rememberData['loginName'], $rememberData['status'], $rememberData['accessLevel'], $rememberData['force_logout'], true);
                            }
                        }
                    }
                }

                // если файл cookie или его содержимое были недействительными
                if (!$valid) {
                    // пометьте файл cookie, чтобы предотвратить дальнейшие попытки
                    $this->setRememberCookie('', '', \time() + 60 * 60 * 24 * 365.25);
                }
            }
        }
    }

    private function resyncSessionIfNecessary()
    {
        // если пользователь вошел в систему
        if ($this->isLoggedIn()) {
            // следующее поле сеанса, возможно, не было инициализировано для сеансов, которые уже существовали до введения этой функции
            if (!isset($_SESSION['admin'][self::SESSION_FIELD_LAST_RESYNC])) {
                $_SESSION['admin'][self::SESSION_FIELD_LAST_RESYNC] = 0;
            }

            // если пришло время для повторной синхронизации
            if (($_SESSION['admin'][self::SESSION_FIELD_LAST_RESYNC] + $this->sessionResyncInterval) <= \time()) {
                // снова получить достоверные данные из базы данных
                $authoritativeData = connect()->table('users')->select('email, loginName, status, accessLevel, force_logout')->where('userId', $this->getUserId())->get('array');

                // если данные пользователя были найдены
                if (!empty($authoritativeData)) {
                    // следующее поле сеанса, возможно, не было инициализировано для сеансов, которые уже существовали до введения этой функции
                    if (!isset($_SESSION['admin'][self::SESSION_FIELD_FORCE_LOGOUT])) {
                        $_SESSION['admin'][self::SESSION_FIELD_FORCE_LOGOUT] = 0;
                    }

                    // если счетчик, отслеживающий принудительные выходы из системы, был увеличен
                    if ($authoritativeData['force_logout'] > $_SESSION['admin'][self::SESSION_FIELD_FORCE_LOGOUT]) {
                        // пользователь должен выйти из системы
                        $this->logOut();
                    }
                    // если счетчик, отслеживающий принудительные выходы из системы, остался неизменным
                    else {
                        // данные сеанса необходимо обновить
                        $_SESSION['admin'][self::SESSION_FIELD_EMAIL] = $authoritativeData['email'];
                        $_SESSION['admin'][self::SESSION_FIELD_USERNAME] = $authoritativeData['loginName'];
                        $_SESSION['admin'][self::SESSION_FIELD_STATUS] = (int) $authoritativeData['status'];
                        $_SESSION['admin'][self::SESSION_FIELD_ROLES] = (int) $authoritativeData['accessLevel'];

                        // помните, что мы только что выполнили необходимую повторную синхронизацию
                        $_SESSION['admin'][self::SESSION_FIELD_LAST_RESYNC] = \time();
                    }
                }
                // если данные для пользователя не найдены
                else {
                    // их учетная запись могла быть удалена, поэтому они должны выйти из системы
                    $this->logOut();
                }
            }
        }
    }

    /**
     * Попытки зарегистрировать пользователя, гарантируя, что имя пользователя уникально
     *
     * Если вы хотите, чтобы учетная запись пользователя была активирована по умолчанию, передайте `null` в качестве обратного вызова
     *
     * Если вы хотите, чтобы пользователь сначала подтвердил свой адрес электронной почты, передайте анонимную функцию в качестве обратного вызова.
     *
     * Функция обратного вызова должна иметь следующую подпись:
     *
     * `function ($selector, $token)`
     *
     * Обе части информации должны быть отправлены пользователю, как правило, встроены в ссылку
     *
     * Когда пользователь хочет подтвердить свой адрес электронной почты в качестве следующего шага, снова потребуются обе части.
     *
     * @param string $email the email address to register
     * @param string $password the password for the new account
     * @param string|null $loginName (optional) the loginName that will be displayed
     * @param callable|null $callback (optional) the function that sends the confirmation email to the user
     * @return int the ID of the user that has been created (if any)
     * @throws InvalidEmailException if the email address was invalid
     * @throws InvalidPasswordException if the password was invalid
     * @throws UserAlreadyExistsException if a user with the specified email address already exists
     * @throws DuplicateUsernameException if the specified loginName wasn't unique
     * @throws TooManyRequestsException if the number of allowed attempts/requests has been exceeded
     * @throws AuthError if an internal problem occurred (do *not* catch)
     *
     * @see confirmAdminEmail
     * @see confirmAdminEmailAndSignIn
     */
    public function register(string $email, string $password, string $loginName = null, callable $callback = null)
    {
        $this->throttle(['enumerateAdminUsers', $this->getIpAddress()], 1, (60 * 60), 75);
        $this->throttle(['createNewAdminAccount', $this->getIpAddress()], 1, (60 * 60 * 12), 5, true);

        $newUserId = $this->createUserInternal(true, $email, $password, $loginName, $callback);

        $this->throttle(['createNewAdminAccount', $this->getIpAddress()], 1, (60 * 60 * 12), 5, false);

        return $newUserId;
    }

    /**
     * Попытки войти в систему пользователя с их loginName and password
     * При использовании этого метода для аутентификации пользователей следует убедиться, что имена пользователей уникальны.
     *
     * @param string $loginName the user's loginName
     * @param string $password the user's password
     * @param $rememberDuration (optional) время в секундах, в течение которого пользователь остается в системе ("remember me"), e.g. `60 * 60 * 24 * 365.25` на один год
     * @param callable|null $onBeforeSuccess (optional) a function that receives the user's ID as its single parameter and is executed before successful authentication; must return `true` to proceed or `false` to cancel
     * @throws UnknownUsernameException if the specified loginName does not exist
     * @throws UsernameRequiredError if the specified loginName does not exist
     * @throws InvalidPasswordException if the password was invalid
     * @throws ValidatePasswordException if the password was invalid
     //* @throws EmailNotVerifiedException if the email address has not been verified yet via confirmation email
     * @throws AttemptCancelledException if the attempt has been cancelled by the supplied callback that is executed before success
     * @throws TooManyRequestsException if the number of allowed attempts/requests has been exceeded
     */
    /**
     * @param string $loginName
     * @param string $password
     * @param null $rememberDuration (optional)
     * @param callable|null $onBeforeSuccess (optional)
     * @throws AttemptCancelledException
     * @throws AuthError
     * @throws EmailNotVerifiedException
     * @throws InvalidPasswordException
     * @throws TooManyRequestsException
     * @throws UnknownUsernameException
     * @throws UsernameRequiredError
     * @throws ValidatePasswordException
     */

    public function login(string $loginName, string $password, $rememberDuration = null, callable $onBeforeSuccess = null)
    {
        $this->throttle(['attemptToAdminLogin', 'loginName', $loginName], 500, (60 * 60 * 24), null, true);

        $this->authenticate($loginName, $password, $rememberDuration, $onBeforeSuccess);
    }

    /**
     * Попытки снова подтвердить пароль текущего пользователя, вошедшего в систему
     *
     * Всякий раз, когда вы хотите снова подтвердить личность пользователя, например перед
     * пользователю разрешено совершать какие-то «опасные» действия, вы должны
     * используйте этот метод, чтобы подтвердить, что пользователь является тем, кем он себя называет.
     *
     * Например, когда пользователя запомнили долгоживущим файлом cookie.
     * и, таким образом, {@see isRemembered} возвращает "true", это означает, что
     * пользователь уже довольно долгое время не вводит свой пароль.
     *
     * @param string $password the user's password
     * @return bool whether the supplied password has been correct
     * @throws NotLoggedInException если пользователь в данный момент не вошел в систему
     * @throws TooManyRequestsException if the number of allowed attempts/requests has been exceeded
     * @throws AuthError if an internal problem occurred (do *not* catch)
     * @throws ValidatePasswordException
     * @throws InvalidPasswordException
     */
    public function reconfirmPassword(string $password): bool
    {
        if ($this->isLoggedIn()) {
            $password = self::validatePassword($password);

            $this->throttle(['reconfirmAdminPassword', $this->getIpAddress()], 3, (60 * 60), 4, true);

            $expectedHash = connect()->table('users')->select('password')->where('userId', $this->getUserId())->getValue();
            if (!empty($expectedHash)) {
                $validated = \password_verify($password, $expectedHash);

                if ($validated) {
                    // если пароль необходимо повторно кешировать, чтобы идти в ногу с совершенствованием методов взлома паролей
                    if (\password_needs_rehash($expectedHash, \PASSWORD_DEFAULT)) {
                        // создать новый хеш из пароля и обновить его в базе данных
                        $this->updatePasswordInternal($expectedHash, $password);
                    }
                } else {
                    $this->throttle(['reconfirmAdminPassword', $this->getIpAddress()], 3, (60 * 60), 4, false);

                    // мы не можем аутентифицировать пользователя из-за неправильного пароля
                    throw new InvalidPasswordException();
                }
            }
            else {
                throw new NotLoggedInException();
            }
        }
        else {
            throw new NotLoggedInException();
        }
    }

    /**
     * Выполняет выход пользователя из системы
     */
    public function logOut()
    {
        // если user вошел в систему
        if ($this->isLoggedIn()) {
            // получить любую локально существующую директиву remember
            $rememberDirectiveSelector = $this->getRememberDirectiveSelector();

            // если такая директива remember существует
            if (isset($rememberDirectiveSelector)) {
                // удалить директиву local remember
                $this->deleteRememberDirectiveForUserById($this->getUserId(), $rememberDirectiveSelector);
            }

            $this->insert_history($this->getUsername(), $this->getUserId(), 'logout', 'ok');
            // удалить все переменные session, поддерживаемые этой библиотекой
            unset($_SESSION['admin']);
        }
    }

    /**
     * Выполняет выход пользователя из всех остальных сеансов (кроме текущего)
     *
     * @throws NotLoggedInException if the user is not currently signed in
     * @throws AuthError if an internal problem occurred (do *not* catch)
     */
    public function logOutEverywhereElse()
    {
        if (!$this->isLoggedIn()) {
            throw new NotLoggedInException();
        }

        // определить дату истечения срока действия любой локально существующей директивы запоминания
        $previousRememberDirectiveExpiry = $this->getRememberDirectiveExpiry();

        // запланировать принудительный выход из системы во всех сеансах
        $this->forceLogoutForUserById($this->getUserId());

//        $this->lockForUserById($this->getUserId());

        // следующее поле сеанса, возможно, не было инициализировано для сеансов, которые уже существовали до введения этой функции
        if (!isset($_SESSION['admin'][self::SESSION_FIELD_FORCE_LOGOUT])) {
            $_SESSION['admin'][self::SESSION_FIELD_FORCE_LOGOUT] = 0;
        }

        // убедитесь, что мы просто пропустим или проигнорируем следующий принудительный выход из системы (который мы только что вызвали) в текущем сеансе
        $_SESSION['admin'][self::SESSION_FIELD_FORCE_LOGOUT]++;

        // повторно сгенерировать идентификатор сеанса, чтобы предотвратить атаки фиксации сеанса (запрашивает запись cookie на клиенте)
        Session::regenerate(true);

        // если ранее существовала директива запоминания
        if (isset($previousRememberDirectiveExpiry)) {
            // восстановить директиву со старой датой истечения срока действия, но с новыми учетными данными
            $this->createRememberDirective($this->getUserId(), $previousRememberDirectiveExpiry - \time());
        }
    }

    /**
     * Выходит из системы во всех sessions
     *
     * @throws NotLoggedInException if the user is not currently signed in
     * @throws AuthError if an internal problem occurred (do *not* catch)
     */
    public function logOutEverywhere()
    {
        if (!$this->isLoggedIn()) {
            throw new NotLoggedInException();
        }

        // запланировать принудительный выход из системы во всех sessions
        $this->forceLogoutForUserById($this->getUserId());
        // и немедленно примените выход из системы локально
        $this->logOut();
    }

    /**
     * Destroys all session data
     *
     * @throws AuthError if an internal problem occurred (do *not* catch)
     */
    public function destroySession() {
        // remove all session variables without exception
        $_SESSION = [];
        // delete the session cookie
        $this->deleteSessionCookie();
        // let PHP destroy the session
        \session_destroy();
    }

    /**
     * Создает новую директиву, удерживающую пользователя в системе ("remember me")
     *
     * @param int $userId
     * @param int $duration
     */
    private function createRememberDirective(int $userId, int $duration)
    {
        $selector       = self::createRandomString(24);
        $token          = self::createRandomString(32);
        $tokenHashed    = \password_hash($token, \PASSWORD_DEFAULT);
        $expires        = \time() + $duration;

        connect()->table('users_remembered')->insert([
            'user'      => $userId,
            'selector'  => $selector,
            'token'     => $tokenHashed,
            'expires'   => $expires
        ]);

        $this->setRememberCookie($selector, $token, $expires);
    }

    /**
     * @param int $userId
     * @param string|null $selector
     * @throws AuthError
     */
    protected function deleteRememberDirectiveForUserById(int $userId, ?string $selector = null)
    {
        parent::deleteRememberDirectiveForUserById($userId, $selector);

        $this->setRememberCookie(null, null, \time() - 3600);
    }

    /**
     * Устанавливает или обновляет файл cookie, который управляет token "remember me" token
     *
     * @param string|null $selector the selector from the selector/token pair
     * @param string|null $token the token from the selector/token pair
     * @param int $expires the UNIX time in seconds which the token should expire at
     * @throws AuthError if an internal problem occurred (do *not* catch)
     */
    private function setRememberCookie(?string $selector, ?string $token, int $expires)
    {
        $params = \session_get_cookie_params();

        if (isset($selector) && isset($token)) {
            $content = $selector . self::COOKIE_CONTENT_SEPARATOR . $token;
        }
        else {
            $content = '';
        }

        // save the cookie with the selector and token (requests a cookie to be written on the client)
        $cookie = new Cookie($this->rememberCookieName);
        $cookie->setValue($content);
        $cookie->setExpiryTime($expires);
        $cookie->setPath($params['path']);
        $cookie->setDomain($params['domain']);
        $cookie->setHttpOnly($params['httponly']);
        $cookie->setSecureOnly($params['secure']);
        $result = $cookie->save();

        if ($result === false) {
            throw new HeadersAlreadySentError();
        }

        // if we've been deleting the cookie above
        if (!isset($selector) || !isset($token)) {
            // attempt to delete a potential old cookie from versions v1.x.x to v6.x.x as well (requests a cookie to be written on the client)
            $cookie = new Cookie('auth_remember');
            $cookie->setPath((!empty($params['path'])) ? $params['path'] : '/');
            $cookie->setDomain($params['domain']);
            $cookie->setHttpOnly($params['httponly']);
            $cookie->setSecureOnly($params['secure']);
            $cookie->delete();
        }
    }

    /**
     * @param int $userId
     * @param string|null $email
     * @param string|null $loginName
     * @param int $status
     * @param int $role
     * @param int $forceLogout
     * @param bool $remembered
     * @throws AuthError
     */
    protected function onLoginSuccessful(int $userId, ?string $email, ?string $loginName, int $status, int $role, int $forceLogout, bool $remembered = false)
    {
        // обновить метку времени последнего входа пользователя
        connect()->table('users')->where('userId', $userId)->update(['last_login' => \time()]);

        parent::onLoginSuccessful($userId, $email, $loginName, $status, $role, $forceLogout, $remembered);
    }

    /**
     * Deletes the session cookie on the client / Удаляет session cookie на клиенте
     *
     * @throws AuthError if an internal problem occurred (do *not* catch)
     */
    private function deleteSessionCookie() {
        $params = \session_get_cookie_params();

        // ask for the session cookie to be deleted (requests a cookie to be written on the client)
        $cookie = new Cookie(\session_name());
        $cookie->setPath($params['path']);
        $cookie->setDomain($params['domain']);
        $cookie->setHttpOnly($params['httponly']);
        $cookie->setSecureOnly($params['secure']);
        $result = $cookie->delete();

        if ($result === false) {
            throw new HeadersAlreadySentError();
        }
    }

    /**
     * Подтверждает адрес электронной почты (и активирует учетную запись), указав правильную пару селектор / токен
     * Confirms an email address (and activates the account) by supplying the correct selector/token pair
     *
     * Пара селектор/токен должна быть сгенерирована ранее при регистрации новой учетной записи.
     * The selector/token pair must have been generated previously by registering a new account
     *
     * @param string $selector the selector from the selector/token pair
     * @param string $token the token from the selector/token pair
     * @return string[] an array with the old email address (if any) at index zero and the new email address (which has just been verified) at index one
     * @throws InvalidSelectorTokenPairException if either the selector or the token was not correct
     * @throws TokenExpiredException if the token has already expired
     * @throws UserAlreadyExistsException if an attempt has been made to change the email address to a (now) occupied address
     * @throws TooManyRequestsException if the number of allowed attempts/requests has been exceeded
     * @throws AuthError if an internal problem occurred (do *not* catch)
     */
    public function confirmAdminEmail(string $selector, string $token)
    {
        $this->throttle(['confirmAdminEmail', $this->getIpAddress()], 5, (60 * 60), 10);
        $this->throttle(['confirmAdminEmail', 'selector', $selector], 3, (60 * 60), 10);
        $this->throttle(['confirmAdminEmail', 'token', $token], 3, (60 * 60), 10);

        try {
            $confirmationData = connect()->table('users_confirmations')
                ->join('users', 'users.userId', '=', 'user_id', '', true)
                ->select('id, user_id, email AS new_email, token, expires, users.email AS old_email')
                ->where('selector','=', $selector)
                ->get('array');
        }
        catch (Error $e) {
            throw new DatabaseError($e->getMessage());
        }

        if (!empty($confirmationData)) {
            if (\password_verify($token, $confirmationData['token'])) {
                if ($confirmationData['expires'] >= \time()) {
                    // аннулировать любые потенциальные невыполненные запросы на сброс пароля
                    connect()->table('users_resets')->where('user','=', $confirmationData['user_id'])->delete();

                    // mark the email address as verified (and possibly update it to the new address given)
                    // отметьте адрес `email` как подтвержденный (и, возможно, обновите его на новый указанный `email`)
                    try {
                        connect()->table('users')->where('userId', '=', $confirmationData['user_id'])->update([
                            'email' => $confirmationData['new_email'],
                            'verified' => 1
                        ]);
                    } catch (IntegrityConstraintViolationException $e) {
                        throw new UserAlreadyExistsException();
                    }

                    // if the user is currently signed in
                    if ($this->isLoggedIn()) {
                        // if the user has just confirmed an email address for their own account
                        if ($this->getUserId() === $confirmationData['user_id']) {
                            // immediately update the email address in the current session as well
                            $_SESSION['admin'][self::SESSION_FIELD_EMAIL] = $confirmationData['new_email'];
                        }
                    }

                    // consume the token just being used for confirmation
                    connect()->table('users_confirmations')->where('id', $confirmationData['id'])->delete();

                    // if the email address has not been changed but simply been verified
                    if ($confirmationData['old_email'] === $confirmationData['new_email']) {
                        // the output should not contain any previous email address
                        $confirmationData['old_email'] = null;
                    }

                    return [
                        $confirmationData['old_email'],
                        $confirmationData['new_email']
                    ];
                }
                else {
                    throw new TokenExpiredException();
                }
            }
            else {
                throw new InvalidSelectorTokenPairException();
            }
        }
        else {
            throw new InvalidSelectorTokenPairException();
        }
    }

    /**
     * Подтверждает адрес электронной почты и активирует учетную запись, указав правильную пару селектор/токен
     * Confirms an email address and activates the account by supplying the correct selector/token pair
     *
     * Пара селектор / токен должна быть сгенерирована ранее при регистрации новой учетной записи.
     * The selector/token pair must have been generated previously by registering a new account
     *
     * Пользователь будет автоматически авторизован, если эта операция прошла успешно.
     * The user will be automatically signed in if this operation is successful
     *
     * @param string $selector the selector from the selector/token pair
     * @param string $token the token from the selector/token pair
     * @param int|null $rememberDuration (optional) the duration in seconds to keep the user logged in ("remember me"), e.g. `60 * 60 * 24 * 365.25` for one year
     * @return string[] an array with the old email address (if any) at index zero and the new email address (which has just been verified) at index one
     * @throws InvalidSelectorTokenPairException if either the selector or the token was not correct
     * @throws TokenExpiredException if the token has already expired
     * @throws UserAlreadyExistsException if an attempt has been made to change the email address to a (now) occupied address
     * @throws TooManyRequestsException if the number of allowed attempts/requests has been exceeded
     * @throws AuthError if an internal problem occurred (do *not* catch)
     */
    public function confirmAdminEmailAndSignIn(string $selector, string $token, $rememberDuration = null)
    {
        $emailBeforeAndAfter = $this->confirmAdminEmail($selector, $token);

        if (!$this->isLoggedIn()) {
            if ($emailBeforeAndAfter[1] !== null) {
                $emailBeforeAndAfter[1] = self::validateEmailAddress($emailBeforeAndAfter[1]);

                $userData = $this->getUserDataByEmailAddress($emailBeforeAndAfter[1], ['userId', 'email', 'loginName', 'status', 'accessLevel', 'force_logout']);

                $this->onLoginSuccessful($userData['userId'], $userData['email'], $userData['loginName'], $userData['status'], $userData['accessLevel'], $userData['force_logout'], true);

                if ($rememberDuration !== null) {
                    $this->createRememberDirective($userData['userId'], $rememberDuration);
                }
            }
        }

        return $emailBeforeAndAfter;
    }

    /**
     * Изменяет текущий пароль пользователя, вошедшего в систему, при этом для проверки требуется старый пароль.
     * Changes the currently signed-in user's password while requiring the old password for verification
     *
     * @param string $oldPassword the old password to verify account ownership
     * @param string $newPassword the new password that should be set
     * @throws NotLoggedInException if the user is not currently signed in
     * @throws InvalidPasswordException if either the old password has been wrong or the desired new one has been invalid
     * @throws TooManyRequestsException if the number of allowed attempts/requests has been exceeded
     * @throws AuthError if an internal problem occurred (do *not* catch)
     */
    public function changePassword($oldPassword, $newPassword)
    {
        if ($this->reconfirmPassword($oldPassword)) {
            $this->changePasswordWithoutOldPassword($newPassword);
        }
        else {
            throw new InvalidPasswordException();
        }
    }

    /**
     * Изменяет пароль текущего авторизованного пользователя, не требуя старый пароль для проверки
     * Changes the currently signed-in user's password without requiring the old password for verification
     *
     * @param string $newPassword the new password that should be set
     * @throws NotLoggedInException if the user is not currently signed in
     * @throws InvalidPasswordException if the desired new password has been invalid
     * @throws AuthError if an internal problem occurred (do *not* catch)
     */
    public function changePasswordWithoutOldPassword($newPassword)
    {
        if ($this->isLoggedIn()) {
            $newPassword = self::validatePassword($newPassword);
            $this->updatePasswordInternal($this->getUserId(), $newPassword);

            try {
                $this->logOutEverywhereElse();
            }
            catch (NotLoggedInException $ignored) {}
        }
        else {
            throw new NotLoggedInException();
        }
    }

    /**
     * Попытки изменить адрес электронной почты текущего пользователя, вошедшего в систему (что требует подтверждения)
     * Attempts to change the email address of the currently signed-in user (which requires confirmation)
     *
     * Функция обратного вызова должна иметь следующую подпись:
     * The callback function must have the following signature:
     *
     * `function ($selector, $token)`
     *
     * Обе части информации должны быть отправлены пользователю, как правило, встроены в ссылку.
     * Both pieces of information must be sent to the user, usually embedded in a link
     *
     * Когда пользователь хочет подтвердить свой адрес электронной почты в качестве следующего шага, снова потребуются обе части.
     * When the user wants to verify their email address as a next step, both pieces will be required again
     *
     * @param string $newEmail the desired new email address
     * @param callable $callback the function that sends the confirmation email to the user
     * @throws InvalidEmailException if the desired new email address is invalid
     * @throws UserAlreadyExistsException if a user with the desired new email address already exists
     * @throws EmailNotVerifiedException if the current (old) email address has not been verified yet
     * @throws NotLoggedInException if the user is not currently signed in
     * @throws TooManyRequestsException if the number of allowed attempts/requests has been exceeded
     * @throws AuthError if an internal problem occurred (do *not* catch)
     *
     * @see confirmAdminEmail
     * @see confirmAdminEmailAndSignIn
     */
    public function changeEmail(string $newEmail, callable $callback)
    {
        if ($this->isLoggedIn()) {
            $newEmail = self::validateEmailAddress($newEmail);

            $this->throttle(['enumerateAdminUsers', $this->getIpAddress()], 1, (60 * 60), 75);

            $existingUsersWithNewEmail = connect()->table('users')->count('*', 'count')->where('email', $newEmail)->get();

            if ((int) $existingUsersWithNewEmail->count !== 0) {
                throw new UserAlreadyExistsException();
            }
            $user = connect()->table('users')->select('verified')->where('userId', $this->getUserId())->get();

            // ensure that at least the current (old) email address has been verified before proceeding
            if ((int) $user->verified !== 1) {
                throw new EmailNotVerifiedException();
            }

            $this->throttle(['requestAdminEmailChange', 'userId', $this->getUserId()], 1, (60 * 60 * 24));
            $this->throttle(['requestAdminEmailChange', $this->getIpAddress()], 1, (60 * 60 * 24), 3);

            $this->createConfirmationRequest($this->getUserId(), $newEmail, $callback);
        }
        else {
            throw new NotLoggedInException();
        }
    }

    /**
     * Попытки повторно отправить более ранний запрос подтверждения для пользователя с указанным адресом электронной почты
     * Attempts to re-send an earlier confirmation request for the user with the specified email address
     *
     * Функция обратного вызова должна иметь следующую подпись:
     * The callback function must have the following signature:
     *
     * `function ($selector, $token)`
     *
     * Обе части информации должны быть отправлены пользователю, как правило, встроены в ссылку.
     * Both pieces of information must be sent to the user, usually embedded in a link
     *
     * Когда пользователь хочет подтвердить свой адрес электронной почты в качестве следующего шага, снова потребуются обе части.
     * When the user wants to verify their email address as a next step, both pieces will be required again
     *
     * @param string $email the email address of the user to re-send the confirmation request for
     * @param callable $callback the function that sends the confirmation request to the user
     * @throws ConfirmationRequestNotFound if no previous request has been found that could be re-sent
     * @throws TooManyRequestsException if the number of allowed attempts/requests has been exceeded
     */
    public function resendConfirmationForEmail(string $email, callable $callback)
    {
        $this->throttle(['enumerateAdminUsers', $this->getIpAddress()], 1, (60 * 60), 75);

        $this->resendConfirmationForColumnValue('email', $email, $callback);
    }

    /**
     * Attempts to re-send an earlier confirmation request for the user with the specified ID
     *
     * The callback function must have the following signature:
     *
     * `function ($selector, $token)`
     *
     * Both pieces of information must be sent to the user, usually embedded in a link
     *
     * When the user wants to verify their email address as a next step, both pieces will be required again
     *
     * @param int $userId the ID of the user to re-send the confirmation request for
     * @param callable $callback the function that sends the confirmation request to the user
     * @throws ConfirmationRequestNotFound if no previous request has been found that could be re-sent
     * @throws TooManyRequestsException if the number of allowed attempts/requests has been exceeded
     */
    public function resendConfirmationForUserId(int $userId, callable $callback)
    {
        $this->resendConfirmationForColumnValue('user_id', $userId, $callback);
    }

    /**
     * Attempts to re-send an earlier confirmation request
     *
     * The callback function must have the following signature:
     *
     * `function ($selector, $token)`
     *
     * Both pieces of information must be sent to the user, usually embedded in a link
     *
     * When the user wants to verify their email address as a next step, both pieces will be required again
     *
     * You must never pass untrusted input to the parameter that takes the column name
     *
     * @param string $columnName the name of the column to filter by
     * @param mixed $columnValue the value to look for in the selected column
     * @param callable $callback the function that sends the confirmation request to the user
     * @throws ConfirmationRequestNotFound if no previous request has been found that could be re-sent
     * @throws TooManyRequestsException if the number of allowed attempts/requests has been exceeded
     * @throws AuthError if an internal problem occurred (do *not* catch)
     */
    private function resendConfirmationForColumnValue(string $columnName, $columnValue, callable $callback)
    {
        $latestAttempt = connect()->table('users_confirmations')->select('user_id, email')->where($columnName, $columnValue)->orderBy('id')->get('array');
        if ($latestAttempt === null) {
            throw new ConfirmationRequestNotFound();
        }

        $this->throttle(['resendConfirmationAdmin', 'userId', $latestAttempt['user_id']], 1, (60 * 60 * 6));
        $this->throttle(['resendConfirmationAdmin', $this->getIpAddress()], 4, (60 * 60 * 24 * 7), 2);

        $this->createConfirmationRequest($latestAttempt['user_id'], $latestAttempt['email'], $callback);
    }

    /**
     * Initiates a password reset request for the user with the specified email address
     *
     * The callback function must have the following signature:
     *
     * `function ($selector, $token)`
     *
     * Both pieces of information must be sent to the user, usually embedded in a link
     *
     * When the user wants to proceed to the second step of the password reset, both pieces will be required again
     *
     * @param string $email the email address of the user who wants to request the password reset
     * @param callable $callback the function that sends the password reset information to the user
     * @param int|null $requestExpiresAfter (optional) the interval in seconds after which the request should expire
     * @param int|null $maxOpenRequests (optional) the maximum number of unexpired and unused requests per user
     * @throws InvalidEmailException if the email address was invalid or could not be found
     * @throws EmailNotVerifiedException if the email address has not been verified yet via confirmation email
     * @throws ResetDisabledException if the user has explicitly disabled password resets for their account
     * @throws TooManyRequestsException if the number of allowed attempts/requests has been exceeded
     * @throws AuthError if an internal problem occurred (do *not* catch)
     *
     * @see canResetPasswordOrThrow
     * @see canResetPassword
     * @see resetPassword
     * @see resetPasswordAndSignIn
     */
    public function forgotPassword(string $email, callable $callback, $requestExpiresAfter = null, $maxOpenRequests = null) {
        $email = self::validateEmailAddress($email);

        $this->throttle(['enumerateAdminUsers', $this->getIpAddress()], 1, (60 * 60), 75);

        if ($requestExpiresAfter === null) {
            // use six hours as the default
            $requestExpiresAfter = 60 * 60 * 6;
        }
        else {
            $requestExpiresAfter = (int) $requestExpiresAfter;
        }

        if ($maxOpenRequests === null) {
            // use two requests per user as the default
            $maxOpenRequests = 2;
        }
        else {
            $maxOpenRequests = (int) $maxOpenRequests;
        }

        $userData = $this->getUserDataByEmailAddress($email, ['userId', 'verified', 'resettable']);

        // ensure that the account has been verified before initiating a password reset
        if ((int) $userData['verified'] !== 1) {
            throw new EmailNotVerifiedException();
        }

        // do not allow a password reset if the user has explicitly disabled this feature
        if ((int) $userData['resettable'] !== 1) {
            throw new ResetDisabledException();
        }

        $openRequests = $this->throttling ?  $this->getOpenPasswordResetRequests($userData['id']) : 0;

        if ($openRequests < $maxOpenRequests) {
            $this->throttle(['requestAdminPasswordReset', $this->getIpAddress()], 4, (60 * 60 * 24 * 7), 2);
            $this->throttle(['requestAdminPasswordReset', 'user', $userData['userId']], 4, (60 * 60 * 24 * 7), 2);

            $this->createPasswordResetRequest($userData['userId'], $requestExpiresAfter, $callback);
        }
        else {
            throw new TooManyRequestsException('', $requestExpiresAfter);
        }
    }

    /**
     * Аутентифиция существующего пользователя
     *
     * @param string|null $loginName loginName пользователя
     * @param string|null $password пароль пользователя
     * @param null $rememberDuration (optional/необязательно) время в секундах, в течение которого пользователь остается в системе («запомнить меня»), например `60 * 60 * 24 * 365,25` на один год
     * @param callable|null $onBeforeSuccess (optional/необязательно) функция, которая получает ID пользователя в качестве единственного параметра и выполняется до успешной аутентификации; должен вернуть true для продолжения или false для отмены
     * @throws AttemptCancelledException если попытка была отменена предоставленным обратным вызовом, который выполняется до успеха
     * @throws AuthError если возникла внутренняя проблема (* не * ловить)
     * @throws EmailNotVerifiedException если email address еще не был подтвержден по email с подтверждением
     * @throws Exceptions\UserRoleExistsException
     * @throws InvalidPasswordException если password был недействителен
     * @throws TooManyRequestsException если количество разрешенных попыток / запросов было превышено
     * @throws UnknownIdException
     * @throws UnknownUsernameException если была сделана попытка аутентификации с несуществующим loginName
     * @throws UsernameRequiredError если email address недействителен или не может быть найден
     * @throws ValidatePasswordException если password был недействителен
     */
    private function authenticate(?string $loginName, ?string $password, $rememberDuration = null, callable $onBeforeSuccess = null)
    {
        $this->throttle(['enumerateAdminUsers', $this->getIpAddress()], 1, (60 * 60), 75);               // перечислить пользователей
        $this->throttle(['attemptToAdminLogin', $this->getIpAddress()], 4, (60 * 60), 5, true); // попытка Войти

        $columnsToFetch = [ 'userId', 'email', 'password', 'verified', 'loginName', 'status', 'accessLevel', 'force_logout' ];

        // найти информацию об учетной записи, используя указанное loginName
        if ($loginName !== null) {
            $userData = $this->getUserDataByUsername($loginName, $columnsToFetch);
        }
        else {
            throw new UsernameRequiredError();
        }

        $userData = $this->getAdminUserDataByRole($userData['loginName'], $columnsToFetch);

        $password = self::validatePassword($password);

        if (\password_verify($password, $userData['password'])) {
            // если пароль необходимо повторно кешировать, чтобы идти в ногу с совершенствованием методов взлома паролей
            if (\password_needs_rehash($userData['password'], \PASSWORD_DEFAULT)) {
                // создать новый хеш из пароля и обновить его в базе данных
                $this->updatePasswordInternal($userData['userId'], $password);
            }

            if ($userData['verified'] == 1) {
                // функция, которая получает идентификатор пользователя в качестве единственного параметра и выполняется до успешной аутентификации;
                // должен вернуть true для продолжения или false для отмены
                if (!isset($onBeforeSuccess) || (\is_callable($onBeforeSuccess) && $onBeforeSuccess($userData['userId']) === true)) {
                    $this->onLoginSuccessful($userData['userId'], $userData['email'], $userData['loginName'], $userData['status'], $userData['accessLevel'], $userData['force_logout'], false);

                    // продолжать поддерживать старый формат параметров
                    if ($rememberDuration === true) {
                        $rememberDuration = 60 * 60 * 24 * 28;
                    } elseif ($rememberDuration === false) {
                        $rememberDuration = null;
                    }

                    if ($rememberDuration !== null) {
                        $this->createRememberDirective($userData['userId'], $rememberDuration);
                    }

                    $this->insert_history($userData['loginName'], $userData['userId'], 'login', 'ok');

                    return;
                }

                $this->throttle(['attemptToAdminLogin', $this->getIpAddress()], 4, (60 * 60), 5, false);

                if (isset($userData['loginName'])) {
                     $this->throttle(['attemptToAdminLogin', 'loginName', $userData['loginName']], 500, (60 * 60 * 24), null, false);
                }

                throw new AttemptCancelledException();
            } else {

                throw new EmailNotVerifiedException();
            }
        }

        $this->throttle(['attemptToAdminLogin', $this->getIpAddress()], 4, (60 * 60), 5, false);
        $this->throttle(['attemptToAdminLogin', 'loginName', $loginName], 500, (60 * 60 * 24), null, false);

        // мы не можем аутентифицировать пользователя из-за неправильного пароля
        throw new InvalidPasswordException();
    }

    // 'login', 'ok'
    public function insert_history($login, $userId, $action, $status): void
    {
        $history['eventOn']   = gmdate('Y-m-d H:i:s');
        $history['loginName'] = $login;
        $history['userId']    = $userId;
        $history['visitorIp'] = $this->getIpAddress();
        $history['action']    = $action;
        $history['status']    = $status;

        connect('user_history')->insert($history);

        connect('user_history')->where("eventOn < '" . gmdate('Y-m-d H:i:s') . "' - INTERVAL 1 MONTH")->delete($history);
    }

    /**
     * Возвращает запрошенные данные пользователя для учетной записи с указанным адресом электронной почты (если есть)
     * Returns the requested user data for the account with the specified email address (if any)
     *
     * Вы никогда не должны передавать ненадежный ввод в параметр, который принимает список столбцов.
     * You must never pass untrusted input to the parameter that takes the column list
     *
     * @param string $email the email address to look for
     * @param array $requestedColumns the columns to request from the user's record
     * @return array the user data (if an account was found)
     * @throws InvalidEmailException if the email address could not be found
     * @throws AuthError if an internal problem occurred (do *not* catch)
     */
    private function getUserDataByEmailAddress(string $email, array $requestedColumns)
    {
        $projection = \implode(', ', $requestedColumns);
        $userData = connect()->table('users')->select($projection)->where('email',  $email)->get('array');

        if (!empty($userData)) {
            return $userData;
        } else {
            throw new InvalidEmailException();
        }
    }

    /**
     * Возвращает количество открытых запросов на сброс пароля указанным пользователем.
     * Returns the number of open requests for a password reset by the specified user
     *
     * @param int $userId the ID of the user to check the requests for
     * @return int the number of open requests for a password reset
     * @throws AuthError if an internal problem occurred (do *not* catch)
     */
    private function getOpenPasswordResetRequests(int $userId): int
    {
        $requests = connect()->table('users_resets')->count('*', 'count')->where('user',  $userId)->where('expires','>', \time())->get();

        if (!empty($requests->count)) {
            return $requests->count;
        } else {
            return 0;
        }
    }

    /**
     * Создает новый запрос на сброс пароля
     * Creates a new password reset request
     *
     * Функция обратного вызова должна иметь следующую подпись:
     * The callback function must have the following signature:
     *
     * `function ($selector, $token)`
     *
     * Обе части информации должны быть отправлены пользователю, как правило, встроены в ссылку.
     * Both pieces of information must be sent to the user, usually embedded in a link
     *
     * Когда пользователь хочет перейти ко второму этапу сброса пароля, снова потребуются обе части.
     * When the user wants to proceed to the second step of the password reset, both pieces will be required again
     *
     * @param int $userId the ID of the user who requested the reset
     * @param int $expiresAfter the interval in seconds after which the request should expire
     * @param callable $callback the function that sends the password reset information to the user
     * @throws AuthError if an internal problem occurred (do *not* catch)
     */
    private function createPasswordResetRequest(int $userId, int $expiresAfter, callable $callback)
    {
        $selector = self::createRandomString(20);
        $token = self::createRandomString(20);
        $tokenHashed = \password_hash($token, \PASSWORD_DEFAULT);
        $expiresAt = \time() + $expiresAfter;

        connect()->table('users_resets')->insert([
            'user'      => $userId,
            'selector'  => $selector,
            'token'     => $tokenHashed,
            'expires'   => $expiresAt
        ]);

        if (\is_callable($callback)) {
            $callback($selector, $token);
        } else {
            throw new MissingCallbackError();
        }
    }

    /**
     * Сбрасывает пароль для конкретной учетной записи, указав правильную пару селектор / токен
     * Resets the password for a particular account by supplying the correct selector/token pair
     *
     * Пара selector/token должна быть сгенерирована ранее с помощью вызова {@see ForgotPassword}.
     * The selector/token pair must have been generated previously by calling {@see forgotPassword}
     *
     * @param string $selector the selector from the selector/token pair
     * @param string $token the token from the selector/token pair
     * @param string $newPassword the new password to set for the account
     * @return string[] an array with the user's ID at index `id` and the user's email address at index `email`
     * @throws InvalidSelectorTokenPairException if either the selector or the token was not correct
     * @throws TokenExpiredException if the token has already expired
     * @throws ResetDisabledException if the user has explicitly disabled password resets for their account
     * @throws InvalidPasswordException if the new password was invalid
     * @throws TooManyRequestsException if the number of allowed attempts/requests has been exceeded
     * @throws AuthError if an internal problem occurred (do *not* catch)
     *
     * @see forgotPassword
     * @see canResetPasswordOrThrow
     * @see canResetPassword
     * @see resetPasswordAndSignIn
     */
    public function resetPassword(string $selector, string $token, string $newPassword): array
    {
        $this->throttle([ 'resetAdminPassword', $this->getIpAddress() ], 5, (60 * 60), 10);
        $this->throttle([ 'resetAdminPassword', 'selector', $selector ], 3, (60 * 60), 10);
        $this->throttle([ 'resetAdminPassword', 'token', $token ], 3, (60 * 60), 10);

        connect()->table('users_resets')
            ->join('users', 'users.userId', '=', 'a.user')
            ->select('users_resets.id, users_resets.user, users_resets.token, users_resets.expires, users.email, users.resettable')
            ->where('users_resets.selector', '=', $selector)->get('array');

        if (!empty($resetData)) {
            if ((int) $resetData['resettable'] === 1) {
                if (\password_verify($token, $resetData['token'])) {
                    if ($resetData['expires'] >= \time()) {
                        $newPassword = self::validatePassword($newPassword);
                        $this->updatePasswordInternal($resetData['user'], $newPassword);
                        $this->forceLogoutForUserById($resetData['user']);


                        connect()->table('users_resets')->where('id', $resetData['id'])->delete();

                        return ['userId' => $resetData['user'], 'email' => $resetData['email']
                        ];
                    }
                    else {
                        throw new TokenExpiredException();
                    }
                }
                else {
                    throw new InvalidSelectorTokenPairException();
                }
            }
            else {
                throw new ResetDisabledException();
            }
        }
        else {
            throw new InvalidSelectorTokenPairException();
        }
    }

    /**
     * Resets the password for a particular account by supplying the correct selector/token pair
     *
     * The selector/token pair must have been generated previously by calling {@see forgotPassword}
     *
     * The user will be automatically signed in if this operation is successful
     *
     * @param string $selector the selector from the selector/token pair
     * @param string $token the token from the selector/token pair
     * @param string $newPassword the new password to set for the account
     * @param int|null $rememberDuration (optional) the duration in seconds to keep the user logged in ("remember me"), e.g. `60 * 60 * 24 * 365.25` for one year
     * @return string[] an array with the user's ID at index `id` and the user's email address at index `email`
     * @throws InvalidSelectorTokenPairException if either the selector or the token was not correct
     * @throws TokenExpiredException if the token has already expired
     * @throws ResetDisabledException if the user has explicitly disabled password resets for their account
     * @throws InvalidPasswordException if the new password was invalid
     * @throws TooManyRequestsException if the number of allowed attempts/requests has been exceeded
     * @throws AuthError if an internal problem occurred (do *not* catch)
     *
     * @see forgotPassword
     * @see canResetPasswordOrThrow
     * @see canResetPassword
     * @see resetPassword
     */
    public function resetPasswordAndSignIn(string $selector, string $token, string $newPassword, int $rememberDuration = null): array
    {
        $idAndEmail = $this->resetPassword($selector, $token, $newPassword);

        if (!$this->isLoggedIn()) {
            $idAndEmail['email'] = self::validateEmailAddress($idAndEmail['email']);

            $userData = $this->getUserDataByEmailAddress($idAndEmail['email'], ['loginName', 'status', 'accessLevel', 'force_logout']);

            $this->onLoginSuccessful($idAndEmail['userId'], $idAndEmail['email'], $userData['loginName'], $userData['status'], $userData['accessLevel'], $userData['force_logout'], true);

            if ($rememberDuration !== null) {
                $this->createRememberDirective($idAndEmail['id'], $rememberDuration);
            }
        }

        return $idAndEmail;
    }

    /**
     * Check if the supplied selector/token pair can be used to reset a password
     *
     * The password can be reset using the supplied information if this method does *not* throw any exception
     *
     * The selector/token pair must have been generated previously by calling {@see forgotPassword}
     *
     * @param string $selector the selector from the selector/token pair
     * @param string $token the token from the selector/token pair
     * @throws InvalidSelectorTokenPairException if either the selector or the token was not correct
     * @throws TokenExpiredException if the token has already expired
     * @throws ResetDisabledException if the user has explicitly disabled password resets for their account
     * @throws TooManyRequestsException if the number of allowed attempts/requests has been exceeded
     * @throws AuthError if an internal problem occurred (do *not* catch)
     * @throws InvalidPasswordException
     *
     * @see forgotPassword
     * @see canResetPassword
     * @see resetPassword
     * @see resetPasswordAndSignIn
     */
    public function canResetPasswordOrThrow(string $selector, string $token) {
        try {
            // pass an invalid password intentionally to force an expected error
            $this->resetPassword($selector, $token, null);
            // we should already be in one of the `catch` blocks now so this is not expected
            throw new AuthError();
        }
            // if the password is the only thing that's invalid
        catch (InvalidPasswordException $ignored) {
            // the password can be reset
        }
            // if some other things failed (as well)
        catch (AuthException $e) {
            // re-throw the exception
            throw $e;
        }
    }

    /**
     * Check if the supplied selector/token pair can be used to reset a password
     *
     * The selector/token pair must have been generated previously by calling {@see forgotPassword}
     *
     * @param string $selector the selector from the selector/token pair
     * @param string $token the token from the selector/token pair
     * @return bool whether the password can be reset using the supplied information
     * @throws AuthError if an internal problem occurred (do *not* catch)
     *
     * @see forgotPassword
     * @see canResetPasswordOrThrow
     * @see resetPassword
     * @see resetPasswordAndSignIn
     */
    public function canResetPassword(string $selector, string $token): bool
    {
        try {
            $this->canResetPasswordOrThrow($selector, $token);

            return true;
        }
        catch (AuthException $e) {
            return false;
        }
    }

    /**
     * Sets whether password resets should be permitted for the account of the currently signed-in user
     *
     * @param bool $enabled whether password resets should be enabled for the user's account
     * @throws NotLoggedInException if the user is not currently signed in
     * @throws AuthError if an internal problem occurred (do *not* catch)
     */
    public function setPasswordResetEnabled(bool $enabled)
    {
        if ($this->isLoggedIn()) {
            connect()->table('users')->where('userId','=', $this->getUserId())->update( ['resettable' => $enabled ? 1 : 0]);
        } else {
            throw new NotLoggedInException();
        }
    }

    /**
     * Returns whether password resets are permitted for the account of the currently signed-in user
     *
     * @return bool
     * @throws NotLoggedInException if the user is not currently signed in
     */
    public function isPasswordResetEnabled(): bool
    {
        if ($this->isLoggedIn()) {
            $enabled = connect()->table('users')->select('resettable')->where('userId',  $this->getUserId())->get();
            return (int) $enabled->resettable === 1;
        } else {
            throw new NotLoggedInException();
        }
    }

    /**
     * Возвращает, вошел ли пользователь в систему, читая данные из session.
     *
     * @return boolean whether the user is logged in or not
     */
    public function isLoggedIn(): bool
    {
        return isset($_SESSION) && isset($_SESSION['admin'][self::SESSION_FIELD_LOGGED_IN]) && $_SESSION['admin'][self::SESSION_FIELD_LOGGED_IN] === true;
    }

    /**
     * Сокращение / псевдоним для ´isLoggedIn()´
     *
     * @return boolean
     */
    public function check(): bool
    {
        return $this->isLoggedIn();
    }

    /**
     * Возвращает идентификатор пользователя, вошедшего в систему, путем чтения из сеанса
     *
     * @return int the user ID
     */
    public function getUserId(): ?int
    {
        if (isset($_SESSION) && isset($_SESSION['admin'][self::SESSION_FIELD_USER_ID])) {
            return $_SESSION['admin'][self::SESSION_FIELD_USER_ID];
        }
        else {
            return null;
        }
    }

    /**
     * Сокращение / псевдоним для {@see getUserId}
     *
     * @return int
     */
    public function id(): ?int
    {
        return $this->getUserId();
    }

    /**
     * Returns the currently signed-in user's email address by reading from the session
     * Возвращает адрес электронной почты пользователя, вошедшего в систему, путем чтения из сеанса
     *
     * @return string the email address
     */
    public function getEmail(): ?string
    {
        if (isset($_SESSION) && isset($_SESSION['admin'][self::SESSION_FIELD_EMAIL])) {
            return $_SESSION['admin'][self::SESSION_FIELD_EMAIL];
        }
        else {
            return null;
        }
    }

    /**
     * Returns the currently signed-in user's display name by reading from the session
     * Возвращает отображаемое имя текущего вошедшего в систему пользователя путем чтения из сеанса
     *
     * @return string the display name
     */
    public function getUsername(): ?string
    {
        if (isset($_SESSION) && isset($_SESSION['admin'][self::SESSION_FIELD_USERNAME])) {
            return $_SESSION['admin'][self::SESSION_FIELD_USERNAME];
        }
        else {
            return null;
        }
    }

    /**
     * Returns the currently signed-in user's status by reading from the session
     * Возвращает статус текущего вошедшего в систему пользователя путем чтения из сеанса
     *
     * @return int the status as one of the constants from the {@see Status} class
     */
    public function getStatus(): ?int
    {
        if (isset($_SESSION) && isset($_SESSION['admin'][self::SESSION_FIELD_STATUS])) {
            return $_SESSION['admin'][self::SESSION_FIELD_STATUS];
        }
        else {
            return null;
        }
    }

    /**
     * Возвращает, находится ли текущий авторизованный пользователь в "нормальном" состоянии.
     * Returns whether the currently signed-in user is in "normal" state
     *
     * @return bool
     *
     * @see Status
     * @see Administration::getStatus
     */
    public function isNormal(): bool
    {
        return $this->getStatus() === Status::NORMAL;
    }


    /**
     * banned
     */
    public function setBanned()
    {
        if (!isset($_SESSION['admin'][self::SESSION_FIELD_BANNED])) {
            $_SESSION['admin'][self::SESSION_FIELD_BANNED] = 1;
        } else {
            $_SESSION['admin'][self::SESSION_FIELD_BANNED] = $_SESSION['admin'][self::SESSION_FIELD_BANNED] + 1;
        }
    }

    /**
     * @return mixed|null
     */
    public function getBanned()
    {
        if (isset($_SESSION) && isset($_SESSION['admin'][self::SESSION_FIELD_BANNED])) {
            return $_SESSION['admin'][self::SESSION_FIELD_BANNED];
        } else {
            return null;
        }
    }


    /**
     * Возвращает, находится ли текущий авторизованный пользователь в "заблокированном" состоянии.
     * Returns whether the currently signed-in user is in "locked" state
     *
     * @return bool
     *
     * @see Status
     * @see Administration::getStatus
     */
    public function isLocked(): bool
    {
        return $this->getStatus() === Status::PENDING_REVIEW;
    }


    /**
     * Возвращает, имеет ли текущий вошедший в систему пользователь указанную роль.
     *
     * @param string $role
     * @return bool
     */
    public function hasRole(string $role): bool
    {
        if (empty($role)) {
            return false;
        }

        if (isset($_SESSION) && isset($_SESSION['admin'][self::SESSION_FIELD_ROLES])) {

            $role = (int) $role;

            return (((int) $_SESSION['admin'][self::SESSION_FIELD_ROLES]) & $role) === $role;
        }
        else {
            return false;
        }
    }

    /**
     * Возвращает true, имеет ли текущий вошедший в систему пользователь * любую * из указанных ролей.
     *
     * @param array $roles
     * @return bool
     */
    public function hasAnyRole(array $roles): bool
    {
        foreach ($roles as $role) {
            if ($this->hasRole($role)) {
                return true;
            }
        }

        return false;
    }

    /**
     * Возвращает true, есть ли у текущего вошедшего в систему пользователя * все * указанные роли.
     * Returns whether the currently signed-in user has *all* of the specified roles
     *
     * @param string[] ...$roles
     * @return bool
     */
    public function hasAllRoles(...$roles): bool
    {
        foreach ($roles as $role) {
            if (!$this->hasRole($role)) {
                return false;
            }
        }

        return true;
    }

    /**
     * Возвращает, имеет ли пользователь с данным идентификатором указанную роль
     *
     * @param int $userId the ID of the user to check the roles for
     * @param array $roles
     * @return bool
     * @throws UnknownIdException if no user with the specified ID has been found
     */
    public function doesUserHaveRole(int $userId, array $roles): bool
    {
        if (empty($roles) || !\is_array($roles)) {
            return false;
        }

        $accessLevel = connect()->table('users')->select('accessLevel')->in('userType', [UserTypes::DEVELOPER, UserTypes::ADMIN])->where('userId', $userId)
            ->in('accessLevel', $roles)->getValue();

        if ($accessLevel === null) {
            throw new UnknownIdException();
        }

        return true;
    }
    /**
     * Возвращает, запомнился ли текущий авторизованный пользователь долгоживущим файлом cookie.
     * Returns whether the currently signed-in user has been remembered by a long-lived cookie
     *
     * @return bool whether they have been remembered
     */
    public function isRemembered(): ?bool
    {
        if (isset($_SESSION) && isset($_SESSION['admin'][self::SESSION_FIELD_REMEMBERED])) {
            return $_SESSION['admin'][self::SESSION_FIELD_REMEMBERED];
        }
        else {
            return null;
        }
    }

    /**
     * Returns the user's current IP address
     *
     * @return string the IP address (IPv4 or IPv6)
     */
    public function getIpAddress(): ?string
    {
        return $this->ipAddress;
    }

    /**
     * @return bool
     */
    public function isRememberCookieName(): bool
    {
        return isset($_COOKIE[$this->rememberCookieName]);
    }


    /**
     * Выполняет регулирование или ограничение скорости с использованием алгоритма маркерной корзины (алгоритм обратного дырявой корзины)
     *
     * @param array $criteria отдельные критерии, которые вместе описывают регулируемый ресурс
     * @param int $supply количество единиц, предоставляемых за интервал (>= 1)
     * @param int $interval интервал (в секундах), на который предоставляется подача (>= 5)
     * @param int|null $burstiness (optional/необязательно) допустимая степень вариации или неравномерности во время пиков (>= 1)
     * @param bool|null $simulated (optional/необязательно) следует ли имитировать пробный запуск вместо фактического потребления запрошенных единиц
     * @param int|null $cost (optional/необязательно) количество единиц для запроса (>= 1)
     * @param bool|null $force (optional/необязательно) применять ли регулирование локально (с помощью этого вызова), даже если регулирование было отключено глобально (на экземпляре с помощью параметра конструктора)
     * @return float количество единиц, оставшихся от запаса
     * @throws TooManyRequestsException если фактический спрос превысил обозначенное предложение
     * @throws AuthError если возникла внутренняя проблема (* не * ловить)
     */
    public function throttle(array $criteria, $supply, $interval, $burstiness = null, $simulated = null, $cost = null, $force = null)
    {
        // проверьте предоставленные параметры и при необходимости установите соответствующие значения по умолчанию
        $force = ($force !== null) ? (bool) $force : false;

        if (!$this->throttling && !$force) {
            return $supply;
        }

        // сгенерировать уникальный ключ для корзины (состоящий из 44 или менее символов ASCII)
        $key = Base64::encodeUrlSafeWithoutPadding(\hash('sha256', \implode("\n", $criteria), true));

        // проверьте предоставленные параметры и при необходимости установите соответствующие значения по умолчанию
        $burstiness = ($burstiness !== null) ? (int) $burstiness : 1000;
        $simulated = ($simulated !== null) ? (bool) $simulated : false;
        $cost = ($cost !== null) ? (int) $cost : 1;

        $now = \time();

        // определить объем корзины
        $capacity = $burstiness * (int) $supply;

        // рассчитать скорость пополнения корзины (в секунду)
        $bandwidthPerSecond = (int) $supply / (int) $interval;


        $bucket = connect()->table('users_throttling')->select('tokens, replenished_at')->where('bucket', '=', $key)->get('array');
        if ($bucket === null) {
            $bucket = [];
        }

        // инициализировать количество токенов в корзины
        $bucket['tokens'] = isset($bucket['tokens']) ? (float) $bucket['tokens'] : (float) $capacity;
        // инициализировать время последнего пополнения корзины (как временная метка Unix в секундах)
        $bucket['replenished_at'] = isset($bucket['replenished_at']) ? (int) $bucket['replenished_at'] : $now;

        // пополнить корзину по мере необходимости
        $secondsSinceLastReplenishment = \max(0, $now - $bucket['replenished_at']);
        $tokensToAdd = $secondsSinceLastReplenishment * $bandwidthPerSecond;
        $bucket['tokens'] = \min((float) $capacity, $bucket['tokens'] + $tokensToAdd);
        $bucket['tokens'] = str_replace(',', '.', $bucket['tokens']);

        $bucket['replenished_at'] = $now;

        $accepted = $bucket['tokens'] >= $cost;

        if (!$simulated) {
            if ($accepted) {
                // удалить запрошенное количество токенов из корзины
                $bucket['tokens'] = \max(0, $bucket['tokens'] - $cost);
                $bucket['tokens'] = str_replace(',', '.', $bucket['tokens']);
            }

            // установить самое раннее время, после которого корзина *может* быть удалена (как временная метка Unix в секундах)
            $bucket['expires_at'] = $now + \floor($capacity / $bandwidthPerSecond * 2);

            // объединить обновленную корзину с базой данных
            $affected = connect()->table('users_throttling')->where('bucket', '=', $key)->update($bucket);

            if ($affected === 0) {
                $bucket['bucket'] = $key;

                try {
                    connect()->table('users_throttling')->insert($bucket);
                }
                catch (IntegrityConstraintViolationException $ignored) {}

            }
        }

        if ($accepted) {
            return $bucket['tokens'];
        }
        else {
            $tokensMissing = $cost - $bucket['tokens'];
            $estimatedWaitingTimeSeconds = \ceil($tokensMissing / $bandwidthPerSecond);

            throw new TooManyRequestsException('', $estimatedWaitingTimeSeconds);
        }
    }

//    /**
//     * Returns the component that can be used for administrative tasks
//     * Возвращает компонент, который можно использовать для административных задач.
//     *
//     * Вы должны предлагать доступ к этому интерфейсу только авторизованным пользователям (ограничен вашим собственным контролем доступа)
//     *
//     * @return Administration
//     */
//    public function admin(): Administration
//    {
//        return new Administration();
//    }

    /**
     * Creates a UUID v4 as per RFC 4122
     *
     * The UUID contains 128 bits of data (where 122 are random), i.e. 36 characters
     *
     * @return string the UUID
     * @author Jack @ Stack Overflow
     */
    public static function createUuid()
    {
        $data = \openssl_random_pseudo_bytes(16);

        // set the version to 0100
        $data[6] = \chr(\ord($data[6]) & 0x0f | 0x40);
        // set bits 6-7 to 10
        $data[8] = \chr(\ord($data[8]) & 0x3f | 0x80);

        return \vsprintf('%s%s-%s-%s-%s-%s%s%s', \str_split(\bin2hex($data), 4));
    }

    /**
     * Generates a unique cookie name for the given descriptor based on the supplied seed
     *
     * @param string $descriptor a short label describing the purpose of the cookie, e.g. 'session'
     * @param string|null $seed (optional) the data to deterministically generate the name from
     * @return string
     */
    public static function createCookieName(string $descriptor, ?string $seed = null): string
    {
        // use the supplied seed or the current UNIX time in seconds
        $seed = ($seed !== null) ? $seed : \time();

        foreach (self::COOKIE_PREFIXES as $cookiePrefix) {
            // if the seed contains a certain cookie prefix
            if (\strpos($seed, $cookiePrefix) === 0) {
                // prepend the same prefix to the descriptor
                $descriptor = $cookiePrefix . $descriptor;
            }
        }

        // generate a unique token based on the name(space) of this library and on the seed
        $token = Base64::encodeUrlSafeWithoutPadding(\md5(__NAMESPACE__ . "\n" . $seed, true));

        return $descriptor . '_' . $token;
    }

    /**
     * Generates a unique cookie name for the 'remember me' feature
     * Создает уникальное имя файла cookie для функции «запомнить меня»
     *
     * @param string|null $sessionName (optional) session name на котором должен быть основан вывод
     * @return string
     */
    public static function createRememberCookieName(?string $sessionName = null): string
    {
        return self::createCookieName('remember_mnv', ($sessionName !== null) ? $sessionName : \session_name());
    }

    /**
     * Returns the selector of a potential locally existing remember directive
     * Возвращает селектор потенциально существующей локально директивы запоминания.
     *
     * @return string|null
     */
    private function getRememberDirectiveSelector(): ?string
    {
        if (isset($_COOKIE[$this->rememberCookieName])) {
            $selectorAndToken = \explode(self::COOKIE_CONTENT_SEPARATOR, $_COOKIE[$this->rememberCookieName], 2);

            return $selectorAndToken[0];
        }
        else {
            return null;
        }
    }

    /**
     * Возвращает дату истечения срока действия потенциально существующей локально директивы запоминания.
     * Returns the expiry date of a potential locally existing remember directive
     *
     * @return int|null
     */
    private function getRememberDirectiveExpiry(): ?int
    {
        // если пользователь в настоящее время вошел в систему
        if ($this->isLoggedIn()) {
            // определить селектор любой существующей в настоящее время директивы запоминания
            $existingSelector = $this->getRememberDirectiveSelector();

            // если в настоящее время существует директива запоминания, селектор которой мы только что получили
            if (isset($existingSelector)) {
                // получить дату истечения срока действия для данного селектора
                $existingExpiry = connect()->table('users_remembered')->select('expires')->where('selector',  $existingSelector)->where('user',  $this->getUserId())->get();

                // если установлен срок годности
                if (isset($existingExpiry->expires)) {
                    return (int) $existingExpiry->expires;
                }
            }
        }

        return null;
    }

}