<?php

declare(strict_types=1);

namespace PpPhp\Core;

/**
 * Security API – comprehensive authentication, authorisation, and security utilities.
 *
 * Mirrors PHPRunner's Security class while adding modern features:
 *  - CSRF protection (token generation & validation)
 *  - Rate limiting (brute‑force prevention)
 *  - TOTP‑based two‑factor authentication
 *  - Secure "Remember Me" functionality
 *  - JWT‑ready authentication tokens
 *  - Bcrypt password hashing via password_hash()
 *
 * All session‑related functions require session_start() to have been called.
 */
class Security
{
    // ── Session keys ────────────────────────────────────────────────────
    private const SESSION_USER       = 'security_user';
    private const SESSION_PERMISSIONS = 'security_permissions';
    private const SESSION_ALLOWED_PAGES = 'security_allowed_pages';
    private const SESSION_OWNER_IDS  = 'security_owner_ids';
    private const SESSION_DISPLAY_NAME = 'security_display_name';

    // ── Authentication ──────────────────────────────────────────────────

    /**
     * Attempt to log a user in with username/email and password.
     *
     * @param string $username   Username or email.
     * @param string $password   Plain‑text password.
     * @param bool   $rememberMe If true, set a persistent "Remember Me" cookie.
     * @return bool True on success, false on failure.
     */
    public static function Login(string $username, string $password, bool $rememberMe = false): bool
    {
        $user = self::GetUserData($username);
        if ($user === false) {
            return false;
        }

        if (!password_verify($password, $user['password'])) {
            return false;
        }

        // Session fixation: an attacker who can set a victim's session id (via
        // a link or an XSS on a sibling app) would otherwise be holding a
        // session that becomes authenticated the moment the victim signs in.
        // A fresh id at the privilege boundary breaks that.
        self::regenerateSession();

        // Store in session
        $_SESSION[self::SESSION_USER] = [
            'id'       => $user['id'],
            'username' => $user['username'],
            'email'    => $user['email'],
            'role'     => $user['role'],
            'fullname' => $user['fullname'] ?? $user['username'],
        ];

        if ($rememberMe) {
            self::SetRememberMeCookie($user['id']);
        }

        return true;
    }

    /**
     * Give the caller a brand new session id, preserving the session data.
     *
     * Safe to call when no session is running (CLI, tests) — it simply does
     * nothing rather than emitting a warning.
     */
    public static function regenerateSession(): void
    {
        if (session_status() === PHP_SESSION_ACTIVE && !headers_sent()) {
            session_regenerate_id(true);
        }
    }

    /**
     * Log the current user out (no redirect).
     */
    public static function Logout(): void
    {
        // Clear the entire session array
        $_SESSION = [];

        // Destroy the session file
        if (ini_get('session.use_cookies')) {
            $params = session_get_cookie_params();
            setcookie(
                session_name(),
                '',
                time() - 42000,
                $params['path'],
                $params['domain'],
                $params['secure'],
                $params['httponly']
            );
        }
        session_destroy();
    }

    /**
     * Login as a specific user (no password check, no redirect).
     * Called after successful external authentication (OAuth, etc.).
     *
     * @param string $username The username to log in as.
     * @return bool True if the user was found and session set.
     */
    public static function LoginAs(string $username): bool
    {
        $user = self::GetUserData($username);
        if ($user === false) {
            return false;
        }
        self::regenerateSession();      // same privilege boundary as Login()
        $_SESSION[self::SESSION_USER] = [
            'id'       => $user['id'],
            'username' => $user['username'],
            'email'    => $user['email'],
            'role'     => $user['role'],
            'fullname' => $user['fullname'] ?? $user['username'],
        ];
        return true;
    }

    /**
     * Verify a username/password pair without logging in.
     *
     * @param string $username
     * @param string $password
     * @return bool True if credentials are valid.
     */
    public static function CheckUsernamePassword(string $username, string $password): bool
    {
        $user = self::GetUserData($username);
        if ($user === false) {
            return false;
        }
        return password_verify($password, $user['password']);
    }

    // ── User state ──────────────────────────────────────────────────────

    /** Return true if a user is currently logged in. */
    public static function IsLoggedIn(): bool
    {
        // Accept both the new Security key and the legacy Auth key
        $id = (int) ($_SESSION[self::SESSION_USER]['id'] ?? $_SESSION['user']['id'] ?? 0);
        if ($id < 1) return false;
        return self::sessionAccountExists($id);
    }

