<?php
namespace Misuzu\Users;

use RuntimeException;
use Misuzu\ClientInfo;
use Misuzu\DB;
use Misuzu\Pagination;

class UserSession {
    public const TOKEN_SIZE = 64;
    public const LIFETIME = 60 * 60 * 24 * 31;

    // Database fields
    private $session_id = -1;
    private $user_id = -1;
    private $session_key = '';
    private $session_ip = '::1';
    private $session_ip_last = null;
    private $session_user_agent = '';
    private $session_client_info = '';
    private $session_country = 'XX';
    private $session_expires = null;
    private $session_expires_bump = 1;
    private $session_created = null;
    private $session_active = null;

    private $user = null;

    private static $localSession = null;

    public const TABLE = 'sessions';
    private const QUERY_SELECT = 'SELECT %1$s FROM `' . DB::PREFIX . self::TABLE . '` AS '. self::TABLE;
    private const SELECT = '%1$s.`session_id`, %1$s.`user_id`, %1$s.`session_key`, %1$s.`session_user_agent`, %1$s.`session_client_info`, %1$s.`session_country`, %1$s.`session_expires_bump`'
        . ', INET6_NTOA(%1$s.`session_ip`) AS `session_ip`'
        . ', INET6_NTOA(%1$s.`session_ip_last`) AS `session_ip_last`'
        . ', UNIX_TIMESTAMP(%1$s.`session_created`) AS `session_created`'
        . ', UNIX_TIMESTAMP(%1$s.`session_active`) AS `session_active`'
        . ', UNIX_TIMESTAMP(%1$s.`session_expires`) AS `session_expires`';

    public function getId(): int {
        return $this->session_id < 1 ? -1 : $this->session_id;
    }

    public function getUserId(): int {
        return $this->user_id < 1 ? -1 : $this->user_id;
    }
    public function getUser(): User {
        if($this->user === null)
            $this->user = User::byId($this->getUserId());
        return $this->user;
    }

    public function getToken(): string {
        return $this->session_key;
    }

    public function getInitialRemoteAddress(): string {
        return $this->session_ip;
    }

    public function getLastRemoteAddress(): string {
        return $this->session_ip_last ?? '';
    }
    public function hasLastRemoteAddress(): bool {
        return !empty($this->session_ip_last);
    }
    public function setLastRemoteAddress(string $remoteAddr): self {
        $this->session_ip_last = $remoteAddr;
        return $this;
    }

    public function getUserAgent(): string {
        return $this->session_user_agent;
    }
    public function getClientInfo(): ClientInfo {
        return ClientInfo::decode($this->session_client_info);
    }

    public function getCountry(): string {
        return $this->session_country;
    }
    public function getCountryName(): string {
        return get_country_name($this->getCountry());
    }

    public function getCreatedTime(): int {
        return $this->session_created === null ? -1 : $this->session_created;
    }

    public function getActiveTime(): int {
        return $this->session_active === null ? -1 : $this->session_active;
    }
    public function hasActiveTime(): bool {
        return $this->session_active !== null;
    }
    public function setActiveTime(int $timestamp): self {
        if($timestamp > $this->session_active)
            $this->session_active = $timestamp;
        return $this;
    }

    public function getExpiresTime(): int {
        return $this->session_expires === null ? -1 : $this->session_expires;
    }
    public function setExpiresTime(int $timestamp): self {
        $this->session_expires = $timestamp;
        return $this;
    }
    public function hasExpired(): bool {
        return $this->getExpiresTime() <= time();
    }

    public function shouldBumpExpire(): bool {
        return boolval($this->session_expires_bump);
    }

    public function bump(string $remoteAddr, int $timestamp = -1): void {
        if($timestamp < 0)
            $timestamp = time();

        $this->setActiveTime($timestamp)
            ->setLastRemoteAddress($remoteAddr);

        if($this->shouldBumpExpire())
            $this->setExpiresTime($timestamp + self::LIFETIME);

        $this->update();
    }

    public function delete(): void {
        DB::prepare('DELETE FROM `' . DB::PREFIX . self::TABLE . '` WHERE `session_id` = :session')
            ->bind('session', $this->getId())
            ->execute();
    }

