<?php
/**
 * Created by PhpStorm.
 * User: crose
 * Date: 5/4/16
 * Time: 1:34 PM
 */

namespace IMATHUZH\Qfq\Core\Store;


use IMATHUZH\Qfq\Core\Typo3\T3Handler;


/**
 * Class Session
 * @package qfq
 */
class Session {

    private static $instance = null;
    private static $phpUnit = null;
    private static $sessionLocal = array();
    private static $sessionId = null;
    private static $sessionOpen = false;
    private static $lastActivity = false;

    private static $flagFeUserChanged = false;

    /**
     * @param bool|false $phpUnit
     *
     * @throws \CodeException
     */
    private function __construct($phpUnit = false) {

        #TODO: rewrite $phpUnit to: "if (!defined('PHPUNIT_QFQ')) {...}"
        if (self::$phpUnit !== null) {
            throw new \CodeException("Try to set flag phpunit again - that should not happen.", ERROR_CODE_SHOULD_NOT_HAPPEN);
        }

        self::$phpUnit = $phpUnit;

        if (self::$phpUnit === true) {
            self::$sessionLocal = array();
        } else {

            ini_set('session.cookie_httponly', 1);

            $lifetime = SYSTEM_COOKIE_LIFETIME;

            $path = $this->getSitePath();

            $secure = false; // if you only want to receive the cookie over HTTPS
            $httponly = true; // prevent JavaScript access to session cookie
            $samesite = 'lax';

            // Needed expire date for header() method
            $expireDate = date("D, d-M-Y H:i:s", strtotime('+2 days')) . ' GMT';

            // More information about previous dots in domains: https://stackoverflow.com/questions/348282/php-cookie-domain-subdomain-control
            if (PHP_VERSION_ID < 70300) {
                session_set_cookie_params($lifetime, $path . ';SameSite=' . $samesite, null, $secure, $httponly);
            } else {
                session_set_cookie_params([
                    'lifetime' => $lifetime,
                    'path' => $path,
                    'domain' => null,
                    'secure' => $secure,
                    'httponly' => $httponly,
                    'samesite' => $samesite
                ]);
            }

            // Old way to set cookie_params without samesite - samesite is needed later
            // session_set_cookie_params($lifetime, $path);

            $currentCookieParams = session_get_cookie_params();
            session_name(SESSION_NAME);

            session_start();

            // Currently, setcookie() is only called to really extend the lifetime. All other parameter needs to be given again.
            // For PHP Version < 7.3 cookie with samesite can only be set over the header() method.
            // Secure is actually set to false because of testing instance, otherwise SIP cant get requested without HTTPS.
            // Setup for Secure-> true : 1. Change value of $secure from false to true. 2. Write String "Secure;" after "HttpOnly;" in header() function below.
            if (PHP_VERSION_ID < 70300) {
// header() here makes no sense, cause it's too late: T3 already sent some non header content.
//                header("Set-Cookie: ".SESSION_NAME."=".session_id()."; expires=".$date."; path=".$path."; domain=".$currentCookieParams['domain']."; HttpOnly; SameSite=Lax");
            } else {
                setcookie(SESSION_NAME, session_id(), [
                    'expires' => time() + $lifetime,
                    'path' => $path,
                    'httponly' => $httponly,
                    'secure' => $secure,
                    'samesite' => 'Lax'
                ]);
            }

            // Old way to set cookie without samesite - samesite is needed later
            //  setcookie(SESSION_NAME, session_id(), time() + $lifetime, $path, $currentCookieParams['domain'], $currentCookieParams['secure'], true);

            self::$sessionId = session_id();
        }

        self::$sessionOpen = true;
        self::persistSiteAndBeUserData();
    }