    /**
     * Verify the signed-in account still exists in the database.
     *
     * A session can outlive the row it points at — most notably after a fresh
     * install, which purges the users table and re-creates the admin with a NEW
     * auto-increment id while the browser still holds the previous session
     * cookie. The app would then look signed in but write a dangling user id,
     * failing any FK that references users(id) (e.g. projects.created_by).
     *
     * Checked once per request and cached. Any database problem fails OPEN
     * (treated as valid) so a transient outage can never lock everyone out.
     */
    private static function sessionAccountExists(int $id): bool
    {
        static $cache = [];
        if (isset($cache[$id])) return $cache[$id];
        try {
            DB::Init();
            $rows = DB::FetchAll(
                'SELECT ' . \PpPhp\Core\Auth\UserSchema::qCol('id') . ' AS id FROM '
                . \PpPhp\Core\Auth\UserSchema::qTable()
                . ' WHERE ' . \PpPhp\Core\Auth\UserSchema::qCol('id') . ' = ?',
                [$id]
            );
            $ok = !empty($rows);
        } catch (\Throwable) {
            return true;   // cannot verify → assume valid, never lock out
        }
        if (!$ok) {
            // Stale session → drop the identity so guards send the user to /login
            // for a fresh sign-in that stores the correct id.
            unset($_SESSION[self::SESSION_USER], $_SESSION['user']);
        }
        return $cache[$id] = $ok;
    }

    /** Return true if the current user has the superadmin role (owner counts — it is the top tier). */
    public static function IsAdmin(): bool
    {
        return in_array(self::CurrentUserGroup(), ['owner', 'superadmin'], true);
    }

    /**
     * The hidden platform-owner tier — a single vendor/root account that sits
     * ABOVE superadmin and exclusively manages core plugins. It is invisible to
     * every other account (see hiddenUserWhere / isHiddenUser).
     */
    public static function IsOwner(): bool
    {
        return self::CurrentUserGroup() === 'owner';
    }

    /** The reserved owner username (env-overridable). */
    public static function ownerUsername(): string
    {
        $u = getenv('PP_OWNER_USERNAME');
        return ($u !== false && $u !== '') ? $u : 'admin-ppradhan';
    }

    /** True when a username/role belongs to the hidden owner tier. */
    public static function isHiddenUser(string $username = '', string $role = ''): bool
    {
        return $role === 'owner' || ($username !== '' && strcasecmp($username, self::ownerUsername()) === 0);
    }

    /**
     * A SQL fragment that hides the owner account from everyone except the owner.
     * Returns '' (no filter) for the owner, else a condition excluding owner rows.
     * Prefix-aware: pass the table alias if the query uses one.
     */
    public static function hiddenUserWhere(string $alias = ''): string
    {
        if (self::IsOwner()) {
            return '';
        }
        $p = $alias !== '' ? "`{$alias}`." : '';
        return "({$p}`role` <> 'owner')";
    }

