<?php

namespace Velis\App;

use DateTime;
use DateTimeZone;
use Exception;
use ReflectionException;
use ResourceBundle;
use RuntimeException;
use Velis\Acl\Priv;
use Velis\Acl\Role;
use Velis\Api\Token;
use Velis\App\User\Authentication;
use Velis\App;
use Velis\Arrays;
use Velis\Http\Request;
use Velis\Label;
use Velis\Lang;
use Velis\Model\DataObject\NoColumnsException;
use Velis\Output;

/**
 * Application user
 * @author Olek Procki <olo@velis.pl>
 */
class User extends \User\User
{
    /**
     * Login exception codes
     */
    public const DEMO_EXPIRED_CODE        = 600;
    public const ACCOUNT_BLOCKED_CODE     = 601;
    public const INVALID_CREDENTIALS_CODE = 602;
    public const RESET_TOKEN_EXPIRED      = 603;
    public const ACCOUNT_LOCK_WARNING     = 604;
    public const NO_COMPLEX_LOGIN         = 605;
    public const NO_ACTIVE_COMPANY        = 606;
    public const ACTIVATION_LINK_HAS_BEEN_USED = 607;
    public const INVALID_REQUEST_METHOD   = 608;
    public const int ONE_TIME_PASS_MULTIPLE_ACCOUNTS_FOUND = 609;

    public static $warningCodes = [
            self::ACTIVATION_LINK_HAS_BEEN_USED,
        ];

    /**
     * Rest API token
     * @var Token
     */
    protected $_token;


    /**
     * Current user privs
     * @var array
     */
    protected $_privs = [];


    /**
     * Authentication object
     * @var Authentication
     */
    protected $_authentication;


    /**
     * Creates new user
     * @param mixed $data
     * @throws NoColumnsException
     */
    public function __construct($data = null)
    {
        parent::__construct($data);

        if (!App::$user || App::$user->id() === ($data['user_id'] ?? null)) {
            if (session_id() && App::$session && App::$session->isLogged) {
                if (!isset(App::$session->userPrivs) || !isset(App::$session->userRoles)) {
                    $this->_loadPrivs();
                    $this->_loadRoles();
                } else {
                    $this->_privs = App::$session->userPrivs;
                }
            }
        } elseif (isset($data['_privs'])) {
            $this->_privs = $data['_privs'];
        }
    }


    /**
     * Returns API data only
     * @param bool $full
     * @return array
     * @throws Exception
     */
    public function getApiData($full = false)
    {
        $userApiData = parent::getApiData();

        $timezone = $this->getDateTimeZone();
        $userApiData['update_date'] = Output::formatIsoDateTime($userApiData['update_date'], $timezone);

        if (!$full) {
            return $userApiData;
        }

        if ($this->_hasField('is_bot') && $this->is_bot) {
            $userApiData = Arrays::extractFields($userApiData, [
                'user_id',
                'email',
                'name',
                'last_name',
                'login',
                'update_date',
                'country_id',
                'timezone_id',
                'active',
                'is_internal',
                'is_bot',
                'lang_id',
            ]);
        }

        $data = [
            'user' => $userApiData,
            'token' => $this->_token ? $this->_token->getApiData() : '',
        ];

        if (!$this->_hasField('is_bot') || !$this->is_bot) {
            $data['privs'] = App::$session->userPrivs;
            $data['roles'] = App::$session->userRoles;
        }

        return $data;
    }


    /**
     * Logs user in
     *
     * @param string $login
     * @param string $password
     * @param array $resetToken
     * @param bool $mobile login from mobile
     * @param bool $oneTimePass logging in with one time password
     *
     * @return bool
     */
    public function login($login, $password, $resetToken = null, $mobile = false, $oneTimePass = false): bool
    {
        $this->_authentication = new Authentication();
        $this->_authentication->setParams([
            'dataSource'    => $this->_getListDatasource(),
            'resetToken'    => $resetToken,
            'mobile'        => $mobile,
            'oneTimePass'   => $oneTimePass,
        ]);

        return $this->_authentication->perform($login, $password);
    }

    /**
     * Login user using token
     * @param string token
     * @return bool
     */
    public function tokenLogin($request)
    {
        $this->_authentication = new Authentication(Authentication::TOKEN);
        return $this->_authentication->perform($request);
    }


    /**
     * @return Authentication
     */
    public function getAuthentication()
    {
        return $this->_authentication;
    }


    /**
     * Initialize ACL data (roles/privs)
     */
    public function initAcl()
    {
        $this->_loadPrivs();
        $this->_loadRoles();
    }

