<?php

namespace Velis\Session;

use Exception;
use Phalcon\Session\ManagerInterface;
use Redis;
use RedisException;
use Velis\Acl\Priv;
use Velis\Acl\Role;
use Velis\App;
use Velis\Arrays;
use Velis\Exception as VelisException;
use Velis\Session\Serializer\AbstractSerializer;
use Velis\User;

/**
 * @author Jan Małysiak <jan.malysiak@velis.pl>
 */
class RedisSession extends Session
{
    public const SESSION_STORE_KEY = 'sessions';

    /**
     * @var string[]
     */
    private array $userSessions = [];

    private Redis $redis;

    private ?int $lifetime;

    private AbstractSerializer $serializer;

    /**
     * Constructor
     * @param ManagerInterface $manager
     * @param Redis $redis
     * @param int|null $lifetime
     * @param AbstractSerializer $serializer
     */
    public function __construct(ManagerInterface $manager, Redis $redis, ?int $lifetime, AbstractSerializer $serializer)
    {
        parent::__construct($manager);

        $this->redis = $redis;
        $this->lifetime = $lifetime;
        $this->serializer = $serializer;
    }

    /**
     * {@inheritDoc}
     * @throws VelisException
     */
    public function start(): bool
    {
        if (parent::start()) {
            $this->addToUserSessions();

            return true;
        }

        return false;
    }

    /**
     * Adds current session id to all user sessions ids
     * @throws VelisException
     */
    private function addToUserSessions()
    {
        $userData = $this->manager->get('userData');
        if (!($userData && $userData['user_id'])) {
            return;
        }

        try {
            $this->loadUserSessions();
            $sessionId = $this->getId();

            if (!$this->userSessions[$sessionId]) {
                $this->userSessions[$sessionId] = $sessionId;
                $this->saveToUserSessions();
            }

            $this->checkSessionCount();
        } catch (Exception $e) {
            VelisException::raise(sprintf('Store user (ID: %s) sessions exception: %s %s', $userData['user_id'], $e->getCode(), $e->getMessage()));
        }
    }

    /**
     * Checks if there are more sessions than allowed for the user and removes the oldest one
     * @throws RedisException
     */
    private function checkSessionCount(): void
    {
        $sessionLimit = App::$config->session->limit;

        if (!$sessionLimit) {
            return;
        }

        $sessions = $this->userSessions ?? [];
        unset($sessions['tokens']);
        $sessionsToKeep = array_slice($sessions, -$sessionLimit);
        $oldestSessions = array_diff($sessions, $sessionsToKeep);

        foreach ($oldestSessions as $sessionId) {
            $this->removeSession($sessionId);
        }
    }

    /**
     * Removes session
     * @throws RedisException
     */

    private function removeSession(string $sessionId): void
    {
        if ($sessionId) {
            $userData = $this->manager->get('userData');
            $key = $this->getUserSessionsKey($userData['user_id']);
            $this->redis->del($this->getUserSessionStoreKey($sessionId));
            $this->removeFromUserSessions($key, $sessionId);
        }
    }

    /**
     * Load all user sessions ids
     * @param string|null $key
     * @throws RedisException
     */
    private function loadUserSessions(string $key = null)
    {
        $sessions = [];

        $userData = $this->manager->get('userData');
        if ($userData && $userData['user_id']) {
            if (!$key) {
                $key = $this->getUserSessionsKey($userData['user_id']);
            }

            $sessions = unserialize($this->redis->get($key));
            $id = $this->getId();

            if ($this->session_node_token && !$sessions['tokens'][$id]) {
                $sessions['tokens'][$id] = $this->session_node_token;
            }
        }

        $this->manager->set('userSessions', $sessions);
        $_SESSION['userSessions'] = $this->userSessions = Arrays::toArray($sessions);
    }

