<?php

namespace Velis\App\User;

use Exception;
use InvalidArgumentException;
use Laminas\Validator\Ip;
use Velis\Api\Token;
use Velis\App\User;
use Velis\App;
use Velis\Arrays;
use Velis\Filter;
use Velis\Http\Request;
use Velis\Lang;
use Velis\Model\BaseModel;
use Velis\Model\DataObject\NoColumnsException;
use Velis\Output;
use Velis\Session\SessionInterface;
use Velis\Timezone;

/**
 * Application user authentication adapter
 * @author Olek Procki <olo@velis.pl>
 */
class Authentication extends BaseModel
{
    const STANDARD  = 'standard';
    const TOKEN     = 'token';

    const HASH_MD5 = 0;
    const HASH_BCRYPT = 1;

    /**
     * Authentication method
     * @var string
     */
    protected $_method;


    /**
     * Additional parameters
     * @var array
     */
    protected $_params = [];


    /**
     * User login
     * @var string
     */
    protected $_login;


    /**
     * User password
     * @var string
     */
    protected $_password;


    /**
     * Loaded user data
     * @var array
     */
    protected $_userData;


    /**
     * User's last password change date
     * @var string
     */
    protected $_lastPasswordChangeInfo;


    /**
     * Service constructor
     * @param string $method
     * @param array|null $params
     */
    public function __construct($method = self::STANDARD, array $params = null)
    {
        $this->setMethod($method);

        if ($params !== null) {
            $this->setParams($params);
        }
    }


    /**
     * Sets authentication method
     * @param string $method
     */
    public function setMethod($method)
    {
        $this->_method = $method;
    }


    /**
     * Sets authentication params
     * @param array $params
     */
    public function setParams(array $params)
    {
        $this->_params = $params;
    }


    /**
     * Perform authentication
     */
    public function perform()
    {
        $func = '_' . $this->_method . 'Login';

        if (!method_exists($this, $func)) {
            throw new InvalidArgumentException('Invalid authentication method');
        }

        return call_user_func_array([$this, $func], func_get_args());
    }


    /**
     * Standard login method
     *
     * @param string $login
     * @param string $password
     * @return bool
     * @throws Exception
     */
    protected function _standardLogin($login, $password)
    {
        $this->_login    = $login;
        $this->_password = $this->_params['resetToken'] ? false : $password;

        $this->_validateExternalIp();

        if (!$this->_loadUserData()) {
            if ($this->_params['resetToken']) {
                throw new Exception(
                    sprintf(Lang::get('USER_NOTIFICATION_LOGIN_LINK_HAS_BEEN_USED'), Lang::get('GENERAL_PASSWORD_FORGOTTEN')),
                    User::ACTIVATION_LINK_HAS_BEEN_USED
                );
            }

            return false;
        }

        if (
            App::$user->activated &&
            !(App::demoMode() && App::$user->isDemoAccount())
        ) {
            $this->_checkRequestMethod();
        }

        $this
            ->_checkExpiration()
            ->_checkFailedLoginCount()
            ->_validatePassword()
            ->_deactivateIfExpired()
            ->_updateUser()
            ->_prepareSession()
            ->_setExternalEmail()
        ;

        App::$session->userData = App::$user->getArrayCopy();
        App::$user->initAcl();

        Timezone::setTimezone();

        return true;
    }

    /**
     * Login with token
     *
     * @param Request $request
     * @return bool
     */
    protected function _tokenLogin($request)
    {
        if ($request instanceof Request) {
            $token = $request->authorization();
        } else {
            $token = $request;
        }

        if ($token = Token::instance($token)) {
            $this->_userData = $token->getUser();
            $this->_userData->getSubstitutedUserIds();
            App::$user->append($this->_userData);

            App::$session->isLogged = true;
            App::$session->userIp = $_SERVER['REMOTE_ADDR'];
            App::$session->userData = App::$user->getArrayCopy();

            App::$user->initAcl();
            Timezone::setTimezone();

            return true;
        }

        return false;
    }

    /**
     * Returns true if external login
     * @return $this
     */
    protected function _validateExternalIp()
    {
        $ipValidator = new Ip();

        if (
            App::$registry['filter']['externalLogin']
            && $ipValidator->isValid(App::$registry['filter']['externalLogin'])
        ) {
            $_SERVER['REMOTE_ADDR'] = App::$registry['filter']['externalLogin'];
        }

        return $this;
    }