    public static function purgeUser(User $user): void {
        DB::prepare('DELETE FROM `' . DB::PREFIX . self::TABLE . '` WHERE `user_id` = :user')
            ->bind('user', $user->getId())
            ->execute();
    }

    public function setCurrent(): void {
        self::$localSession = $this;
    }
    public static function unsetCurrent(): void {
        self::$localSession = null;
    }
    public static function getCurrent(): ?self {
        return self::$localSession;
    }
    public static function hasCurrent(): bool {
        return self::$localSession !== null;
    }

    public static function generateToken(): string {
        return bin2hex(random_bytes(self::TOKEN_SIZE / 2));
    }

    public function update(): void {
        DB::prepare(
            'UPDATE `' . DB::PREFIX . self::TABLE . '`'
            . ' SET `session_active` = FROM_UNIXTIME(:active), `session_ip_last` = INET6_ATON(:remote_addr), `session_expires` = FROM_UNIXTIME(:expires)'
            . ' WHERE `session_id` = :session'
        )   ->bind('active', $this->session_active)
            ->bind('remote_addr', $this->session_ip_last)
            ->bind('expires', $this->session_expires)
            ->bind('session', $this->session_id)
            ->execute();
    }

    public static function create(
        User $user,
        string $remoteAddr,
        string $countryCode,
        ?string $userAgent = null,
        ?ClientInfo $clientInfo = null
    ): self {
        $userAgent = $userAgent ?? filter_input(INPUT_SERVER, 'HTTP_USER_AGENT') ?? '';
        $clientInfo ??= ClientInfo::parse($_SERVER);
        $token = self::generateToken();

        $sessionId = DB::prepare(
            'INSERT INTO `' . DB::PREFIX . self::TABLE . '`'
            . ' (`user_id`, `session_ip`, `session_country`, `session_user_agent`, `session_client_info`, `session_key`, `session_created`, `session_expires`)'
            . ' VALUES (:user, INET6_ATON(:remote_addr), :country, :user_agent, :client_info, :token, NOW(), NOW() + INTERVAL :expires SECOND)'
        )   ->bind('user', $user->getId())
            ->bind('remote_addr', $remoteAddr)
            ->bind('country', $countryCode)
            ->bind('user_agent', $userAgent)
            ->bind('client_info', $clientInfo->encode())
            ->bind('token', $token)
            ->bind('expires', self::LIFETIME)
            ->executeGetId();

        if($sessionId < 1)
            throw new RuntimeException('Failed to create new session.');

        return self::byId($sessionId);
    }

    private static function countQueryBase(): string {
        return sprintf(self::QUERY_SELECT, 'COUNT(*)');
    }
    public static function countAll(?User $user = null): int {
        $getCount = DB::prepare(
            self::countQueryBase()
            . ($user === null ? '' : ' WHERE `user_id` = :user')
        );
        if($user !== null)
            $getCount->bind('user', $user->getId());
        return (int)$getCount->fetchColumn();
    }

    private static function byQueryBase(): string {
        return sprintf(self::QUERY_SELECT, sprintf(self::SELECT, self::TABLE));
    }
    public static function byId(int $sessionId): self {
        $session = DB::prepare(self::byQueryBase() . ' WHERE `session_id` = :session_id')
            ->bind('session_id', $sessionId)
            ->fetchObject(self::class);

        if(!$session)
            throw new RuntimeException('Could not find a session with that ID.');

        return $session;
    }
    public static function byToken(string $token): self {
        $session = DB::prepare(self::byQueryBase() . ' WHERE `session_key` = :token')
            ->bind('token', $token)
            ->fetchObject(self::class);

        if(!$session)
            throw new RuntimeException('Could not find a session with that token.');

        return $session;
    }
    public static function all(?Pagination $pagination = null, ?User $user = null): array {
        $sessionsQuery = self::byQueryBase()
            . ($user === null ? '' : ' WHERE `user_id` = :user')
            . ' ORDER BY `session_created` DESC';

        if($pagination !== null)
            $sessionsQuery .= ' LIMIT :range OFFSET :offset';

        $getSessions = DB::prepare($sessionsQuery);

        if($user !== null)
            $getSessions->bind('user', $user->getId());

        if($pagination !== null)
            $getSessions->bind('range', $pagination->getRange())
                ->bind('offset', $pagination->getOffset());

        return $getSessions->fetchObjects(self::class);
    }
}