    /**
     * Returns key to store user sessions
     * @param int|null $userId
     * @return string
     */
    private function getUserSessionsKey(?int $userId): string
    {
        return $this->getSessionsKeyPrefix() . $userId;
    }


    /**
     * Returns the key where the user's session is stored
     * @param string $sessionId
     * @return string
     */
    private function getUserSessionStoreKey(string $sessionId): string
    {
        return '_PHCR' . $this->getPrefix() . $sessionId;
    }

    /**
     * Returns key to store anonymous sessions or prefix of user sessions key
     * @return string
     */
    private function getSessionsKeyPrefix(): string
    {
        return '_PHCR' . $this->getPrefix() . self::SESSION_STORE_KEY;
    }

    /**
     * Saves user sessions ids list
     * @param string|null $key
     * @throws RedisException
     */
    private function saveToUserSessions(string $key = null)
    {
        $userData = $this->manager->get('userData');
        if (($userData && $userData['user_id']) || $key) {
            if (!$key) {
                $key = $this->getUserSessionsKey($userData['user_id']);
            }

            $this->redis->set($key, serialize($this->userSessions), $this->lifetime);
        }

        $this->manager->set('userSessions', $this->userSessions);
        $_SESSION['userSessions'] = $this->userSessions;
    }

    /**
     * {@inheritDoc}
     * @throws VelisException
     */
    public function regenerateId(bool $deleteOldSession = true): SessionInterface
    {
        parent::regenerateId($deleteOldSession);

        $this->generateNodeToken();
        $this->addToUserSessions();

        return $this;
    }

    /**
     * Generates user session node token. Token is valid only if session exists.
     * @throws VelisException
     */
    private function generateNodeToken()
    {
        try {
            if (!$this->manager->get('session_node_token')) {
                $this->manager->set('session_node_token', substr(hash('sha256', 'jhasd9hassdp;asd' . $this->getId()), 0, 20));
            }
        } catch (Exception $e) {
            VelisException::raise(sprintf('Generate node token error (ID: %s): %s %s', $this->userData['user_id'], $e->getCode(), $e->getMessage()));
        }
    }

    /**
     * Checks if user session exists
     * @param string $sessionId
     * @param int[]|int|null $userIds
     * @return bool
     * @throws RedisException
     */
    public function checkSession(string $sessionId, $userIds = null): bool
    {
        if (!$sessionId) {
            return false;
        }

        if (!$userIds) {
            $userIds = App::getSuperUsers();
        }

        if (!is_array($userIds)) {
            $userIds = [$userIds];
        }

        foreach ($userIds as $userId) {
            if ($sessionList = $this->redis->get($this->getUserSessionsKey($userId))) {
                $sessionList = unserialize($sessionList);

                if (in_array($sessionId, $sessionList)) {
                    return true;
                }
            }
        }

        return false;
    }

    /**
     * Removes user sessions
     * @param User|null $user
     * @throws RedisException
     */
    public function removeSessions(User $user = null)
    {
        if ($user instanceof User) {
            $sessionsKey = $user->id();
        } else {
            $sessionsKey = '*';
        }

        foreach ($this->getLoggedUserIds($sessionsKey) as $userId) {
            $userSessionsKey = $this->getUserSessionsKey($userId);

            if ($sessionList = $this->redis->get($userSessionsKey)) {
                $sessionList = unserialize($sessionList);

                foreach ($sessionList as $sessionId) {
                    if (is_string($sessionId)) {
                        $this->redis->del(
                            $this->getUserSessionStoreKey($sessionId)
                        );
                    }
                }

                $this->redis->del($userSessionsKey);
            }
        }
    }

    /**
     * @param string $sessionsKey
     * @return array
     * @throws RedisException
     */
    private function getLoggedUserIds(string $sessionsKey): array
    {
        $userIds = [];
        $prefix = $this->getSessionsKeyPrefix();
        $sessionsList = $this->redis->keys($prefix . $sessionsKey);

        foreach ($sessionsList as $listId) {
            $userId = str_replace($prefix, '', $listId);
            $userIds[$userId] = $userId;
        }

        return $userIds;
    }