    /**
     * Loads user data
     * @return array|bool
     */
    protected function _loadUserData()
    {
        $isEmailLogin = Filter::validateEmail($this->_login);
        $baseQuery = "SELECT * FROM " . $this->_params['dataSource'] . " WHERE active = 1 AND enabled = 1";

        if ($this->_params['resetToken']) {
            $params['reset_password_token'] = $this->_params['resetToken'];
            $baseQuery .= " AND reset_password_token = :reset_password_token";
        }

        if (!$this->_params['mobile'] && in_array('user_origin_id', User::getObjectFields())) {
            $baseQuery .= " AND external_auth <> 1";
        }

        $query = $baseQuery;

        if ($isEmailLogin) {
            // When using email, we first try to match by login (case insensitive)
            $params['lowercase_login'] = strtolower($this->_login);
            $query .= " AND lower(login) = :lowercase_login";
        } else {
            // When using login we expect exact match (case sensitive)
            $params['login'] = $this->_login;
            $query .= " AND login = :login";
        }

        $users = self::$_db->getAll($query, $params);

        if (sizeof($users) !== 1) {
            // When using login we expect one user to be returned, as logins as unique.
            if (!$isEmailLogin) {
                return false;
            }

            // When using email, if it doesn't match any existing login or more
            // than one user is returned, we try to match by email (case insensitive).
            unset($params['login']);
            unset($params['lowercase_login']);
            $query = $baseQuery;

            $params['lowercase_email'] = strtolower($this->_login);
            $query .= " AND lower(email) = :lowercase_email";

            $users = self::$_db->getAll($query, $params);

            // For successful login we expect exactly one user to be returned.
            if (sizeof($users) !== 1) {
                return false;
            }
        }

        $this->_userData = Arrays::getFirst($users);

        $this->_userData['substitute_for'] = array_filter(explode(',', $this->_userData['substitute_for'] ?? ''));
        App::$user->append($this->_userData);

        return $this->_userData;
    }

    /**
     * Check the request method to login, only the POST request allow to login user.
     *
     * @return $this
     */
    protected function _checkRequestMethod()
    {
        $request = App::getService('request');

        if (!$request->isPost()) {
            throw new Exception(Lang::get('GENERAL_LOGIN_ERROR'), User::INVALID_REQUEST_METHOD);
        }

        return $this;
    }

    /**
     * Checks account expiration
     *
     * @return $this
     * @throws Exception
     */
    protected function _checkExpiration()
    {
        if ($this->_params['resetToken'] && date($this->_userData['reset_password_date_expiry']) < date('Y-m-d H:i:s')) {
            throw new Exception(Lang::get('USER_RESET_TOKEN_EXPIRED'), User::RESET_TOKEN_EXPIRED);
        }

        if (App::demoMode() && App::$user->isExpired()) {
            throw new Exception(Lang::get('USER_ACCOUNT_EXPIRED'), User::DEMO_EXPIRED_CODE);
        }

        return $this;
    }


    /**
     * Checks failed login count
     *
     * @return $this
     * @throws Exception
     */
    protected function _checkFailedLoginCount()
    {
        if (
            App::$config->settings->failedLoginCount
            && App::$user->unsuccessful_login_count >= App::$config->settings->failedLoginCount
        ) {
            if (App::$user->enabled) {
                App::$user->enabled = 0;
                App::$user->modify();
            }
            throw new Exception(Lang::get('USER_ACCOUNT_HAS_BEEN_BLOCKED'), User::ACCOUNT_BLOCKED_CODE);
        }

        return $this;
    }


    /**
     * Validate password & handle failure
     *
     * @return $this
     * @throws Exception
     */
    protected function _validatePassword()
    {
        // reset unsuccessful login counter when password is valid
        $isPasswordReset = $this->_password === false && isset($this->_params['resetToken']);
        if (
            $isPasswordReset
            || App::$user->validatePassword($this->_password, $this->_params['mobile'], $this->_params['oneTimePass'])
        ) {
            $this['previous_succsessful_login'] = App::$user->last_succsessful_login;

            App::$user->unsuccessful_login_count = 0;
            App::$user->last_succsessful_login = date('Y-m-d H:i:s');

            return $this;
        }

        // another authentication method (without using password) has been used
        if ($this->_password === false) {
            return $this;
        }

        // otherwise, update failed login count for invalid password
        if (App::$config->settings->failedLoginCount) {
            App::$user->unsuccessful_login_count++;
            App::$user->skipUpdateDateChange = true;
            App::$user->modify();
            App::$user->skipUpdateDateChange = false;
        }

        // disable account when unsuccessful login count exceeded
        if (
            App::$config->settings->failedLoginCount
            && App::$user->unsuccessful_login_count >= App::$config->settings->failedLoginCount
        ) {
            if (App::$user->enabled) {
                App::$user->enabled = 0;
                App::$user->modify();
            }
            throw new Exception(Lang::get('USER_ACCOUNT_HAS_BEEN_BLOCKED'), User::ACCOUNT_BLOCKED_CODE);
        }

        $error = '';

        // check if user is trying to log in with old password
        if (App::$user->activated && App::$user->enabled) {
            if (App::$user->unsuccessful_login_count) {
                if (App::$user->selectFromPasswordChanges($this->_password)) {
                    $error .= ' ' . Lang::get('GENERAL_USED_OLD_PASSWORD');
                    $error .= ' ' . Lang::get('GENERAL_CURRENT_PASSWORD_SET')
                        . ' ' . App::$user->getLastPasswordChangeDate() . '. ';
                }
            }
        }

        if ($this->_params['oneTimePass']) {
            $error .= Lang::get('USER_ONE_TIME_PASSWORD_INVALID') . ' ';

            if (App::$config->settings->failedLoginCount) {
                $loginAttempts = App::$config->settings->failedLoginCount - App::$user->unsuccessful_login_count;

                if (App::$user->unsuccessful_login_count > 0) {
                    $error .= sprintf(Lang::get('USER_BEFORE_LOCK_ALT'), $loginAttempts);
                }
            }
        } else {
            if (App::$config->settings->failedLoginCount) {
                $loginAttempts = App::$config->settings->failedLoginCount - App::$user->unsuccessful_login_count;

                if ($loginAttempts == 1 || $loginAttempts == 3) {
                    $error .= sprintf(Lang::get('USER_BEFORE_LOCK'), $loginAttempts);
                } elseif ($loginAttempts == 2) {
                    $error .= sprintf(Lang::get('USER_BEFORE_LOCK'), $loginAttempts);
                    $this->_checkRecentlyChangedPasswordDate();
                    throw new Exception($error, User::ACCOUNT_LOCK_WARNING);
                } else {
                    $error .= Lang::get('USER_INVALID_LOG_IN');
                }
            } else {
                $error = Lang::get('USER_INVALID_LOG_IN');
            }
            $this->_checkRecentlyChangedPasswordDate();
        }

        throw new Exception($error, User::INVALID_CREDENTIALS_CODE);
    }


