<?php

namespace Mnv\Modules\User;


use Mnv\Core\Auth\Status;
use Mnv\Core\Database\Throwable\IntegrityConstraintViolationException;
use Mnv\Core\Utilities\Cookie\Session;
use Mnv\Core\Utilities\Base64\Base64;

use Mnv\Core\Auth\Errors\AuthError;
use Mnv\Core\Auth\Errors\DatabaseError;
use Mnv\Core\Auth\Errors\MissingCallbackError;
use Mnv\Core\Auth\Exceptions\UnknownIdException;
use Mnv\Core\Auth\Exceptions\InvalidPhoneException;
use Mnv\Core\Auth\Exceptions\InvalidEmailException;
use Mnv\Core\Auth\Exceptions\UserRoleExistsException;
use Mnv\Core\Auth\Exceptions\InvalidPasswordException;
use Mnv\Core\Auth\Exceptions\UnknownUsernameException;
use Mnv\Core\Auth\Exceptions\ValidatePasswordException;
use Mnv\Core\Auth\Exceptions\AmbiguousUsernameException;
use Mnv\Core\Auth\Exceptions\DuplicateUsernameException;
use Mnv\Core\Auth\Exceptions\UserAlreadyExistsException;

use Mnv\Core\Validations\ValidateEmail;
use Mnv\Core\Validations\ValidatePhone;

/**
 * Абстрактный базовый класс для компонентов, реализующих управление пользователями
 *
 * @internal
 */
abstract class UserManager {

    /** SITE USER */
    /** @var string session field for whether the client is currently signed in */
    const SESSION_USER_LOGGED_IN = 'site_user_logged_in';
    /** @var string session field for the ID of the user who is currently signed in (if any) */
    const SESSION_USER_USER_ID = 'site_user_user_id';
    /** @var string session field for the email address of the user who is currently signed in (if any) */
    const SESSION_USER_EMAIL = 'site_user_email';
    /** @var string session field for the email address of the user who is currently signed in (if any) */
    const SESSION_USER_PHONE = 'site_user_phone';
    /** @var string session field for the display name (if any) of the user who is currently signed in (if any) */
    const SESSION_USER_USERNAME = 'site_user_username';
    /** @var string session field for the status of the user who is currently signed in (if any) as one of the constants from the {@see Status} class */
    const SESSION_USER_STATUS = 'site_user_status';
    /** @var string session field for the roles of the user who is currently signed in (if any) as a bitmask using constants */
    const SESSION_USER_ROLES = 'site_user_roles';
    /** @var string session field for whether the user who is currently signed in (if any) has been remembered (instead of them having authenticated actively) */
    const SESSION_USER_REMEMBERED = 'site_user_remembered';
    /** @var string session field for the UNIX timestamp in seconds of the session data's last resynchronization with its authoritative source in the database */
    const SESSION_USER_LAST_RESYNC = 'site_user_last_resync';
    /** @var string session field for the counter that keeps track of forced logouts that need to be performed in the current session */
    const SESSION_USER_FORCE_LOGOUT = 'site_user_force_logout';
    /** @var string session field limit content count page */
    const SESSION_USER_LIMIT = 'site_user_limit';
    /** @var string session field info успешное регистрирование данных */
    const SESSION_USER_INFO = 'site_user_info';
    /** @var string session field info не удачное регистрирование данных */
    const SESSION_USER_ERROR = 'site_user_error';
    /** @var string session field info успешное регистрирование данных */
    const SESSION_USER_BANNED = 'site_user_banned';
    /** @var string session field info успешное регистрирование данных */
    const SESSION_USER_FULL_NAME   = 'site_user_full_name';
    /** @var string session field info успешное регистрирование данных */
    const SESSION_USER_FIRST_NAME  = 'site_user_first_name';
    /** @var string session field info успешное регистрирование данных */
    const SESSION_USER_LAST_NAME   = 'site_user_last_name';
    const SESSION_USER_PHOTO   = 'site_user_last_name';

    const SESSION_USER_PASSPORT = 'site_user_passport';
    const SESSION_USER_PINFL = 'site_user_pinfl';
    