    /**
     * Idempotently ensure the hidden owner account exists (creates it + widens
     * the role enum on first run). The creation password comes from
     * Auth\OwnerCredentials (env → configured hash → random), never a fixed
     * built-in. Email is env-overridable (PP_OWNER_EMAIL). Never throws — a seed
     * failure must not break the app. Guarded to run at most once per request.
     */
    public static function ensureOwnerAccount(bool $force = false): void
    {
        static $done = false;
        if ($done && !$force) {
            return;
        }
        $done = true;
        try {
            // Owner status is NO LONGER granted by username alone (that was a
            // privilege-escalation: anyone could create `admin-ppradhan`). It is
            // granted only when the owner is VERIFIED — either grandfathered (a
            // legit install that already has an owner account) or established via
            // the email-code challenge (OwnerVerification). Until then, this
            // method does nothing, so a stray `admin-ppradhan` is never promoted.
            \PpPhp\Core\Auth\OwnerVerification::ensureGrandfathered();
            if (!\PpPhp\Core\Auth\OwnerVerification::isVerified()) {
                return;
            }
            $owner = self::ownerUsername();
            // Canonical owner email — permanent for the platform vendor account,
            // overridable only via the PP_OWNER_EMAIL environment variable.
            $email = getenv('PP_OWNER_EMAIL') ?: PlatformSettings::DEFAULT_OWNER_EMAIL;
            // The hidden owner account needs a role column to exist; a mapped
            // existing table without one simply has no owner concept.
            if (\PpPhp\Core\Auth\UserSchema::col('role') === null) {
                return;
            }
            $uT = \PpPhp\Core\Auth\UserSchema::qTable();
            $uId = \PpPhp\Core\Auth\UserSchema::qCol('id');
            $uRole = \PpPhp\Core\Auth\UserSchema::qCol('role');
            $uEmail = \PpPhp\Core\Auth\UserSchema::qCol('email');
            $uName = \PpPhp\Core\Auth\UserSchema::qCol('username');
            $uPass = \PpPhp\Core\Auth\UserSchema::qCol('password');
            $rows = DB::FetchAll(
                "SELECT {$uId} AS id, {$uRole} AS role, {$uEmail} AS email FROM {$uT} WHERE {$uName} = ?",
                [$owner]
            );
            if (!empty($rows)) {
                $id = (int) $rows[0]['id'];
                if (($rows[0]['role'] ?? '') !== 'owner') {
                    self::widenRoleEnum();
                    DB::Query("UPDATE {$uT} SET {$uRole} = ? WHERE {$uId} = ?", ['owner', $id]);
                }
                // Keep the canonical email pinned (self-heals older seeds).
                if (($rows[0]['email'] ?? '') !== $email) {
                    DB::Query("UPDATE {$uT} SET {$uEmail} = ? WHERE {$uId} = ?", [$email, $id]);
                }
                return;
            }
            self::widenRoleEnum();
            // Password source, in priority order (see Auth\OwnerCredentials):
            //   PP_OWNER_PASSWORD env → the configured/bundled owner hash →
            //   a random one-off written to storage/owner-initial-password.txt.
            // There is deliberately NO fixed built-in default — an identical
            // password on every install is the shared-secret trap we are closing.
            $seed = \PpPhp\Core\Auth\OwnerCredentials::seedHashForCreate();
            DB::Query(
                "INSERT INTO {$uT} ({$uName}, {$uEmail}, {$uPass}, {$uRole}) VALUES (?, ?, ?, ?)",
                [$owner, $email, $seed['hash'], 'owner']
            );
        } catch (\Throwable $e) {
            // swallow — the platform must run even if seeding is not possible yet
        }
    }

    /** Add 'owner' to the users.role enum if it isn't already there (idempotent). */
    private static function widenRoleEnum(): void
    {
        try {
            $roleCol = \PpPhp\Core\Auth\UserSchema::col('role');
            if ($roleCol === null) {
                return;
            }
            $col = DB::FetchAll("SHOW COLUMNS FROM " . \PpPhp\Core\Auth\UserSchema::qTable() . " LIKE '{$roleCol}'");
            $type = strtolower((string) ($col[0]['Type'] ?? ''));
            // Only widen an EXISTING enum. A mapped table whose role column is a
            // plain VARCHAR accepts 'owner' already — never rewrite its type,
            // which would drop any custom role values it holds.
            if (str_starts_with($type, 'enum(') && !str_contains($type, "'owner'")) {
                DB::Query(
                    'ALTER TABLE ' . \PpPhp\Core\Auth\UserSchema::qTable()
                    . ' MODIFY ' . \PpPhp\Core\Auth\UserSchema::qCol('role')
                    . " ENUM('owner','superadmin','developer','user') NOT NULL DEFAULT 'user'"
                );
            }
        } catch (\Throwable $e) {
            // ignore — non-MySQL or insufficient privilege; owner insert may still work if enum already allows it
        }
    }

    /** Return true if not logged in (guest). */
    public static function IsGuest(): bool
    {
        return !self::IsLoggedIn();
    }

    // ── Auth guards (redirect / halt) ─────────────────────────────────

    /**
     * Redirect to login if the user is not authenticated.
     */
    public static function RequireAuth(): void
    {
        // Generated apps set PP_AUTH_ENABLED=false when database authentication is
        // not enabled → open access (no login gate). The console never defines this
        // constant, so its own auth is always enforced.
        if (defined('PP_AUTH_ENABLED') && !PP_AUTH_ENABLED) return;
        if (!self::IsLoggedIn()) {
            $base = defined('BASE_URL') ? rtrim(BASE_URL, '/') : '';
            header('Location: ' . $base . '/login');
            exit;
        }
    }

    /**
     * Halt with 403 if the current user lacks one of the required roles.
     *
     * @param array $roles Allowed roles (e.g. ['superadmin', 'developer']).
     */
    public static function RequireRole(array $roles): void
    {
        if (defined('PP_AUTH_ENABLED') && !PP_AUTH_ENABLED) return;   // open access → no role gate
        if (array_intersect(self::CurrentUserGroups(), $roles) === []) {
            http_response_code(403);
            echo '403 Forbidden - Insufficient permissions.';
            exit;
        }
    }

