<?php

declare(strict_types=1);

namespace PpPhp\Core\Auth;

use PpPhp\Core\DB;
use PpPhp\Core\Security;

/**
 * Session control for a generated app (Security → Session Control).
 *
 * The build bakes the admin's choices as constants:
 *   PP_SESSION_TIMEOUT       idle minutes before auto-logout (0 = off)
 *   PP_SESSION_LIFETIME      absolute hours before forced logout (0 = off)
 *   PP_SESSION_MAX_CONCURRENT simultaneous sessions per user (0 = unlimited)
 *
 * Responsibilities:
 *   - track()   : on login, record the session in `user_sessions` and evict the
 *                 user's oldest sessions past the concurrent limit.
 *   - enforce()  : on every authenticated request, apply the idle timeout and
 *                 absolute lifetime, and honour an admin "terminate" (the row was
 *                 deleted from `user_sessions` → the user is logged out here).
 *   - untrack()  : on logout, drop the session's row.
 *
 * The `user_sessions` rows are what the console's Active Sessions panel lists and
 * the Kill / Kill-all actions delete. Everything is best-effort: a storage hiccup
 * never locks a user out of their own app (fails OPEN), it just skips enforcement.
 */
final class SessionGuard
{
    private const TABLE = 'user_sessions';
    private static bool $tableReady = false;

    public static function enabled(): bool
    {
        return defined('PP_AUTH_ENABLED') && PP_AUTH_ENABLED;
    }

    private static function timeout(): int   { return defined('PP_SESSION_TIMEOUT') ? max(0, (int) PP_SESSION_TIMEOUT) : 0; }
    private static function lifetime(): int  { return defined('PP_SESSION_LIFETIME') ? max(0, (int) PP_SESSION_LIFETIME) : 0; }
    private static function maxConc(): int   { return defined('PP_SESSION_MAX_CONCURRENT') ? max(0, (int) PP_SESSION_MAX_CONCURRENT) : 0; }
    private static function uid(): int       { return (int) (Security::CurrentUserData()['id'] ?? 0); }

    /** Create the tracking table if a fresh install never had the console set it up. */
    private static function ensureTable(): void
    {
        if (self::$tableReady) return;
        self::$tableReady = true;
        try {
            DB::Exec(
                'CREATE TABLE IF NOT EXISTS `' . self::TABLE . '` ('
                . '`id` INT AUTO_INCREMENT PRIMARY KEY,'
                . '`user_id` INT NOT NULL,'
                . '`session_id` VARCHAR(255) NOT NULL,'
                . '`ip_address` VARCHAR(45) DEFAULT NULL,'
                . '`user_agent` TEXT DEFAULT NULL,'
                . '`last_activity` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,'
                . '`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,'
                . 'INDEX idx_user (`user_id`), INDEX idx_session (`session_id`), INDEX idx_last_activity (`last_activity`)'
                . ') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
            );
        } catch (\Throwable $e) { /* best effort */ }
    }