	/**
	 * Создает случайную строку с заданной максимальной длиной
	 * С параметром по умолчанию вывод должен содержать как минимум столько же случайности, сколько UUID.
	 *
	 * @param int $maxLength the maximum length of the output string (integer multiple of 4)
	 * @return string the new random string
     * @throws \Mnv\Core\Utilities\Base64\Throwable\EncodingError
     */
	public static function createRandomString($maxLength = 24)
	{
		// вычислить, сколько байтов случайности нам нужно для указанной длины строки
		$bytes = \floor((int) $maxLength / 4) * 3;

		// получить случайные данные
		$data = \openssl_random_pseudo_bytes($bytes);

		// вернуть результат в кодировке Base64
		return Base64::encodeUrlSafe($data);
	}

    /**
     * @param $len
     * @return string
     */
    public static function createRandomInt($maxLength)
    {
        $characters = '123456789';
        $charactersLength = strlen($characters);
        $randomString = '';
        for ($i = 0; $i < $maxLength; $i++) {
            $randomString .= $characters[rand(0, $charactersLength - 1)];
        }
        return $randomString;
    }

    /**
     * @param int $length
     * @return false|string
     */
    public function generateRandomString($length = 10)
    {
        return substr(str_shuffle(str_repeat($x='0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', ceil($length/strlen($x)) )),1, $length);
    }


	/**
     * Создает нового пользователя
     * Если вы хотите, чтобы учетная запись пользователя была активирована по умолчанию, передайте `null` в качестве обратного вызова.
     * Если вы хотите, чтобы пользователь сначала подтвердил `verify` свой адрес электронной почты, передайте анонимную функцию в качестве обратного вызова `callback`.
     * Функция обратного вызова должна иметь следующую подпись: `function ($selector, $token)`
     * Обе части информации должны быть отправлены пользователю, обычно встроенные в ссылку.
     * Когда пользователь хочет подтвердить свой адрес электронной почты в качестве следующего шага, обе части потребуются снова.
	 *
	 * @param bool $requireUniqueUsername whether it must be ensured that the username is unique
	 * @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 has been invalid
	 * @throws ValidatePasswordException if the password has been invalid
	 * @throws UserAlreadyExistsException if a user with the specified email address already exists
	 * @throws DuplicateUsernameException if it was specified that the loginName must be unique while it was *not*
	 * @throws AuthError if an internal problem occurred (do *not* catch)
	 *
	 * @see confirmEmail
	 * @see confirmEmailAndSignIn
	 */
	protected function createUserInternal($requireUniqueUsername, $email, $password, $loginName = null, callable $callback = null)
	{
		\ignore_user_abort(true);

		$email =  ValidateEmail::fromString($email); // self::validateEmailAddress($email);
		$password = self::validatePassword($password);

        $loginName = isset($loginName) ? \trim($loginName) : null;

        // если предоставленное `loginName` является пустой строкой или состояло только из пробелов
		if ($loginName === '') $loginName = null;

		// если нужно обеспечить уникальность `loginName`
		if ($requireUniqueUsername) {
			//если `username` действительно было предоставлено
			if ($loginName !== null) {
				// подсчитайте количество пользователей, у которых уже есть указанное `loginName`
                $occurrencesOfUsername = connect()->table('users')->count('*', 'count')->where('loginName','=', $loginName)->get();

				// если какой-либо пользователь с таким `loginName` уже существует
				if ($occurrencesOfUsername->count > 0) {
					// отменить операцию и сообщить о нарушении данного требования
					throw new DuplicateUsernameException();
				}
			}
		}

		$password = \password_hash($password, \PASSWORD_DEFAULT);
		$verified = \is_callable($callback) ? 0 : 1;

		try {
            $newUserId = connect()->table('users')->insert([
                'email'         => $email,
                'password'      => $password,
                'loginName'     => $loginName,
                'verified'      => $verified,
                'registered'    => \time(),
            ]);
		}
		// если у нас есть повторяющаяся запись
		catch (IntegrityConstraintViolationException $e) {
			throw new UserAlreadyExistsException();
		}

		if ($verified === 0) {
			$this->createConfirmationRequest($newUserId, $email, $callback);
		}

		return $newUserId;
	}