    /**
     * Initialize user settings
     */
    public function initSettings()
    {
        $this->loadSettings();
    }

    /**
     * Generate and set new token
     *
     * @return Token
     * @throws NoColumnsException
     */
    public function setToken()
    {
        return $this->_token = Token::create($this);
    }


    /**
     * Returns user token
     * @return Token
     */
    public function getToken()
    {
        return $this->_token;
    }


    /**
     * Check if token has not expired
     * @param \Phalcon\Http\Request|string $request
     * @return bool
     * @throws Exception
     */
    public static function checkToken($request)
    {
        if ($request instanceof Request) {
            $token = $request->authorization();
        } else {
            $token = $request;
        }
        self::$_db->checkDuplicatedQueries(false);
        if ($token = Token::instance($token)) {
            self::$_db->checkDuplicatedQueries(true);
            $date1 = new DateTime($token->date_created);
            $date2 = new DateTime(date('Y-m-d H:i:s'));
            $result = $date1->diff($date2);
            $diff = $result->h + ($result->days * 24);
            if (!App::$config->tokenExpiredHours || App::$config->tokenExpiredHours > $diff) {
                return true;
            }
        }
        self::$_db->checkDuplicatedQueries(true);

        return false;
    }


    /**
     * Returns true if user is logged
     * @return bool
     */
    public function isLogged()
    {
        if (!isset(App::$session) || !session_id()) {
            return false;
        }

        $isLogged = App::$session->isLogged;

        if (App::$config->settings->ipRestriction && App::$session->userIp) {
            $isLogged = App::$session->isLogged && App::$session->userIp == $_SERVER['REMOTE_ADDR'];
        }

        return $isLogged;
    }


    /**
     * Returns session id
     * @return string
     */
    public function getSessionId()
    {
        return session_id();
    }


    /**
     * Loads user privs into session
     * @return $this
     */
    protected function _loadPrivs()
    {
        $privs = Priv::byUser($this);

        App::$session->userPrivs = $privs;
        $this->_privs = $privs;

        return $this;
    }


    /**
     * Loads user assigned roles into session
     * @return $this
     */
    protected function _loadRoles()
    {
        App::$session->userRoles = Role::byUser($this);

        return $this;
    }

    /**
     * Loads user settings
     * @return $this
     */
    protected function loadSettings(): self
    {
        App::$session->settings = parent::settings(null, true);

        return $this;
    }


    /**
     * @param string $module
     * @param ?string $priv
     * @return bool
     */
    protected function checkPriv(string $module, string $priv = null): bool
    {
        if ($priv !== '*') {
            if (array_key_exists($module, $this->_privs)) {
                return in_array($priv, $this->_privs[$module]);
            }
        } else {
            return array_key_exists($module, $this->_privs);
        }
        return false;
    }

    /**
     * @inheritDoc
     */
    public function hasAnyPriv(array $privs): bool
    {
        foreach ($privs as $priv) {
            [$module, $acro] = $priv;
            if ($this->checkPriv($module, $acro)) {
                return true;
            }
        }

        return false;
    }


    /**
     * Returns user roles from session
     * @return Role[]
     */
    public function getRoles(): array
    {
        return App::$session->userRoles ?? [];
    }


    /**
     * Returns true if user has role assigned
     *
     * @param string $roleAcro
     * @return bool
     */
    public function hasRole($roleAcro)
    {
        if ($this->isLogged()) {
            return array_key_exists($roleAcro, App::$session->userRoles);
        }

        return false;
    }


    /**
     * Returns true if user has any role provided in function args
     * @return bool
     */
    public function hasAnyRole()
    {
        foreach (func_get_args() as $role) {
            if ($this->hasRole($role)) {
                return true;
            }
        }

        return false;
    }


    /**
     * Activates account & sets new password
     *
     * @param string $password
     * @return $this
     * @throws Exception
     */
    public function activate($password)
    {
        if (strlen(trim($password))) {
            if ($this->_hasField('hash_algorithm')) {
                $this['hash_algorithm'] = Authentication::HASH_BCRYPT;
            }

            $this['password']  = $this->_getPasswordHash($password);
            $this['activated'] = 1;

            $days = App::settings('PasswordExpireDays') ?: 30;
            $this['password_date_expiry'] = date('Y-m-d', time() + ($days * (24 * 60 * 60)));
            $this['unsuccessful_login_count'] = 0;

            if ($this->reset_password_token) {
                $this->reset_password_token = null;
                $this->reset_password_date_expiry = null;
            }

            if (App::$config->node->pushNotification) {
                $this->_updateNodeToken();
            }

            if (App::$config->settings->strongPassword) {
                $this->_savePasswordHistory($password);
            }

            $this->modify();

            return $this;
        } else {
            throw new RuntimeException(Lang::get('USER_NO_PASSWORD'));
        }
    }