    /** Record this session on login and enforce the per-user concurrent limit. */
    public static function track(): void
    {
        if (!self::enabled()) return;
        $uid = self::uid();
        if ($uid <= 0) return;
        self::ensureTable();
        $sid = session_id();
        try {
            // Replace any stale row for this session id, then insert the current one.
            DB::Delete(self::TABLE, ['session_id' => $sid]);
            DB::Insert(self::TABLE, [
                'user_id'    => $uid,
                'session_id' => $sid,
                'ip_address' => substr((string) ($_SERVER['REMOTE_ADDR'] ?? ''), 0, 45),
                'user_agent' => substr((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 500),
            ]);
            // Concurrent limit: keep the newest N, evict the rest (this one counts).
            $max = self::maxConc();
            if ($max > 0) {
                $rows = DB::FetchAll(
                    'SELECT id FROM `' . self::TABLE . '` WHERE user_id = ? ORDER BY last_activity DESC, id DESC',
                    [$uid]
                ) ?: [];
                if (count($rows) > $max) {
                    foreach (array_slice($rows, $max) as $r) {
                        DB::Delete(self::TABLE, ['id' => (int) $r['id']]);
                    }
                }
            }
            $_SESSION['_pp_login'] = time();
            $_SESSION['_pp_last']  = time();
            $_SESSION['_pp_sess']  = true; // this session is now server-tracked
        } catch (\Throwable $e) { /* best effort */ }
    }

    /** Per-request guard. Silently returns for anonymous or unauthenticated pages. */
    public static function enforce(): void
    {
        if (!self::enabled()) return;
        $uid = self::uid();
        if ($uid <= 0) return;

        $now   = time();
        $login = (int) ($_SESSION['_pp_login'] ?? 0);
        $last  = (int) ($_SESSION['_pp_last'] ?? 0);
        if ($login === 0) { $login = $_SESSION['_pp_login'] = $now; }
        if ($last === 0)  { $last  = $_SESSION['_pp_last']  = $now; }

        // Idle timeout.
        $timeout = self::timeout();
        if ($timeout > 0 && ($now - $last) > $timeout * 60) {
            self::expire('Your session timed out after a period of inactivity. Please sign in again.');
            return;
        }
        // Absolute lifetime.
        $lifetime = self::lifetime();
        if ($lifetime > 0 && ($now - $login) > $lifetime * 3600) {
            self::expire('Your session has expired. Please sign in again.');
            return;
        }

        // Server-side termination + activity touch (only for tracked sessions).
        self::ensureTable();
        $sid = session_id();
        try {
            if (empty($_SESSION['_pp_sess'])) {
                // A session that predates tracking (or a fresh login not yet tracked):
                // adopt it so future admin "terminate" works, but never kill it now.
                DB::Delete(self::TABLE, ['session_id' => $sid]);
                DB::Insert(self::TABLE, [
                    'user_id'    => $uid,
                    'session_id' => $sid,
                    'ip_address' => substr((string) ($_SERVER['REMOTE_ADDR'] ?? ''), 0, 45),
                    'user_agent' => substr((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 500),
                ]);
                $_SESSION['_pp_sess'] = true;
            } else {
                $row = DB::FetchAll('SELECT id FROM `' . self::TABLE . '` WHERE session_id = ? LIMIT 1', [$sid])[0] ?? null;
                if ($row === null) {
                    // The admin terminated this session from the console.
                    self::expire('Your session was ended by an administrator. Please sign in again.');
                    return;
                }
                DB::Exec('UPDATE `' . self::TABLE . '` SET last_activity = NOW() WHERE session_id = ?', [$sid]);
            }
        } catch (\Throwable $e) { /* fail open */ }

        $_SESSION['_pp_last'] = $now;
    }

    /** Drop this session's tracking row (logout). */
    public static function untrack(): void
    {
        if (!self::enabled()) return;
        try { DB::Delete(self::TABLE, ['session_id' => session_id()]); }
        catch (\Throwable $e) {}
    }

    /** Clear the session and bounce to login (HTML) or answer 401 (API/AJAX). */
    private static function expire(string $message): void
    {
        try { self::untrack(); } catch (\Throwable $e) {}
        $isApi = str_contains((string) ($_SERVER['SCRIPT_NAME'] ?? ''), '/api/')
            || strtolower((string) ($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '')) === 'xmlhttprequest'
            || str_contains((string) ($_SERVER['HTTP_ACCEPT'] ?? ''), 'application/json');
        Security::Logout(); // clears the session array + destroys it + expires the cookie
        if ($isApi) {
            if (!headers_sent()) { http_response_code(401); header('Content-Type: application/json'); }
            echo json_encode(['success' => false, 'error' => $message, 'sessionExpired' => true]);
            exit;
        }
        // Start a fresh session so the "why you were logged out" flash survives the
        // redirect (the destroyed session above can't carry it).
        $base = defined('APP_BASE') ? APP_BASE : '';
        if (!headers_sent()) {
            if (session_status() !== PHP_SESSION_ACTIVE) { @session_start(); }
            $_SESSION['_flash'] = ['message' => $message, 'type' => 'warning'];
            header('Location: ' . $base . '/generated/auth/login.php');
        }
        exit;
    }
}