    /**
     * Создает нового пользователя
     * Если вы хотите, чтобы учетная запись пользователя не была активирована по умолчанию, передайте `null` в качестве `callback`.
     * Если вы хотите, чтобы пользователь сначала подтвердил `verify` свой аккаунт, передайте анонимную функцию в качестве `callback`.
     * Функция `callback` должна иметь следующую подпись: `function ($selector, $token)`
     * Обе части информации должны быть отправлены пользователю, обычно встроенные в ссылку.
     * Когда пользователь хочет подтвердить свой аккаунт в качестве следующего шага, обе части потребуются снова.
     *
     * @param bool $unique whether it must be ensured that the email is unique
     * @param bool $requireUnique whether it must be ensured that the email is unique
     * @param array $user пользовательские данные для записи в базу
     * @param int $role роль пользователя в данном случае `Role::CONSUMER`
     * @param int $confirmType через что подтверждать email/phone
     * @param int $confirm подтверждать или не подтверждать
     * @param callable|null $callback (optional) функция, которая отправляет пользователю письмо с подтверждением
     * @return int the ID of the user that has been created (if any)
     * @throws InvalidEmailException if the email address has been invalid
     * @throws ValidatePasswordException if the password has been invalid
     * @throws UserAlreadyExistsException if a user with the specified email address already exists
     * @throws DuplicateUsernameException if it was specified that the loginName must be unique while it was *not*
     * @throws AuthError if an internal problem occurred (do *not* catch)
     *
     * @see confirmEmail
     * @see confirmEmailAndSignIn
     */
    protected function createSiteUserInternal($unique, $requireUnique, $user, $role, $confirmType, $confirm, callable $callback = null)
    {
        // Установить, должно ли отключение клиента прерывать выполнение скрипта
        \ignore_user_abort(true);

        $user['email'] = $unique == 'email' ? ValidateEmail::fromString($user['email']) : null;
        $user['phone'] =  $unique == 'phone' ? ValidatePhone::fromString($user['phone']) : null;
//        $user['password'] = self::validatePassword($user['password']);


        if (!isset($user['fullName']) || empty($user['fullName'])) $user['fullName'] = $user['firstName'] . ' ' . $user['lastName'];
        if (!isset($user['firstName']) || empty($user['firstName'])) $user['firstName'] = null;
        if (!isset($user['lastName']) || empty($user['lastName'])) $user['lastName'] = null;
        if (!isset($user['phone']) || empty($user['phone'])) $user['phone'] = null;
        if (!isset($user['loginName']) || empty($user['loginName'])) $user['loginName'] = $user[$unique];

        // если нужно обеспечить уникальность `email`
        if ($requireUnique) {
            //если `username` действительно было предоставлено
            if ($user[$unique] !== null) {
                // подсчитайте количество пользователей, у которых уже есть указанное поле
                $occurrencesOfUsername = connect('users')->count('*', 'count')->where($unique,  $user[$unique])->get('array');
                // если какой-либо пользователь с таким `email` уже существует
                if ($occurrencesOfUsername['count'] > 0) {
                    // отменить операцию и сообщить о нарушении данного требования
                    throw new DuplicateUsernameException();
                }
            }
        }

        $user['password'] = \password_hash($user['password'], \PASSWORD_DEFAULT);
        $verified = \is_callable($callback) ? 0 : 1;
        $status = \is_callable($callback) ? Status::PENDING_REVIEW : Status::NORMAL;

        try {
            $newUserId = connect('users')->insert([
                'email'         => $user['email'],
                'password'      => $user['password'],
                'loginName'     => $user['loginName'],
                'fullName'      => $user['fullName'],
                'firstName'     => $user['firstName'],
                'lastName'      => $user['lastName'],
                'phone'         => $user['phone'],
                'verified'      => $verified,
                'registered'    => \time(),
                'accessLevel'   => $role,
                'status'        => $status,
            ]);
        }
        // если у нас есть повторяющаяся запись
        catch (IntegrityConstraintViolationException $e) {
            throw new UserAlreadyExistsException();
        }

        if ($verified === 0 && $confirm === 1) {
            if ($confirmType == 'phone') {
                $this->createConfirmationRequestSms($newUserId, $user['phone'], null,  $callback);
            } else {
                $this->createConfirmationRequest($newUserId, $user['email'], $callback);
            }
        }

        return $newUserId;
    }


    /**
	 * Обновляет пароль данного пользователя, устанавливая новый указанный пароль
	 *
	 * @param int $userId  userID, пароль которого должен быть обновлен
	 * @param string $newPassword новый пароль
	 * @throws UnknownIdException если пользователь с указанным ID не найден
	 */
	protected function updatePasswordInternal($userId, $newPassword)
    {
		$newPassword = \password_hash($newPassword, \PASSWORD_DEFAULT);
		$affected = connect()->table('users')->where('userId', '=', $userId)->update(['password' => $newPassword]);

		if ($affected === 0) {
		    throw new UnknownIdException();
		}


	}