    /**
     * {@inheritDoc}
     */
    public function updatePassword($password, $onlyHist = false)
    {
        parent::updatePassword($password, $onlyHist);
        $this->_updateNodeToken();

        if (App::$config->settings->removeTokensOnPasswordChange) {
            Token::invalidateToken([$this->id()]);
        }
    }


    /**
     * Returns true if user has no external role
     * @return bool
     */
    public function isInternal(): bool
    {
        foreach ($this->getRoles() as $role) {
            if ($role->is_external) {
                return false;
            }
        }
        return true;
    }


    /**
     * Switch supervisor do another user context
     * @param \Velis\User|int $user
     * @throws ReflectionException
     * @throws \Velis\Exception
     */
    public function switchUser($user)
    {
        if (!$user instanceof \Velis\User) {
            $user = \User\User::bufferedInstance($user);
        }

        if (!App::$session->sourceUser) {
            $sourceUser = $this->getArrayCopy();
            $sourceUser['_privs'] = $this->_privs;

            App::$session->sourceUser = $sourceUser;
        } elseif ($user->id() === App::$session->sourceUser['id']) {
            App::$session->sourceUser = null;
        }

        $this->append($user, true);

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

        $userData['substitute_for'] = $user->getSubstitutedUserIds();
        App::$user->append($userData);

        App::$session->userData = $userData;

        $this->_settings = null;

        $this->clearSessionData();

        if (method_exists($this, 'getTheme')) {
            $this->getTheme();
        }

        $this->_loadPrivs();
        $this->_loadRoles();
        $this->loadSettings();
    }


    protected function getSessionKeysToUnset(): array
    {
        $keys = [
            'settings',
            'postLoginRedirect',
            'additionalPublicActions',
            'noteCache',
            'bookmarks',
        ];

        if (method_exists(parent::class, 'getSessionKeysToUnset')) {
            // add external session keys from User\User
            return array_merge($keys, parent::getSessionKeysToUnset());
        }

        return $keys;
    }

    /**
     * @description Use for unset all session data to depends on a user for action of switch user.
     * @return void
     */
    protected function clearSessionData(): void
    {
        foreach ($this->getSessionKeysToUnset() as $sessionKey) {
            unset(App::$session->{$sessionKey});
        }
    }

    /**
     * Returns source super user
     * @return \Velis\User|void
     * @throws NoColumnsException
     */
    public function getSourceUser()
    {
        if (session_id() && App::$session && App::$session->sourceUser) {
            return new static(App::$session->sourceUser);
        }
    }


    /**
     * Returns true when switched from super to another user
     * @return bool
     * @throws NoColumnsException
     */
    public function isSwitched()
    {
        return $this->getSourceUser() != null;
    }


    /**
     * Returns true if user is a demo user
     * @param bool $checkSourceUser
     * @return bool
     * @throws NoColumnsException
     */
    public function isDemoAccount($checkSourceUser = false)
    {
        if ($checkSourceUser && $this->isSwitched()) {
            $sourceUser = App::$user->getSourceUser();
            if ($sourceUser['is_demo_account'] == '1' || $sourceUser['date_expiry']) {
                return true;
            } else {
                return false;
            }
        } else {
            return parent::isDemoAccount();
        }
    }


    /**
     * Returns settings by application session
     *
     * @param string $setting
     * @param bool $reload
     * @return mixed
     */
    public function settings($setting = null, $reload = false)
    {
        if ($setting && App::$config->settings->aliases->offsetExists($setting)) {
            $setting = App::$config->settings->aliases[$setting];
        }

        if (!isset($this->_settings) || $reload) {
            if (!isset(App::$session->settings) || $reload) {
                App::$session->settings = parent::settings($setting, $reload);
            }
            $this->_settings = App::$session->settings;
        }

        return $setting ? ($this->_settings[$setting] ?? null) : $this->_settings;
    }


    /**
     * @param array $settings
     * @param bool $reload
     * @return $this
     * @throws Exception
     */
    public function saveSettings($settings, $reload = false)
    {
        if ($reload) {
            unset($this->_settings);
        }

        parent::saveSettings($settings);

        App::$session->settings = parent::settings();
        $this->_settings = App::$session->settings;

        return $this;
    }