    /**
     * Fetches user's recently changed password date
     */
    protected function _checkRecentlyChangedPasswordDate()
    {
        if (!method_exists(App::$user, 'getLastActivationActionDate')) {
            $this->_lastPasswordChangeInfo = '';
            return;
        }

        $lastPasswordChangeDate = App::$user->getLastActivationActionDate();
        $hasChangedWithinTwoDays = (time() - strtotime($lastPasswordChangeDate) <= 48 * 60 * 60);
        if (isset($lastPasswordChangeDate) && $hasChangedWithinTwoDays) {
            $this->_lastPasswordChangeInfo = Lang::get('USER_PASSWORD_RECENTLY_CHANGED') . ' ' . strtolower(Output::getTimeAgo($lastPasswordChangeDate));
            return;
        }
        $this->_lastPasswordChangeInfo = '';
    }


    /**
     * Get user's last password change info
     * @return array
     */
    public function getLastPasswordChangeInfo()
    {
        return $this->_lastPasswordChangeInfo;
    }


    /**
     * Updates password hash using strong hashing algorithm
     * @return $this
     * @throws NoColumnsException
     */
    protected function _updatePasswordHash()
    {
        if (User::hasField('hash_algorithm') && !App::$user->hash_algorithm) {
            $this['hash_algorithm'] = self::HASH_BCRYPT;
            $this['password'] = User::getPasswordHash($this->_password, $this->_login);
        }

        return $this;
    }


    /**
     * Deactivates account with expired password & force setting new one
     * @return $this
     */
    protected function _deactivateIfExpired()
    {
        if (App::$config->settings->passwordExpiration && App::$user->isPasswordExpired()) {
            App::$user->activated = 0;
            $this->_userData['activated'] = 0;
        }

        return $this;
    }


    /**
     * Updates user account data
     * @return $this
     * @throws Exception
     */
    protected function _updateUser()
    {
        if ($this->_params['mobile']) {
            $this->_userData['mobile_pass'] = null;
        }

        if ($this->_params['oneTimePass']) {
            $this->_userData['one_time_pass'] = null;
        }

        App::$user->skipUpdateDateChange = true;
        App::$user->modify();
        App::$user->skipUpdateDateChange = false;

        return $this;
    }


    /**
     * Prepares session data after successful login
     * @return $this
     * @throws NoColumnsException
     */
    protected function _prepareSession()
    {
        $request = App::getService('request');

        if ($request->isApi() || $request->isMobile()) {
            $token = App::$user->setToken();
            $tokenId = $token->id();
            session_id($tokenId);

            /** @var SessionInterface $session */
            $session = App::getService('session');

            $currentSessionId = $session->getId();
            $session->destroy($currentSessionId);

            $session->setId($tokenId);
            $session->start();
        } else {
            $postLoginRedirect = App::$session->postLoginRedirect;
            App::$session->regenerateId();
            App::$session->postLoginRedirect = $postLoginRedirect;
        }

        App::$session->isLogged     = true;
        App::$session->resetToken   = $this->_params['resetToken'];
        App::$session->userIp       = $_SERVER['REMOTE_ADDR'];
        App::$session->csrfToken    = md5(microtime(true) . session_id());
        App::$session->settings     = App::$user->settings(null, true);

        return $this;
    }


    /**
     * Sets external email for test notifications
     * @return $this
     */
    protected function _setExternalEmail()
    {
        $request = App::getService('request');

        if ($_POST['exmail']) {
            setcookie('test_email', $_POST['exmail']);

            if (App::ENV_TESTING == APP_ENV || $request->getHeader('X_USER_EMAIL') == $_POST['exmail']) {
                if (array_key_exists('email', $this->_userData)) {
                    $this->_userData['email'] = $_POST['exmail'];
                } else {
                    $this->_userData['email_address'] = $_POST['exmail'];
                }
            }
        }

        return $this;
    }
}