    /**
     * Extract the SitePath of the current T3 installation.
     *
     * @return bool|string - <path>  with a trailing '/'
     * @throws \CodeException
     */
    private static function getSitePath() {

        if (empty($_SERVER['SCRIPT_NAME'])) {
            throw new \CodeException('Missing _SERVER[SCRIPT_NAME]', ERROR_SESSION_BROKEN_SCRIPT_PATH);
        }

        $path = $_SERVER['SCRIPT_NAME'] ?? '';
        $pos = strrpos($path, '/');

        if ($pos === false) {
            throw new \CodeException("Broken _SERVER[SCRIPT_NAME]: $path", ERROR_SESSION_BROKEN_SCRIPT_PATH);
        }
        // Remove PHP script
        $path = substr($path, 0, $pos + 1);

        // QFQ might be called by API - justify to the SitePath
        $pos = strpos($path, 'typo3conf/');
        if ($pos !== false) {
            $path = substr($path, 0, $pos - strlen($path));
        }

        if (empty($path)) {
            throw new \CodeException("Broken _SERVER[SCRIPT_NAME]: $path", ERROR_SESSION_BROKEN_SCRIPT_PATH);
        }

        return $path;
    }

    /**
     * Free a lock on the current session
     */
    public static function close() {

        if (self::$sessionOpen) {
            session_write_close();
        }
        self::$sessionOpen = false;

    }

    /**
     * Destroy a session - this is only needed in case of attacks
     */
    public static function destroy() {

        if (!defined('PHPUNIT_QFQ')) {

            if (isset($_COOKIE[SESSION_NAME])) {
                unset($_COOKIE[SESSION_NAME]);
                setcookie(SESSION_NAME, '', time() - 86400, '/'); // empty value and old timestamp
            }

            session_destroy();
        }

        $_SESSION = array();
    }

    /**
     * By default the session is opened during the bootstrap. In case the session has been closed manually, reopen it here.
     * This code has never been tested - it should not happen that a session needs manually closed and reopen than.
     * Nevertheless: This code is therefore a fallback in case the reopen really happens somewhat. Maybe this happens in
     * upcoming logic.
     *
     */
    public static function open() {

        if (self::$sessionOpen != true && self::$sessionId != null) {
            session_id(self::$sessionId);
            session_start();
            self::$sessionOpen = true;
        }
    }

    /**
     * Check if the feUserUid stored in the session matches the current one. If not:
     *  - update feUser data in the session
     *  - set the flag that a user has changed
     * This method must be called each time a session is accessed, because the user status may
     * change during one HTTP request.
     *
     * The check is done only then $GLOBAS['TSFE'] exists, i.e. when we are in a Typo3 environment.
     * The user data is not changed otherwise, for instance from an API call.
     *
     * It's necessary to have feUser / feUserUid available in API classes due to dynamic update,
     * which might reload data based on feUser / feUserUid.
     */
    private static function checkFeUserUid() {

        if (!isset($GLOBALS["TSFE"])) {
            return;
        }

        // Get the last activity timestamp to compare later against timeout.
        self::$lastActivity = T3Handler::isFeUserLoggedIn() ? self::get(SESSION_LAST_ACTIVITY) : false;

        // Get the actual fe user and from the session to compare.
        $feUserUid = $GLOBALS["TSFE"]->fe_user->user["uid"] ?? false;
        $feUserUidSession = Session::get(SESSION_FE_USER_UID);
        self::$flagFeUserChanged = ($feUserUid !== $feUserUidSession);

        // Update user data in the session when changed
        if (self::$flagFeUserChanged) {
            $feUserName = $GLOBALS["TSFE"]->fe_user->user["username"] ?? false;
            $feUserGroup = $GLOBALS["TSFE"]->fe_user->user["usergroup"] ?? false;
            // save new feUserUid, feUserName
            Session::set(SESSION_FE_USER_UID, $feUserUid);
            Session::set(SESSION_FE_USER, $feUserName);
            Session::set(SESSION_FE_USER_GROUP, $feUserGroup);
        }

    }