    /**
     * Check user conditions accept expiry
     */
    public function checkConditionsAcceptExpiry()
    {
        if (!App::$session->userData['conditions_date']) {
            $conditionsDate = self::$_db->getOne('
                SELECT date_last_modified
                FROM app.article_tab a
                WHERE
                    article_type_id = :article_type_id
                    AND lang_id = :lang_id
            ', [
                'article_type_id' => 'Conditions',
                'lang_id' => $this['lang_id'],
            ]);

            App::$session->userData['conditions_date'] = $conditionsDate;
        }

        if (!$this['conditions_accept_date'] || $this['conditions_accept_date'] < App::$session->userData['conditions_date']) {
            return false;
        } else {
            return true;
        }
    }


    /**
     * Check if user account is expired (demo mode)
     * @return bool
     */
    public function isExpired()
    {
        if ($this->date_expiry && date('Y-m-d') > $this->date_expiry) {
            return true;
        }
        return false;
    }


    /**
     * Check if user password is expired
     * @return bool
     */
    public function isPasswordExpired()
    {
        if (!$this->no_expire && (date('Y-m-d') > $this->password_date_expiry)) {
            return true;
        }
        return false;
    }


    /**
     * Returns demo account expiry date
     * @param bool $checkSourceUser
     * @return string
     * @throws NoColumnsException
     */
    public function getExpiryDate($checkSourceUser = false)
    {
        if (!$checkSourceUser) {
            return parent::getExpiryDate();
        } elseif ($this->getSourceUser()) {
            return $this->getSourceUser()->getExpiryDate();
        } else {
            return parent::getExpiryDate();
        }
    }

    /**
     * Return user labels
     * @return Label[]
     * @throws ReflectionException
     */
    public function getLabels($reload = null): array
    {
        if ($reload && isset(App::$cache[Label::getCacheName($this->id())])) {
            unset(App::$cache[Label::getCacheName($this->id())]);
        }
        $this->_labels = $this->_labels ?? App::$cache[Label::getCacheName($this->id())] ?? null;
        $this->_labels = Arrays::filterKeepZeros($this->_labels);

        if (!isset($this->_labels) || $reload) {
            $params = ['for' => $this->id()];

            if (App::$config->settings->showLabelOnlyInUserLanguage) {
                $params['has_name'] = Lang::getLanguage();
            }

            $labelClass = App::$config->settings->labelClass ?? Label::class;
            $this->_labels = $labelClass::listAll($params);

            // Workaround due to bug in Phalcon, which doesn't allow storing empty array in cache.
            // Bug has been corrected in Phalcon 5 (https://github.com/phalcon/cphalcon/pull/15350)
            App::$cache[Label::getCacheName($this->id())] = empty($this->_labels) ? Arrays::NULL_ARRAY : $this->_labels;
        }

        array_walk(
            $this->_labels,
            function ($label) {
                // Overwrites property with translated name for multilingual labels
                // or leaves it unchanged for non-multilingual
                $label['name'] = $label->getName();
            }
        );

        return $this->_labels;
    }

    /**
     * Refresh user data
     */
    public function refreshData()
    {
        self::$_db->checkDuplicatedQueries(false);
        $userData = $this->append(static::instance($this->id(), true));
        $userData->getSubstitutedUserIds();
        self::$_db->checkDuplicatedQueries(true);

        App::$session->userData = $userData;
    }


    /**
     * Return user locale setting
     * @return string
     */
    public function getLocale()
    {
        $locale = null;
        $userLocale = null;

        if (method_exists('\User\User', 'getLocale')) {
            $userLocale = parent::getLocale();
        }

        // custom locale for the case when app language is different
        // from the one resulting from user's country default locale
        if ($userLocale) {
            if (strpos($userLocale, Lang::getLanguage()) === false) {
                $customLocale = Lang::getLanguage() . '_' . ($this['country_id'] ?? '');

                // check if custom locale is supported by operating system
                if (in_array($customLocale, ResourceBundle::getLocales(''))) {
                    $locale = $customLocale;
                }
            } else {
                $locale = $userLocale;
            }
        }

        if (!$locale) {
            $locale = Lang::getDefaultLocaleForLang(Lang::getLanguage());
        }

        return $locale;
    }

    /**
     * {@inheritDoc}
     */
    public function modify($checkDiff = false)
    {
        parent::modify($checkDiff);
        $this->unstash();
        App::$session->userData = $this->getArrayCopy();

        return $this;
    }

    public function getAnalyticsId(): string
    {
        $id = strval($this->id());
        $acro = App::$config->get('settings')?->get('instanceAcro') ?? '';

        return "{$id}_{$acro}";
    }
}