    /**
     * Friendly alternative to RequireRole for user-facing pages: instead of a
     * bare 403 page, send the user somewhere safe and show them a SweetAlert
     * explaining why. Pages render the message via core/views/flash-alert.php.
     */
    public static function RequireRoleOrAlert(array $roles, string $message = '', string $redirectTo = '/projects'): void
    {
        if (array_intersect(self::CurrentUserGroups(), $roles) !== []) {
            return;
        }
        self::Deny($message !== '' ? $message : "You don't have permission to open that screen.", $redirectTo);
    }

    /** Flash a "not allowed" SweetAlert and bounce the user to a safe page. */
    public static function Deny(string $message, string $redirectTo = '/projects'): void
    {
        self::Flash('error', 'Not allowed', $message);
        $base = defined('BASE_URL') ? rtrim(BASE_URL, '/') : '';
        header('Location: ' . $base . $redirectTo);
        exit;
    }

    /**
     * Queue a one-time SweetAlert for the next page load.
     *
     * @param string $type One of SweetAlert's icons: success | error | warning | info.
     */
    public static function Flash(string $type, string $title, string $text = ''): void
    {
        $_SESSION['pp_flash'] = ['type' => $type, 'title' => $title, 'text' => $text];
    }

    
    /** Return the current username (or empty string if guest). */
    public static function CurrentUsername(): string
    {
        return $_SESSION[self::SESSION_USER]['username'] ?? $_SESSION['user']['username'] ?? '';
    }

    /** Return the current user's role/group name. */
    public static function CurrentUserGroup(): string
    {
        return $_SESSION[self::SESSION_USER]['role'] ?? $_SESSION['user']['role'] ?? '';
    }

    /**
     * Return all groups the current user belongs to (array).
     * In the current schema each user has a single role, but multi‑role
     * can be added later. For now wraps single role in an array.
     */
    public static function CurrentUserGroups(): array
    {
        $role = self::CurrentUserGroup();
        if ($role === '') {
            return [];
        }
        // Owner is the top tier: it implicitly holds every lower role, so any
        // RequireRole([...]) gate passes for the owner.
        if ($role === 'owner') {
            return ['owner', 'superadmin', 'developer', 'user'];
        }
        return [$role];
    }

    /** Return the current user's display name (or username if not set). */
    public static function GetDisplayName(): string
    {
        return $_SESSION[self::SESSION_DISPLAY_NAME]
            ?? $_SESSION[self::SESSION_USER]['fullname']
            ?? self::CurrentUsername();
    }

    /** Set the current user's display name in the session. */
    public static function SetDisplayName(string $name): void
    {
        $_SESSION[self::SESSION_DISPLAY_NAME] = $name;
    }

    /**
     * Return the full current user data array.
     * Data is cached in the session; call RefreshUserData() to reload from DB.
     */
    public static function CurrentUserData(): array
    {
        return $_SESSION[self::SESSION_USER] ?? $_SESSION['user'] ?? [];
    }

    /**
     * Return full user data from the login table for a given username (or email).
     * Returns false if the user was not found.
     */
    public static function GetUserData(string $username): array|false
    {
        DB::Init();
        // Resolve the configured auth table + column map; aliasedColumns() exposes
        // every mapped field under its LOGICAL name so the array keys below
        // ($user['password'] / ['email'] / ['role'] …) work whatever the real
        // column names are. Falls back to `users` + standard names when no
        // config/auth.php is present (console + pre-feature builds).
        $table = \PpPhp\Core\Auth\UserSchema::qTable();
        $cols  = \PpPhp\Core\Auth\UserSchema::aliasedColumns();
        $rows  = DB::FetchAll(
            "SELECT {$cols} FROM {$table} WHERE " . \PpPhp\Core\Auth\UserSchema::qCol('username') . ' = ?',
            [$username]
        );
        if (!empty($rows)) {
            return $rows[0];
        }
        // Also try email
        $emailCol = \PpPhp\Core\Auth\UserSchema::qCol('email');
        if ($emailCol !== null) {
            $rows = DB::FetchAll("SELECT {$cols} FROM {$table} WHERE {$emailCol} = ?", [$username]);
        }
        return !empty($rows) ? $rows[0] : false;
    }