    /**
     * Stores in the session site related data that do not change during an HTTP request.
     */
    private static function persistSiteAndBeUserData(): void
    {
        $beUser = $GLOBALS["BE_USER"]->user["username"] ?? false;
        $languageId = T3Info::getLanguageId() ?? false;
        $languagePath = T3Info::getLanguagePath($languageId) ?? false;
        $pageId = $GLOBALS["TSFE"]->id ?? 0;

        Session::set(SESSION_BE_USER, $beUser);
        Session::set(SESSION_PAGE_LANGUAGE, $languageId);
        Session::set(SESSION_PAGE_LANGUAGE_PATH, $languagePath);
        Session::set(SESSION_PAGE_ID, $pageId);
    }

    /**
     * Return content to given $key (=SIP).
     * Return 'false' if not found.
     *
     * @param $key
     *
     * @return bool
     */
    public static function get($key) {

        if (!self::$sessionOpen) {
            self::open();
        }

        if (self::$phpUnit) {
            $value = isset(self::$sessionLocal[$key]) ? self::$sessionLocal[$key] : false;
        } else {
            $value = isset($_SESSION[SESSION_NAME][$key]) ? $_SESSION[SESSION_NAME][$key] : false;
        }

        return $value;
    }

    /**
     *
     */
    public static function clearAll() {

        if (!self::$sessionOpen) {
            self::open();
        }

        if (self::$phpUnit) {
            self::$sessionLocal = array();
        } else {
            $_SESSION[SESSION_NAME] = array();
        }
    }

    /**
     * @param $key
     * @param $value
     */
    public static function set($key, $value) {

        if (!self::$sessionOpen) {
            self::open();
        }

        if (self::$phpUnit) {
            self::$sessionLocal[$key] = $value;
        } else {
            $_SESSION[SESSION_NAME][$key] = $value;
        }
    }

    /**
     * Unset the given $key
     *
     * @param $key
     */
    public static function unsetItem($key) {

        if (!self::$sessionOpen) {
            self::open();
        }

        if (isset($_SESSION[SESSION_NAME][$key])) {
            unset($_SESSION[SESSION_NAME][$key]);
        }

    }

    /**
     * @param bool|false $phpUnit
     *
     * @return Session class
     * @throws \CodeException
     */
    public static function getInstance($phpUnit = false) {

        // Design Pattern: Singleton
        if (self::$instance === null) {
            self::$instance = new self($phpUnit);
        }

        if (!self::$sessionOpen) {
//            self::open();
        }

//        self::checkFeUserUid();
        return self::$instance;
    }

    /**
     * Checks if the QFQ session is expired.
     *
     * @param $timeout
     * @throws \UserFormException
     */
    public static function checkSessionExpired($timeout) {

        // Just to be sure that the given $timeout is supported by the current php.ini setup
        self::checkSessionTimeout($timeout);

        if (self::$lastActivity === false || $timeout === false || $timeout == 0) {
            return;
        }

        if (time() - self::$lastActivity > $timeout) {

            T3Handler::feLogOff();
            self::destroy();
        }
    }

    /**
     * Check Session Timeout
     *
     * @param $timeout
     * @throws \UserFormException
     */
    public static function checkSessionTimeout($timeout) {

        if (self::getPhpSessionTimeout() < $timeout) {
            throw new \UserFormException ("The specified timeout of $timeout seconds is higher than the PHP config 'session.gc_maxlifetime' ("
                . ini_get('session.gc_maxlifetime') . ") and/or 'session.cookie_lifetime' ("
                . ini_get('session.cookie_lifetime') . ")");
        }

        if ($timeout > SYSTEM_COOKIE_LIFETIME) {
            throw new \UserFormException ("The specified timeout of $timeout seconds is higher than the hardcoded cookie lifetime (" . SYSTEM_COOKIE_LIFETIME . ")");
        }
    }