    /**
     * Вызывается, когда пользователь успешно вошел в систему
     * Это может произойти через стандартный вход в систему, через функцию «запомнить меня»
     *
     * @param int $userId the ID of the user
     * @param string $email the email address of the user
     * @param string $loginName the display name (if any) of the user
     * @param int $status
     * @param string $roles
     * @param int $forceLogout the counter that keeps track of forced logouts that need to be performed in the current session
     * @param bool $remembered whether the user has been remembered (instead of them having authenticated actively)
     */
    protected function onLoginUserSuccessful($userId, $fullName, $firstName, $lastName, $email, $loginName, $status, $roles, $forceLogout, $remembered)
    {

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

        // save the user data in the session variables maintained by this library
        $_SESSION['siteUser'][self::SESSION_USER_LOGGED_IN]    = true;
        $_SESSION['siteUser'][self::SESSION_USER_USER_ID]      = (int) $userId;
        $_SESSION['siteUser'][self::SESSION_USER_EMAIL]        = $email;
        $_SESSION['siteUser'][self::SESSION_USER_USERNAME]     = $loginName;
        $_SESSION['siteUser'][self::SESSION_USER_PHONE]        = $loginName;
        $_SESSION['siteUser'][self::SESSION_USER_STATUS]       = (int) $status;
        $_SESSION['siteUser'][self::SESSION_USER_ROLES]        = $roles;
        $_SESSION['siteUser'][self::SESSION_USER_FORCE_LOGOUT] = (int) $forceLogout;
        $_SESSION['siteUser'][self::SESSION_USER_REMEMBERED]   = $remembered;
        $_SESSION['siteUser'][self::SESSION_USER_FULL_NAME]    = $fullName;
        $_SESSION['siteUser'][self::SESSION_USER_FIRST_NAME]   = $firstName;
        $_SESSION['siteUser'][self::SESSION_USER_LAST_NAME]    = $lastName;
        $_SESSION['siteUser'][self::SESSION_USER_LAST_RESYNC]  = \time();
        $_SESSION['siteUser'][self::SESSION_USER_PASSPORT]     = '';
        $_SESSION['siteUser'][self::SESSION_USER_PINFL]        = '';


    }


	/**
	 * Проверяет пароль
	 *
	 * @param string $password пароль для проверки
	 * @return string очищенный пароль
	 * @throws ValidatePasswordException если пароль был пустым и кол-во символов меньше 3
	 */
	protected static function validatePassword($password)
    {
		if (empty($password)) {
			throw new ValidatePasswordException();
		}

		$password = \trim($password);

		if (\strlen($password) < 3) {
			throw new ValidatePasswordException();
		}

		return $password;
	}