    /**
     * Reloads users privs in all sessions
     * @param User $user
     * @throws Exception
     */
    public function reloadPrivs(User $user)
    {
        $userSessionsKey = $this->getUserSessionsKey($user->id());
        $sessionList = $this->redis->get($userSessionsKey);

        if (!$sessionList) {
            return;
        }

        $sessionList = unserialize($sessionList);
        $privs = Priv::byUser($user);
        $roles = Role::byUser($user);

        foreach ($sessionList as $key => $sessionId) {
            if ('tokens' === $key) {
                continue;
            }

            $sessionKey = $this->getUserSessionStoreKey($sessionId);
            $session = $this->redis->get($sessionKey);

            if (!$session) {
                continue;
            }

            $this->serializer->unserialize($session);
            $decodedSession = $this->serializer->getData();

            $decodedSession['userPrivs'] = $privs;
            $decodedSession['userRoles'] = $roles;
            /** Set flag to force full page reload */
            $decodedSession['forceReload'] = true;

            $this->serializer->setData($decodedSession);
            $this->redis->set($sessionKey, $this->serializer->serialize(), ['xx', 'ex' => $this->lifetime]);
        }
    }

    /**
     * @throws RedisException
     */
    public function getTtl()
    {
        return $this->redis->get($this->getUserSessionStoreKey($this->getId()))
            ? $this->redis->ttl($this->getUserSessionStoreKey($this->getId()))
            : $this->lifetime;
    }

    /**
     * {@inheritDoc}
     * @throws RedisException
     * @throws VelisException
     */
    public function destroy($id): bool
    {
        $userData = $this->manager->get('userData');
        $key = $this->getUserSessionsKey($userData['user_id']);
        $this->loadUserSessions();

        $destroyed = parent::destroy($id);
        if ($destroyed) {
            $this->removeFromUserSessions($key, $id);
        }

        return $destroyed;
    }

    /**
     * Removes session id from all user session ids
     * @param string $key
     * @param string $sessionId
     * @throws VelisException
     */
    private function removeFromUserSessions(string $key, string $sessionId)
    {
        try {
            unset($this->userSessions[$sessionId]);
            unset($this->userSessions['tokens'][$sessionId]);
            $this->manager->set('userSessions', $this->userSessions);
            $this->saveToUserSessions($key);
        } catch (Exception $e) {
            $userData = $this->manager->get('userData');
            VelisException::raise(sprintf('Remove from user (ID: %s) sessions exception: %s %s', $userData['user_id'], $e->getCode(), $e->getMessage()));
        }
    }

    /**
     * {@inheritDoc}
     * @throws VelisException
     */
    #[\ReturnTypeWillChange]
    public function gc($maxlifetime)
    {
        try {
            foreach ($this->getLoggedUserIds('*') as $userId) {
                $userSessionsKey = $this->getUserSessionsKey($userId);
                if ($sessionList = $this->redis->get($userSessionsKey)) {
                    $sessionList = unserialize($sessionList);
                    foreach ($sessionList as $key => $session) {
                        $sessionKey = $this->getUserSessionStoreKey($session);
                        if ($key !== 'tokens' && !$this->redis->exists($sessionKey)) {
                            unset($sessionList[$key]);
                            unset($sessionList['tokens'][$key]);
                        }
                    }

                    if (!count($sessionList['tokens'])) {
                        unset($sessionList['tokens']);
                    }

                    if (!count($sessionList)) {
                        $this->redis->del($userSessionsKey);
                    } else {
                        $this->redis->set($userSessionsKey, serialize($sessionList), $this->lifetime);
                    }
                }
            }
        } catch (Exception $e) {
            VelisException::raise('GC exception: ' . $e->getMessage(), $e->getCode());

            return false;
        }

        return true;
    }
}