    /** Refresh the current user's data by reloading from the database. */
    public static function RefreshUserData(): void
    {
        if (!self::IsLoggedIn()) return;
        $user = self::GetUserData(self::CurrentUsername());
        if ($user !== false) {
            $_SESSION[self::SESSION_USER] = [
                'id'       => $user['id'],
                'username' => $user['username'],
                'email'    => $user['email'],
                'role'     => $user['role'],
                'fullname' => $user['fullname'] ?? $user['username'],
            ];
        }
    }

    // ── Permissions (table‑level) ───────────────────────────────────────

    /**
     * Get permissions array for a table.
     * Keys: 'A' (add), 'D' (delete), 'E' (edit), 'S' (search/list),
     *       'P' (print/export), 'I' (import), 'M' (admin).
     */
    public static function GetPermissions(string $table): array
    {
        return $_SESSION[self::SESSION_PERMISSIONS][$table] ?? [
            'A' => false,
            'D' => false,
            'E' => false,
            'S' => false,
            'P' => false,
            'I' => false,
            'M' => false,
        ];
    }

    /**
     * Set permissions for a table for the current user.
     * $rights can be an associative array or a string like "ADESPM".
     */
    public static function SetPermissions(string $table, array|string $rights): void
    {
        if (is_string($rights)) {
            $arr = [];
            foreach (str_split('ADESPIM') as $letter) {
                $arr[$letter] = str_contains($rights, $letter);
            }
            $rights = $arr;
        }
        $_SESSION[self::SESSION_PERMISSIONS][$table] = $rights;
    }

    /**
     * Check if the current user has a specific permission on a table.
     *
     * @param string $table  Table name.
     * @param string $action 'A', 'D', 'E', 'S', 'P', 'I', 'M'.
     */
    public static function HasPermission(string $table, string $action): bool
    {
        $perms = self::GetPermissions($table);
        return $perms[$action] ?? false;
    }

    // ── Allowed pages (fine‑grained page access) ────────────────────────

    /**
     * Get the list of allowed pages for a table and page type.
     *
     * @param string $table    Table name (or GLOBAL_PAGES constant for common pages).
     * @param string $pageType One of: list, add, edit, view, print, search, etc.
     * @return array Array of page names the user can access.
     */
    public static function GetAllowedPages(string $table, string $pageType): array
    {
        return $_SESSION[self::SESSION_ALLOWED_PAGES][$table][$pageType] ?? [];
    }

    /**
     * Set which pages the current user can access for a table and page type.
     *
     * @param string        $table
     * @param string        $pageType
     * @param string|array  $pages   A page name or array of page names.
     */
    public static function SetAllowedPages(string $table, string $pageType, string|array $pages): void
    {
        if (!is_array($pages)) {
            $pages = [$pages];
        }
        $_SESSION[self::SESSION_ALLOWED_PAGES][$table][$pageType] = $pages;
    }

    // ── OwnerID (row‑level security) ────────────────────────────────────

    /**
     * Get the OwnerID for a specific table.
     */
    public static function GetOwnerId(string $table): string|int|null
    {
        return $_SESSION[self::SESSION_OWNER_IDS][$table] ?? null;
    }

    /**
     * Set the OwnerID for a specific table (used for "own data only" security).
     *
     * @param string $table
     * @param string|int $ownerId
     */
    public static function SetOwnerId(string $table, string|int $ownerId): void
    {
        $_SESSION[self::SESSION_OWNER_IDS][$table] = $ownerId;
    }

    // ── Two‑factor authentication (TOTP) ───────────────────────────────

    /**
     * Return an array of two‑factor settings (email field, phone field, etc.).
     * Reads from central settings table or a config file.
     */
    public static function TwoFactorSettings(): array
    {
        // Stub – can be extended to read from DB
        return [
            'enabled'        => false,
            'method'         => 'email', // email | sms | totp
            'email_field'    => 'email',
            'phone_field'    => 'phone',
            'totp_secret_field' => 'totp_secret',
        ];
    }

    // ── Password utilities ──────────────────────────────────────────────

    /**
     * Hash a password using bcrypt (PASSWORD_DEFAULT).
     */
    public static function HashPassword(string $password): string
    {
        return password_hash($password, PASSWORD_DEFAULT);
    }

    /**
     * Verify a plain‑text password against a hash.
     */
    public static function VerifyPassword(string $password, string $hash): bool
    {
        return password_verify($password, $hash);
    }