    /**
     * Get the minimum of 'session.cookie_lifetime' and 'session.gc_maxlifetime'
     * A zero means unlimited and will be limited to one day.
     *
     * @return int|string
     */
    public static function getPhpSessionTimeout() {
        $timeout = ini_get('session.cookie_lifetime');
        $gc_maxlifetime = ini_get('session.gc_maxlifetime');

        if ($timeout == 0) {
            $timeout = 86400;
        }

        if ($gc_maxlifetime == 0) {
            // Just to set a limit
            $timeout = 86400;
        }
        // gc_maxlifetime should be ignored when the value is '' / false #20505
        if ($gc_maxlifetime === '' || $gc_maxlifetime === false) {
            return $timeout;
        }

        return $timeout > $gc_maxlifetime ? $gc_maxlifetime : $timeout;
    }

    /**
     * Stores SQL performance data into the session.
     *
     * Adds or updates the execution time for a specific SQL query key in the session-based
     * performance tracking array. Useful for collecting and later visualizing query performance
     * in the Dev Panel.
     *
     * @param string $level
     * @param string $query
     * @param int $time Execution time of the query in milliseconds.
     * @return void
     */
    public static function fillSqlPerformance(string $level, string $query, float $time): void {
        $performanceArray = self::get(PERFORMANCE_SESSION_KEY) ?: [];

        // Ensure there's at least one content block
        if (empty($performanceArray)) {
            return;
        }

        // Get the current content / Last
        $lastIndex = array_key_last($performanceArray);

        // Initialize level if not present
        if (!isset($performanceArray[$lastIndex][$level])) {
            $performanceArray[$lastIndex][$level] = [
                DEV_PANEL_PERFORMANCE_ARRAY_KEY_QUERY => $query,
                DEV_PANEL_PERFORMANCE_ARRAY_KEY_TIME => $time
            ];
        } else {
            // If it exists, just update the time
            $performanceArray[$lastIndex][$level][DEV_PANEL_PERFORMANCE_ARRAY_KEY_TIME] += $time;
        }

        // Save updated session data
        self::set(PERFORMANCE_SESSION_KEY, $performanceArray);
    }

    /**
     * Appends a single report cache event (hit or write) to the session.
     * Flat, chronological list — read by the Dev Panel ReportCache tab.
     *
     * @param string $type  REPORT_CACHE_EVENT_TYPE_HIT | REPORT_CACHE_EVENT_TYPE_WRITE
     * @param float  $ms     Duration in milliseconds.
     * @param string $level  Report level label (e.g. '10', '10.20').
     * @param string $file   Cache file path.
     * @return void
     */
    public static function fillReportCacheEvent(string $type, float $ms, string $level, string $file): void {
        $events = self::get(REPORT_CACHE_SESSION_KEY) ?: [];
        $events[] = [
            DEV_PANEL_REPORT_CACHE_ARRAY_KEY_TYPE  => $type,
            DEV_PANEL_REPORT_CACHE_ARRAY_KEY_MS    => $ms,
            DEV_PANEL_REPORT_CACHE_ARRAY_KEY_LEVEL => $level,
            DEV_PANEL_REPORT_CACHE_ARRAY_KEY_FILE  => $file,
        ];
        self::set(REPORT_CACHE_SESSION_KEY, $events);
    }

    /**
     * Adds an empty Subarray for the Dev Panel.
     * Used to make distinctions between different Reports.
     * @return void
     */
    public static function addEmptyContentBlock(): void {
        $performanceArray = self::get(PERFORMANCE_SESSION_KEY) ?: [];
        $performanceArray[] = [];
        self::set(PERFORMANCE_SESSION_KEY, $performanceArray);
    }

    public static function hasFeUserChanged(): bool
    {
        self::checkFeUserUid();
        $flag = self::$flagFeUserChanged;
        self::$flagFeUserChanged = false;
        return $flag;
    }
}