    /**
     * Создает запрос на подтверждение по email
     * Функция обратного вызова должна иметь следующую подпись: `function ($selector, $token)`
     * Обе части информации должны быть отправлены пользователю, обычно встроенные в ссылку.
     * Когда пользователь хочет подтвердить свой адрес электронной почты в качестве следующего шага, обе части потребуются снова.
     *
     * @param int $userId the user's ID
     * @param string $email the email address to verify
     * @param callable $callback the function that sends the confirmation email to the user
     * @throws MissingCallbackError
     */
	protected function createConfirmationRequest($userId, $email, callable $callback)
	{
		$selector = self::createRandomString(18);
		$token = self::createRandomString(18);
		$tokenHashed = \password_hash($token, \PASSWORD_DEFAULT);
		$expires = \time() + 60 * 60 * 24;

		connect()->table('users_confirmations')->insert([
		    'user_id'   => (int) $userId,
            'email'     => $email,
            'selector'  => $selector,
            'token'     => $tokenHashed,
            'expires'   => $expires
        ]);


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

    /**
     * Создает запрос на подтверждение по email
     * Функция обратного вызова должна иметь следующую подпись: `function ($selector, $token)`
     * Обе части информации должны быть отправлены пользователю, обычно встроенные в ссылку.
     * Когда пользователь хочет подтвердить свой адрес электронной почты в качестве следующего шага, обе части потребуются снова.
     *
     * @param int $userId the user's ID
     * @param string $phone the email address to verify
     * @param int|null $expiresAfter
     * @param callable $callback the function that sends the confirmation phone to the user
     * @throws AuthError if an internal problem occurred (do *not* catch)
     */
    protected function createConfirmationRequestSms($userId, $phone, $expiresAfter, callable $callback)
    {
        $selector = self::createRandomInt(4);
        $tokenHashed = \password_hash($selector, \PASSWORD_DEFAULT);
        $expires = !is_null($expiresAfter) ? \time() + $expiresAfter : \time() + 60 * 60 * 24;

        connect()->table('users_confirmations')->insert([
            'user_id'   => (int) $userId,
            'email'     => $phone,
            'selector'  => $selector,
            'token'     => $tokenHashed,
            'expires'   => $expires
        ]);

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


    /**
     * @throws MissingCallbackError
     */
    public function createConfirmationRequestPhone($userId, $phone, $expiresAfter, callable $callback)
    {
        $codeSms = self::createRandomInt(4);
        $tokenHashed = \password_hash($codeSms, \PASSWORD_DEFAULT);
        $expires = !is_null($expiresAfter) ? \time() + $expiresAfter : \time() + 60 * 60 * 24;

        $userData = connect()->table('users_confirmations')->where('user_id',  (int) $userId)->get();
        if (!empty($userData)) {
            connect()->table('users_confirmations')->where('user_id', (int)$userId)->where('email', $phone)->update([
                'selector'  => $codeSms,
                'token'     => $tokenHashed,
                'expires'   => $expires
            ]);
        } else {
            connect()->table('users_confirmations')->insert([
                'user_id'   => (int) $userId,
                'email'     => $phone,
                'selector'  => $codeSms,
                'token'     => $tokenHashed,
                'expires'   => $expires
            ]);
        }

        if (\is_callable($callback)) {
            $callback($phone, $codeSms);
        }
        else {
            throw new MissingCallbackError();
        }
    }

    /**
     * @throws MissingCallbackError
     * @throws InvalidPhoneException
     */
    public function createRepeatPhoneCode($phone, $expiresAfter, callable $callback)
    {
        $codeSms = self::createRandomInt(4);
        $tokenHashed = \password_hash($codeSms, \PASSWORD_DEFAULT);
        $expires = !is_null($expiresAfter) ? \time() + $expiresAfter : \time() + 60 * 60 * 24;

        $userData = connect('users_confirmations')->where('email', $phone)->get('array');
        connect('users_confirmations')->where('user_id', $userData['user_id'])->where('email', $phone)->delete();

        if (!empty($userData)) {
            connect()->table('users_confirmations')->where('user_id', $phone)->where('email', $phone)->insert([

                'user_id'   => (int) $userData['user_id'],
                'email'     => $phone,
                'selector'  => $codeSms,
                'token'     => $tokenHashed,
                'expires'   => $expires
            ]);
        } else {
            throw new InvalidPhoneException();
        }

        if (\is_callable($callback)) {
            $callback($phone, $codeSms);
        } else {
            throw new MissingCallbackError();
        }
    }

	/**
	 * Удаляет существующую директиву, которая удерживает пользователя в системе («запомни меня»).
	 *
	 * @param int $userId the ID of the user who shouldn't be kept signed in anymore
	 * @param string $selector (optional) the selector which the deletion should be restricted to
	 * @throws AuthError if an internal problem occurred (do *not* catch)
	 */
	protected function deleteRememberDirectiveForUserById($userId, $selector = null)
    {
		if (isset($selector)) connect()->where('selector', '=', (string) $selector);
		connect()->table('users_remembered')->where('user','=', (int) $userId)->delete();

	}

	/**
	 * Запускает принудительный выход из системы во всех сеансах, принадлежащих указанному пользователю.
	 *
	 * @param int $userId ID пользователя для выхода
	 * @throws AuthError если возникла внутренняя проблема (do *not* catch)
	 */
	protected function forceLogoutForUserById($userId)
    {
		$this->deleteRememberDirectiveForUserById($userId);
        $user = connect()->table('users')->select('force_logout')->where('userId', $userId)->get();
        $user->force_logout = $user->force_logout + 1;

        connect()->table('users')->where('userId', '=', $userId)->update(['force_logout' => $user->force_logout]);
	}
}