    /**
     * Check if a password hash needs rehashing (e.g. after upgrading work factor).
     */
    public static function PasswordNeedsRehash(string $hash): bool
    {
        return password_needs_rehash($hash, PASSWORD_DEFAULT);
    }

    // ── Secure token generation ─────────────────────────────────────────

    /**
     * Generate a cryptographically secure random token (hex).
     *
     * @param int $bytes Number of random bytes (default 32 → 64 hex chars).
     */
    public static function GenerateToken(int $bytes = 32): string
    {
        return bin2hex(random_bytes($bytes));
    }

    // ── Remember Me ─────────────────────────────────────────────────────

    /**
     * Set a persistent "Remember Me" cookie that survives browser closes.
     * Stores a hashed token in the database and a plain token in the cookie.
     */
    public static function SetRememberMeCookie(int $userId): void
    {
        $token = self::GenerateToken();
        $hashedToken = hash('sha256', $token);

        DB::Init();
        DB::Insert('remember_tokens', [
            'user_id'  => $userId,
            'token'    => $hashedToken,
            'expires_at' => date('Y-m-d H:i:s', strtotime('+30 days')),
            'created_at' => date('Y-m-d H:i:s'),
        ]);

        setcookie('remember_token', $userId . ':' . $token, [
            'expires'  => time() + 30 * 86400,
            'path'     => '/',
            'domain'   => '',
            'secure'   => true,
            'httponly' => true,
            'samesite' => 'Lax',
        ]);
    }

    /**
     * Attempt to log in from a Remember Me cookie.
     * Returns true if successful and sets the session.
     */
    public static function LoginFromRememberMe(): bool
    {
        if (self::IsLoggedIn() || !isset($_COOKIE['remember_token'])) {
            return false;
        }

        $parts = explode(':', $_COOKIE['remember_token'], 2);
        if (count($parts) !== 2) {
            return false;
        }
        [$userId, $token] = $parts;
        $hashedToken = hash('sha256', $token);

        DB::Init();
        $rows = DB::Select('remember_tokens', [
            'user_id' => (int)$userId,
            'token'   => $hashedToken,
        ]);
        if (empty($rows)) {
            return false;
        }

        $row = $rows[0];
        if (strtotime($row['expires_at']) < time()) {
            DB::Delete('remember_tokens', ['id' => $row['id']]);
            return false;
        }

        // Log the user in
        $userRows = DB::FetchAll(
            'SELECT ' . \PpPhp\Core\Auth\UserSchema::aliasedColumns() . ' FROM '
            . \PpPhp\Core\Auth\UserSchema::qTable()
            . ' WHERE ' . \PpPhp\Core\Auth\UserSchema::qCol('id') . ' = ?',
            [(int) $userId]
        );
        if (empty($userRows)) {
            return false;
        }
        $user = $userRows[0];
        $_SESSION[self::SESSION_USER] = [
            'id'       => $user['id'],
            'username' => $user['username'],
            'email'    => $user['email'],
            'role'     => $user['role'],
            'fullname' => $user['fullname'] ?? $user['username'],
        ];
        return true;
    }

    // ── CSRF Protection ─────────────────────────────────────────────────
    // (Nested logical grouping; call Security::CSRF::Token() etc.)

    public static function CSRF(): CSRFHelper
    {
        return new CSRFHelper();
    }

    /**
     * Enforce CSRF on the current (state-changing) request in one call.
     *
     * Reads the token from the `_token`/`csrf_token` field OR the
     * `X-CSRF-Token` header (both attached automatically by the shared
     * theme-head injector). On failure it emits a 403 and EXITS — JSON for a
     * fetch/XHR/AJAX request, a tiny HTML page for a classic form navigation —
     * so a controller guards a handler with a single line:
     *
     *     Security::RequireCsrf();
     *
     * Standard 403 (not the non-standard 419, which some SAPIs coerce to 500).
     */
    public static function RequireCsrf(): void
    {
        if (self::CSRF()->CheckRequest()) {
            return;
        }
        http_response_code(403);
        $msg = 'Your session security token expired. Please refresh the page and try again.';

        $accept = (string) ($_SERVER['HTTP_ACCEPT'] ?? '');
        $isAjax = isset($_SERVER['HTTP_X_CSRF_TOKEN'], $_SERVER['HTTP_X_CSRF'])
            || strtolower((string) ($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '')) === 'xmlhttprequest'
            || stripos($accept, 'application/json') !== false
            || isset($_SERVER['HTTP_X_CSRF_TOKEN'])
            || isset($_SERVER['HTTP_X_CSRF']);

        if ($isAjax) {
            header('Content-Type: application/json');
            echo json_encode(['success' => false, 'message' => $msg]);
        } else {
            header('Content-Type: text/html; charset=utf-8');
            echo '<!doctype html><meta charset="utf-8"><title>Session expired</title>'
                . '<body style="font:15px/1.6 system-ui,sans-serif;max-width:32rem;margin:18vh auto;padding:0 1.2rem;text-align:center">'
                . '<h1 style="font-size:1.3rem">Session expired</h1><p>' . htmlspecialchars($msg) . '</p>'
                . '<p><a href="javascript:history.back()" style="color:#4f46e5">Go back</a></p></body>';
        }
        exit;
    }

