<?php

/**
 * This file is part of the Nette Framework (https://nette.org)
 * Copyright (c) 2004 David Grudl (https://davidgrudl.com)
 */
declare (strict_types=1);
namespace Packetery\Nette\Http;

use Packetery\Nette;
/**
 * Provides access to session sections as well as session settings and management methods.
 */
class Session
{
    use \Packetery\Nette\SmartObject;
    /** Default file lifetime */
    private const DefaultFileLifetime = 3 * \Packetery\Nette\Utils\DateTime::HOUR;
    private const SecurityOptions = [
        'referer_check' => '',
        // must be disabled because PHP implementation is invalid
        'use_cookies' => 1,
        // must be enabled to prevent Session Hijacking and Fixation
        'use_only_cookies' => 1,
        // must be enabled to prevent Session Fixation
        'use_trans_sid' => 0,
        // must be disabled to prevent Session Hijacking and Fixation
        'use_strict_mode' => 1,
        // must be enabled to prevent Session Fixation
        'cookie_httponly' => \true,
    ];
    /** @var array<callable(self): void>  Occurs when the session is started */
    public $onStart = [];
    /** @var array<callable(self): void>  Occurs before the session is written to disk */
    public $onBeforeWrite = [];
    /** @var bool  has been session ID regenerated? */
    private $regenerated = \false;
    /** @var bool  has been session started by Nette? */
    private $started = \false;
    /** @var array default configuration */
    private $options = [
        'cookie_samesite' => IResponse::SameSiteLax,
        'cookie_lifetime' => 0,
        // for a maximum of 3 hours or until the browser is closed
        'gc_maxlifetime' => self::DefaultFileLifetime,
    ];
    /** @var IRequest */
    private $request;
    /** @var IResponse */
    private $response;
    /** @var \SessionHandlerInterface */
    private $handler;
    /** @var bool */
    private $readAndClose = \false;
    /** @var bool */
    private $fileExists = \true;
    /** @var bool */
    private $autoStart = \true;
    public function __construct(IRequest $request, IResponse $response)
    {
        $this->request = $request;
        $this->response = $response;
        $this->options['cookie_path'] =& $this->response->cookiePath;
        $this->options['cookie_domain'] =& $this->response->cookieDomain;
        $this->options['cookie_secure'] =& $this->response->cookieSecure;
    }
    /**
     * Starts and initializes session data.
     * @throws \Packetery\Nette\InvalidStateException
     */
    public function start() : void
    {
        $this->doStart();
    }
    private function doStart($mustExists = \false) : void
    {
        if (\session_status() === \PHP_SESSION_ACTIVE) {
            // adapt an existing session
            if (!$this->started) {
                $this->configure(self::SecurityOptions);
                $this->initialize();
            }
            return;
        }
        $this->configure(self::SecurityOptions + $this->options);
        if (!\session_id()) {
            // session is started for first time
            $id = $this->request->getCookie(\session_name());
            $id = \is_string($id) && \preg_match('#^[0-9a-zA-Z,-]{22,256}$#Di', $id) ? $id : \session_create_id();
            \session_id($id);
            // causes resend of a cookie to make sure it has the right parameters
        }
        try {
            // session_start returns false on failure only sometimes (even in PHP >= 7.1)
            \Packetery\Nette\Utils\Callback::invokeSafe('session_start', [['read_and_close' => $this->readAndClose]], function (string $message) use(&$e) : void {
                $e = new \Packetery\Nette\InvalidStateException($message);
            });
        } catch (\Throwable $e) {
        }
        if ($e) {
            @\session_write_close();
            // this is needed
            throw $e;
        }
        if ($mustExists && $this->request->getCookie(\session_name()) !== \session_id()) {
            // PHP regenerated the ID which means that the session did not exist and cookie was invalid
            $this->destroy();
            return;
        }
        $this->initialize();
        \Packetery\Nette\Utils\Arrays::invoke($this->onStart, $this);
    }
    /** @internal */
    public function autoStart(bool $forWrite) : void
    {
        if ($this->started || !$forWrite && !$this->exists()) {
            return;
        } elseif (!$this->autoStart) {
            \trigger_error('Cannot auto-start session because autostarting is disabled', \E_USER_WARNING);
            return;
        }
        $this->doStart(!$forWrite);
    }
    private function initialize() : void
    {
        $this->started = \true;
        $this->fileExists = \true;
        /* structure:
        			__NF: Data, Meta, Time
        				DATA: section->variable = data
        				META: section->variable = Timestamp
        		*/
        $nf =& $_SESSION['__NF'];
        if (!\is_array($nf)) {
            $nf = [];
        }
        // regenerate empty session
        if (empty($nf['Time']) && !$this->readAndClose) {
            $nf['Time'] = \time();
            if ($this->request->getCookie(\session_name()) === \session_id()) {
                // ensures that the session was created with use_strict_mode (ie by Nette)
                $this->regenerateId();
            }
        }
        // expire section variables
        $now = \time();
        foreach ($nf['META'] ?? [] as $section => $metadata) {
            foreach ($metadata ?? [] as $variable => $value) {
                if (!empty($value['T']) && $now > $value['T']) {
                    if ($variable === '') {
                        // expire whole section
                        unset($nf['META'][$section], $nf['DATA'][$section]);
                        continue 2;
                    }
                    unset($nf['META'][$section][$variable], $nf['DATA'][$section][$variable]);
                }
            }
        }
    }
    public function __destruct()
    {
        $this->clean();
    }
    /**
     * Has been session started?
     */
    public function isStarted() : bool
    {
        return $this->started && \session_status() === \PHP_SESSION_ACTIVE;
    }
    /**
     * Ends the current session and store session data.
     */
    public function close() : void
    {
        if (\session_status() === \PHP_SESSION_ACTIVE) {
            $this->clean();
            \session_write_close();
            $this->started = \false;
        }
    }
    /**
     * Destroys all data registered to a session.
     */
    public function destroy() : void
    {
        if (\session_status() !== \PHP_SESSION_ACTIVE) {
            throw new \Packetery\Nette\InvalidStateException('Session is not started.');
        }
        \session_destroy();
        $_SESSION = null;
        $this->started = \false;
        $this->fileExists = \false;
        if (!$this->response->isSent()) {
            $params = \session_get_cookie_params();
            $this->response->deleteCookie(\session_name(), $params['path'], $params['domain'], $params['secure']);
        }
    }
    /**
     * Does session exist for the current request?
     */
    public function exists() : bool
    {
        return \session_status() === \PHP_SESSION_ACTIVE || $this->fileExists && $this->request->getCookie($this->getName());
    }
    /**
     * Regenerates the session ID.
     * @throws \Packetery\Nette\InvalidStateException
     */
    public function regenerateId() : void
    {
        if ($this->regenerated) {
            return;
        }
        if (\session_status() === \PHP_SESSION_ACTIVE) {
            if (\headers_sent($file, $line)) {
                throw new \Packetery\Nette\InvalidStateException('Cannot regenerate session ID after HTTP headers have been sent' . ($file ? " (output started at {$file}:{$line})." : '.'));
            }
            \session_regenerate_id(\true);
        } else {
            \session_id(\session_create_id());
        }
        $this->regenerated = \true;
    }
    /**
     * Returns the current session ID. Don't make dependencies, can be changed for each request.
     */
    public function getId() : string
    {
        return \session_id();
    }
    /**
     * Sets the session name to a specified one.
     * @return static
     */
    public function setName(string $name)
    {
        if (!\preg_match('#[^0-9.][^.]*$#DA', $name)) {
            throw new \Packetery\Nette\InvalidArgumentException('Session name cannot contain dot.');
        }
        \session_name($name);
        return $this->setOptions(['name' => $name]);
    }
    /**
     * Gets the session name.
     */
    public function getName() : string
    {
        return $this->options['name'] ?? \session_name();
    }
    /********************* sections management ****************d*g**/
    /**
     * Returns specified session section.
     * @throws \Packetery\Nette\InvalidArgumentException
     */
    public function getSection(string $section, string $class = SessionSection::class) : SessionSection
    {
        return new $class($this, $section);
    }
    /**
     * Checks if a session section exist and is not empty.
     */
    public function hasSection(string $section) : bool
    {
        if ($this->exists() && !$this->started) {
            $this->autoStart(\false);
        }
        return !empty($_SESSION['__NF']['DATA'][$section]);
    }
    /** @return string[] */
    public function getSectionNames() : array
    {
        if ($this->exists() && !$this->started) {
            $this->autoStart(\false);
        }
        return \array_keys($_SESSION['__NF']['DATA'] ?? []);
    }
    /** @deprecated use getSectionNames() */
    public function getIterator() : \Iterator
    {
        \trigger_error(__METHOD__ . '() is deprecated', \E_USER_DEPRECATED);
        return new \ArrayIterator($this->getSectionNames());
    }
    /**
     * Cleans and minimizes meta structures.
     */
    private function clean() : void
    {
        if (!$this->isStarted()) {
            return;
        }
        \Packetery\Nette\Utils\Arrays::invoke($this->onBeforeWrite, $this);
        $nf =& $_SESSION['__NF'];
        foreach ($nf['META'] ?? [] as $name => $foo) {
            if (empty($nf['META'][$name])) {
                unset($nf['META'][$name]);
            }
        }
    }
    /********************* configuration ****************d*g**/
    /**
     * Sets session options.
     * @return static
     * @throws \Packetery\Nette\NotSupportedException
     * @throws \Packetery\Nette\InvalidStateException
     */
    public function setOptions(array $options)
    {
        $normalized = [];
        $allowed = \ini_get_all('session', \false) + ['session.read_and_close' => 1, 'session.cookie_samesite' => 1];
        // for PHP < 7.3
        foreach ($options as $key => $value) {
            if (!\strncmp($key, 'session.', 8)) {
                // back compatibility
                $key = \substr($key, 8);
            }
            $normKey = \strtolower(\preg_replace('#(.)(?=[A-Z])#', '$1_', $key));
            // camelCase -> snake_case
            if (!isset($allowed["session.{$normKey}"])) {
                $hint = \substr((string) \Packetery\Nette\Utils\Helpers::getSuggestion(\array_keys($allowed), "session.{$normKey}"), 8);
                if ($key !== $normKey) {
                    $hint = \preg_replace_callback('#_(.)#', function ($m) {
                        return \strtoupper($m[1]);
                    }, $hint);
                    // snake_case -> camelCase
                }
                throw new \Packetery\Nette\InvalidStateException("Invalid session configuration option '{$key}'" . ($hint ? ", did you mean '{$hint}'?" : '.'));
            }
            $normalized[$normKey] = $value;
        }
        if (isset($normalized['read_and_close'])) {
            if (\session_status() === \PHP_SESSION_ACTIVE) {
                throw new \Packetery\Nette\InvalidStateException('Cannot configure "read_and_close" for already started session.');
            }
            $this->readAndClose = (bool) $normalized['read_and_close'];
            unset($normalized['read_and_close']);
        }
        $this->autoStart = $normalized['auto_start'] ?? \true;
        unset($normalized['auto_start']);
        if (\session_status() === \PHP_SESSION_ACTIVE) {
            $this->configure($normalized);
        }
        $this->options = $normalized + $this->options;
        return $this;
    }
    /**
     * Returns all session options.
     */
    public function getOptions() : array
    {
        return $this->options;
    }
    /**
     * Configures session environment.
     */
    private function configure(array $config) : void
    {
        $special = ['cache_expire' => 1, 'cache_limiter' => 1, 'save_path' => 1, 'name' => 1];
        $cookie = $origCookie = \session_get_cookie_params();
        foreach ($config as $key => $value) {
            if ($value === null || \ini_get("session.{$key}") == $value) {
                // intentionally ==
                continue;
            } elseif (\strncmp($key, 'cookie_', 7) === 0) {
                $cookie[\substr($key, 7)] = $value;
            } else {
                if (\session_status() === \PHP_SESSION_ACTIVE) {
                    throw new \Packetery\Nette\InvalidStateException("Unable to set 'session.{$key}' to value '{$value}' when session has been started" . ($this->started ? '.' : ' by session.auto_start or session_start().'));
                }
                if (isset($special[$key])) {
                    ("session_{$key}")($value);
                } elseif (\function_exists('ini_set')) {
                    \ini_set("session.{$key}", (string) $value);
                } else {
                    throw new \Packetery\Nette\NotSupportedException("Unable to set 'session.{$key}' to '{$value}' because function ini_set() is disabled.");
                }
            }
        }
        if ($cookie !== $origCookie) {
            if (\PHP_VERSION_ID >= 70300) {
                @\session_set_cookie_params($cookie);
                // @ may trigger warning when session is active since PHP 7.2
            } else {
                @\session_set_cookie_params(
                    // @ may trigger warning when session is active since PHP 7.2
                    $cookie['lifetime'],
                    $cookie['path'] . (isset($cookie['samesite']) ? '; SameSite=' . $cookie['samesite'] : ''),
                    $cookie['domain'],
                    $cookie['secure'],
                    $cookie['httponly']
                );
            }
            if (\session_status() === \PHP_SESSION_ACTIVE) {
                $this->sendCookie();
            }
        }
        if ($this->handler) {
            \session_set_save_handler($this->handler);
        }
    }
    /**
     * Sets the amount of time (like '20 minutes') allowed between requests before the session will be terminated,
     * null means "for a maximum of 3 hours or until the browser is closed".
     * @return static
     */
    public function setExpiration(?string $expire)
    {
        if ($expire === null) {
            return $this->setOptions(['gc_maxlifetime' => self::DefaultFileLifetime, 'cookie_lifetime' => 0]);
        } else {
            $expire = \Packetery\Nette\Utils\DateTime::from($expire)->format('U') - \time();
            return $this->setOptions(['gc_maxlifetime' => $expire, 'cookie_lifetime' => $expire]);
        }
    }
    /**
     * Sets the session cookie parameters.
     * @return static
     */
    public function setCookieParameters(string $path, ?string $domain = null, ?bool $secure = null, ?string $sameSite = null)
    {
        return $this->setOptions(['cookie_path' => $path, 'cookie_domain' => $domain, 'cookie_secure' => $secure, 'cookie_samesite' => $sameSite]);
    }
    /** @deprecated */
    public function getCookieParameters() : array
    {
        \trigger_error(__METHOD__ . '() is deprecated.', \E_USER_DEPRECATED);
        return \session_get_cookie_params();
    }
    /**
     * Sets path of the directory used to save session data.
     * @return static
     */
    public function setSavePath(string $path)
    {
        return $this->setOptions(['save_path' => $path]);
    }
    /**
     * Sets user session handler.
     * @return static
     */
    public function setHandler(\SessionHandlerInterface $handler)
    {
        if ($this->started) {
            throw new \Packetery\Nette\InvalidStateException('Unable to set handler when session has been started.');
        }
        $this->handler = $handler;
        return $this;
    }
    /**
     * Sends the session cookies.
     */
    private function sendCookie() : void
    {
        $cookie = \session_get_cookie_params();
        $this->response->setCookie(\session_name(), \session_id(), $cookie['lifetime'] ? $cookie['lifetime'] + \time() : 0, $cookie['path'], $cookie['domain'], $cookie['secure'], $cookie['httponly'], $cookie['samesite'] ?? null);
    }
}