    // ── Rate Limiting ───────────────────────────────────────────────────
    public static function RateLimit(): RateLimitHelper
    {
        return new RateLimitHelper();
    }

    // ── TOTP ────────────────────────────────────────────────────────────
    public static function TOTP(): TOTPHelper
    {
        return new TOTPHelper();
    }
}

// ────────────────────────────────────────────────────────────────────────
//  Helper classes (in same file or separate files)
// ────────────────────────────────────────────────────────────────────────

/**
 * CSRF token generation and validation.
 *
 * Usage:
 *   $token = Security::CSRF()->Token();
 *   <input type="hidden" name="_token" value="<?= $token ?>">
 *   if (!Security::CSRF()->Check($_POST['_token'])) { die('Invalid token'); }
 */
class CSRFHelper
{
    private const SESSION_KEY = 'csrf_token';
    private const TOKEN_LENGTH = 32;

    /** Generate (or return existing) CSRF token. */
    public function Token(): string
    {
        if (empty($_SESSION[self::SESSION_KEY])) {
            $_SESSION[self::SESSION_KEY] = bin2hex(random_bytes(self::TOKEN_LENGTH));
        }
        return $_SESSION[self::SESSION_KEY];
    }

    /** Validate a submitted token. */
    public function Check(string $token): bool
    {
        if (empty($_SESSION[self::SESSION_KEY])) {
            return false;
        }
        return hash_equals($_SESSION[self::SESSION_KEY], $token);
    }

    /**
     * Validate the CSRF token for the CURRENT request, taking it from the
     * conventional form field (`_token` / `csrf_token`) or, for AJAX/fetch,
     * an `X-CSRF-Token` request header. The console's shared head injector
     * attaches that header to every same-origin unsafe request automatically,
     * so controllers can guard a handler with a single call regardless of
     * whether it was reached by a form post or a fetch.
     */
    public function CheckRequest(): bool
    {
        $token = (string) ($_POST['_token'] ?? $_POST['csrf_token'] ?? '');
        if ($token === '') {
            $token = (string) ($_SERVER['HTTP_X_CSRF_TOKEN'] ?? $_SERVER['HTTP_X_CSRF'] ?? '');
        }
        return $token !== '' && $this->Check($token);
    }

    /** Regenerate the token (e.g. after login). */
    public function Regenerate(): string
    {
        $_SESSION[self::SESSION_KEY] = bin2hex(random_bytes(self::TOKEN_LENGTH));
        return $_SESSION[self::SESSION_KEY];
    }

    /** Return a hidden input field HTML snippet. */
    public function HiddenField(): string
    {
        return '<input type="hidden" name="_token" value="' . htmlspecialchars($this->Token()) . '">';
    }
}

/**
 * Simple rate limiter using session or file‑based storage.
 *
 * Usage:
 *   $limit = Security::RateLimit();
 *   if (!$limit->Check('login:' . $_SERVER['REMOTE_ADDR'], 5, 60)) {
 *       die('Too many attempts. Try again later.');
 *   }
 */
class RateLimitHelper
{
    private const SESSION_PREFIX = 'ratelimit_';
    private const STORAGE_DIR = 'storage/ratelimit/';

    /**
     * Check if the given key is within the allowed number of attempts.
     *
     * @param string $key          Unique identifier (e.g. IP + action).
     * @param int    $maxAttempts  Maximum attempts allowed.
     * @param int    $decaySeconds Time window in seconds.
     * @return bool True if allowed.
     */
    public function Check(string $key, int $maxAttempts, int $decaySeconds): bool
    {
        $attempts = $this->getAttempts($key, $decaySeconds);
        return count($attempts) < $maxAttempts;
    }

    /**
     * Record an attempt for the given key.
     */
    public function Hit(string $key): void
    {
        $attempts = $this->getAttempts($key, 86400);
        $attempts[] = time();
        $_SESSION[self::SESSION_PREFIX . $key] = $attempts;
    }

    /**
     * Return the number of remaining attempts.
     */
    public function Remaining(string $key, int $maxAttempts, int $decaySeconds): int
    {
        $count = count($this->getAttempts($key, $decaySeconds));
        return max(0, $maxAttempts - $count);
    }

    private function getAttempts(string $key, int $decaySeconds): array
    {
        $all = $_SESSION[self::SESSION_PREFIX . $key] ?? [];
        $cutoff = time() - $decaySeconds;
        $valid = array_filter($all, fn($ts) => $ts >= $cutoff);
        $_SESSION[self::SESSION_PREFIX . $key] = $valid;
        return $valid;
    }
}

/**
 * TOTP (Time‑based One‑Time Password) – RFC 6238.
 *
 * Usage:
 *   $secret = Security::TOTP()->GenerateSecret();
 *   $valid  = Security::TOTP()->Verify($secret, $code);
 */
class TOTPHelper
{
    private const DIGITS   = 6;
    private const PERIOD   = 30;
    private const ALGORITHM = 'sha1';

    /** Generate a random 16‑character base32 secret. */
    public function GenerateSecret(int $length = 16): string
    {
        $chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
        $secret = '';
        for ($i = 0; $i < $length; $i++) {
            $secret .= $chars[random_int(0, strlen($chars) - 1)];
        }
        return $secret;
    }

    /**
     * Verify a TOTP code against a secret.
     *
     * @param string $secret Base32‑encoded secret.
     * @param string $code   6‑digit code from user.
     * @param int    $drift  Number of time steps to allow (default 1 → ±30s).
     */
    public function Verify(string $secret, string $code, int $drift = 1): bool
    {
        for ($i = -$drift; $i <= $drift; $i++) {
            if ($this->generateCode($secret, time() + ($i * self::PERIOD)) === $code) {
                return true;
            }
        }
        return false;
    }

    /**
     * Generate the otpauth:// URL for QR code generation.
     */
    public function GetQRCodeUrl(string $username, string $secret, string $issuer = 'PPPHP'): string
    {
        // rawurlencode (not urlencode) so spaces become %20, never '+', which some
        // authenticator apps read literally in the label. Algorithm is upper-cased
        // per the otpauth spec (SHA1/SHA256/SHA512).
        $encodedIssuer = rawurlencode($issuer);
        $encodedUser   = rawurlencode($username);
        return "otpauth://totp/{$encodedIssuer}:{$encodedUser}?secret={$secret}&issuer={$encodedIssuer}&algorithm=" . strtoupper(self::ALGORITHM) . "&digits=" . self::DIGITS . "&period=" . self::PERIOD;
    }

    private function generateCode(string $secret, int $timeSlice): string
    {
        $secret = $this->base32Decode($secret);
        // RFC 4226: the moving factor is an 8-byte big-endian counter. pack('N', …)
        // only emits 4 bytes, so pad the high word with zeros (counters stay < 2^32
        // for any realistic date) to get the full 8-byte message real apps use.
        $time = "\x00\x00\x00\x00" . pack('N', intdiv($timeSlice, self::PERIOD));
        $hash = hash_hmac(self::ALGORITHM, $time, $secret, true);
        $offset = ord($hash[strlen($hash) - 1]) & 0x0F;
        $value = (ord($hash[$offset]) & 0x7F) << 24
            | (ord($hash[$offset + 1]) & 0xFF) << 16
            | (ord($hash[$offset + 2]) & 0xFF) << 8
            | (ord($hash[$offset + 3]) & 0xFF);
        return str_pad((string)($value % (10 ** self::DIGITS)), self::DIGITS, '0', STR_PAD_LEFT);
    }

    private function base32Decode(string $base32): string
    {
        $chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
        $base32 = strtoupper($base32);
        $bits = '';
        foreach (str_split($base32) as $char) {
            $pos = strpos($chars, $char);
            if ($pos === false) continue;
            $bits .= str_pad(decbin($pos), 5, '0', STR_PAD_LEFT);
        }
        $bytes = '';
        foreach (str_split($bits, 8) as $chunk) {
            if (strlen($chunk) < 8) break;
            $bytes .= chr(bindec($chunk));
        }
        return $bytes;
    }
}
