<?php

declare(strict_types=1);

namespace App\Auth;

/**
 * Wraps PHP native sessions for the UI's BFF role.
 *
 * SPEC §M08.2: file-backed inside the container, `HttpOnly`, `SameSite=Lax`,
 * `Secure` when `APP_ENV=production`. 8h idle timeout / 24h absolute max.
 *
 * The class never exits a closed/inactive session — `startSession()` is
 * idempotent and starts the session lazily so middlewares can call it
 * without worrying about double-start. Touch (`refreshActivity`) updates
 * the idle marker on each authenticated request.
 *
 * `regenerateId()` MUST be called after any auth-state change (login
 * success, logout) to defeat session fixation.
 *
 * SEC_REVIEW F8: `regenerateId()` and `clear()` previously short-circuited
 * silently when `headers_sent()` was true. That left the pre-auth cookie
 * intact post-login — classic session fixation. Both methods now
 * fail-closed under HTTP (throw `\RuntimeException`, surfaced by Slim as
 * a 500 so the operator notices the upstream output bug) and use a manual
 * id-rotation path under CLI/test (`$cliFallback === true`) where
 * `headers_sent()` is structurally always true and there's no real cookie
 * to send.
 */
class SessionManager
{
    /**
     * Base name without prefix. The actual cookie name set on the
     * response is `cookieName()`, which adds the `__Host-` prefix
     * (SEC_REVIEW F57) when the cookie is `Secure` so the browser
     * enforces:
     *   - `Secure` (HTTPS-only),
     *   - `Path=/` exactly,
     *   - no `Domain` attribute (host-only, no subdomain cookie
     *     shadowing).
     * The constant remains for callers that want the unprefixed
     * brand name (e.g. log messages, doc strings).
     */
    public const COOKIE_NAME = 'irdb_session';

    private const KEY_USER = '_user';
    private const KEY_LAST_ACTIVE = '_last_active';
    private const KEY_AUTH_AT = '_authenticated_at';
    private const KEY_REVALIDATED_AT = '_revalidated_at';
    private const KEY_FLASH = '_flash';
    private const KEY_NEXT = '_next';
    private const KEY_OIDC = '_oidc';
    /** Mirrors `App\Http\CsrfMiddleware::SESSION_KEY` — kept duplicated to
     * avoid a domain → http-layer dependency on what is otherwise a
     * value-only collaboration. */
    private const KEY_CSRF = '_csrf';

    private readonly bool $cliFallback;

    /** @var \Closure(): bool */
    private \Closure $headersSentFn;

    public function __construct(
        private readonly bool $secureCookie = true,
        private readonly int $idleSeconds = 28800,
        private readonly int $absoluteSeconds = 86400,
        ?bool $cliFallback = null,
        ?\Closure $headersSentFn = null,
    ) {
        $this->cliFallback = $cliFallback ?? (PHP_SAPI === 'cli');
        $this->headersSentFn = $headersSentFn ?? static fn (): bool => headers_sent();
    }

    public function startSession(): void
    {
        if (session_status() === PHP_SESSION_ACTIVE) {
            return;
        }
        if (($this->headersSentFn)()) {
            // Tests run without HTTP — fall back to a manual session id so
            // SessionManager remains usable in CLI tests.
            if (session_id() === '') {
                session_id(bin2hex(random_bytes(16)));
            }
            session_start();

            return;
        }
        session_set_cookie_params([
            'lifetime' => 0,
            'path' => '/',
            'domain' => '',
            'secure' => $this->secureCookie,
            'httponly' => true,
            'samesite' => 'Lax',
        ]);
        session_name($this->cookieName());
        session_start();
        $this->enforceLifetimes();
    }

    /**
     * SEC_REVIEW F57: prefix the session cookie with `__Host-` when
     * we're issuing it `Secure` (production / HTTPS). The prefix is
     * a browser-enforced contract that REJECTS the cookie unless:
     *   - it has `Secure`,
     *   - `Path=/` exactly,
     *   - no `Domain` attribute (so a subdomain can't shadow the
     *     parent-domain session).
     * `__Host-` cookies are also rejected over HTTP, so dev mode
     * (`secureCookie=false`, `APP_ENV=development`) keeps the
     * unprefixed name.
     */
    public function cookieName(): string
    {
        return $this->secureCookie ? '__Host-' . self::COOKIE_NAME : self::COOKIE_NAME;
    }

    public function regenerateId(): void
    {
        if (session_status() !== PHP_SESSION_ACTIVE) {
            return;
        }
        if (!($this->headersSentFn)()) {
            session_regenerate_id(true);
            $this->rotateCsrfToken();

            return;
        }
        if (!$this->cliFallback) {
            throw new \RuntimeException(
                'cannot regenerate session id: response headers already sent',
            );
        }
        $this->rotateIdUnderCli();
        $this->rotateCsrfToken();
    }

    /**
     * SEC_REVIEW F40: drop the cached CSRF token whenever the session id
     * rotates so a token leaked over the pre-auth privilege boundary
     * (e.g. via Referer on a public route, a sub-resource integrity
     * check, or shared with a less-trusted page) cannot be replayed
     * post-auth. `CsrfMiddleware` lazily mints a fresh token on the
     * next request when the slot is missing, so the redirect that
     * always follows `regenerateId()` (303 to /app/dashboard, /no-access,
     * /login, etc.) re-issues a clean token on its first protected
     * GET. `clear()` already wipes `$_SESSION` outright on logout, so
     * the rotate-on-id-rotate hook covers the login direction.
     */
    private function rotateCsrfToken(): void
    {
        unset($_SESSION[self::KEY_CSRF]);
    }

    public function setUser(UserContext $user): void
    {
        $now = time();
        $_SESSION[self::KEY_USER] = $user->toArray();
        $_SESSION[self::KEY_LAST_ACTIVE] = $now;
        $_SESSION[self::KEY_AUTH_AT] = $now;
        // SEC_REVIEW F36: seed the revalidation clock on login. The
        // AuthRequired middleware re-checks the user's role against the api
        // every `revalidateAfterSeconds` so role changes / disablement /
        // deletion in Entra or in the api propagate to live UI sessions
        // without waiting for the 8h idle timeout.
        $_SESSION[self::KEY_REVALIDATED_AT] = $now;
    }

    /**
     * Update the cached user record without resetting the auth clocks.
     * Called after a successful background revalidation when the api
     * reports a different role / display name / email than the session
     * had cached (SEC_REVIEW F36). Preserves `_authenticated_at` and
     * `_last_active` so idle/absolute timeouts keep running off the
     * original login.
     */
    public function updateUser(UserContext $user): void
    {
        $_SESSION[self::KEY_USER] = $user->toArray();
    }

    public function getUser(): ?UserContext
    {
        $row = $_SESSION[self::KEY_USER] ?? null;
        if (!is_array($row)) {
            return null;
        }

        return UserContext::fromArray($row);
    }

    /**
     * Last time the cached user record was revalidated against the api.
     * Returns `null` for legacy/pre-feature sessions that predate F36 —
     * the middleware bootstraps the timestamp on first sight.
     */
    public function lastRevalidatedAt(): ?int
    {
        $value = $_SESSION[self::KEY_REVALIDATED_AT] ?? null;

        return is_int($value) ? $value : null;
    }

    public function markRevalidated(?int $now = null): void
    {
        $_SESSION[self::KEY_REVALIDATED_AT] = $now ?? time();
    }

    public function refreshActivity(): void
    {
        if (isset($_SESSION[self::KEY_USER])) {
            $_SESSION[self::KEY_LAST_ACTIVE] = time();
        }
    }

    public function clear(): void
    {
        $_SESSION = [];
        if (session_status() !== PHP_SESSION_ACTIVE) {
            return;
        }
        if (!($this->headersSentFn)()) {
            session_regenerate_id(true);

            return;
        }
        if (!$this->cliFallback) {
            throw new \RuntimeException(
                'cannot regenerate session id on clear(): response headers already sent',
            );
        }
        $this->rotateIdUnderCli();
    }

    public function flash(string $type, string $message): void
    {
        $current = $_SESSION[self::KEY_FLASH] ?? [];
        if (!is_array($current)) {
            $current = [];
        }
        $current[] = ['type' => $type, 'message' => $message];
        $_SESSION[self::KEY_FLASH] = $current;
    }

    /**
     * @return list<array{type: string, message: string}>
     */
    public function consumeFlash(): array
    {
        $current = $_SESSION[self::KEY_FLASH] ?? [];
        unset($_SESSION[self::KEY_FLASH]);
        if (!is_array($current)) {
            return [];
        }
        $out = [];
        foreach ($current as $entry) {
            if (is_array($entry) && isset($entry['type'], $entry['message'])) {
                $out[] = ['type' => (string) $entry['type'], 'message' => (string) $entry['message']];
            }
        }

        return $out;
    }

    /**
     * Stash a post-action redirect target. The value is dropped on the
     * floor (no error) if it is not a safe local path, so callers can
     * pass attacker-influenced input directly without an open-redirect
     * sink (SEC_REVIEW F10).
     */
    public function setNext(string $url): void
    {
        if (!self::isSafeRedirectPath($url)) {
            return;
        }
        $_SESSION[self::KEY_NEXT] = $url;
    }

    public function consumeNext(): ?string
    {
        $next = $_SESSION[self::KEY_NEXT] ?? null;
        unset($_SESSION[self::KEY_NEXT]);
        if (!is_string($next) || !self::isSafeRedirectPath($next)) {
            return null;
        }

        return $next;
    }

    /**
     * True iff `$url` is a same-origin path (HTTP `Location` value with
     * no host/scheme). Rejects:
     *   - empty,
     *   - anything not starting with `/`,
     *   - protocol-relative URLs (`//evil.example.com/phish`),
     *   - back-slash-after-slash (`/\evil` — Windows-style normalisation
     *     some clients accept as a host),
     *   - any control character (CR/LF for header injection, NUL).
     *
     * Used by `setNext()`/`consumeNext()` and by the form-`next`
     * delete redirects in admin controllers (SEC_REVIEW F10).
     */
    public static function isSafeRedirectPath(string $url): bool
    {
        if ($url === '' || $url[0] !== '/') {
            return false;
        }
        if (strlen($url) >= 2 && ($url[1] === '/' || $url[1] === '\\')) {
            return false;
        }

        return preg_match('/[\x00-\x1f\x7f]/', $url) !== 1;
    }

    /**
     * Convenience for callers that read a `next` form field: returns the
     * candidate iff it is a safe local path, else returns `$default`.
     * `$default` itself is NOT validated — callers MUST pass a known-safe
     * literal (typically a hard-coded `/app/...` route).
     */
    public static function safeNextOrDefault(mixed $candidate, string $default): string
    {
        if (is_string($candidate) && self::isSafeRedirectPath($candidate)) {
            return $candidate;
        }

        return $default;
    }

    /**
     * Stash OIDC state/nonce/code-verifier between `/login/oidc` and
     * `/oidc/callback` requests. The keys are scoped under one session
     * slot so we can wipe them in one call.
     *
     * @param array<string, string> $state
     */
    public function setOidcState(array $state): void
    {
        $_SESSION[self::KEY_OIDC] = $state;
    }

    /**
     * @return array<string, string>
     */
    public function consumeOidcState(): array
    {
        $state = $_SESSION[self::KEY_OIDC] ?? [];
        unset($_SESSION[self::KEY_OIDC]);
        if (!is_array($state)) {
            return [];
        }
        $out = [];
        foreach ($state as $k => $v) {
            if (is_string($k) && is_string($v)) {
                $out[$k] = $v;
            }
        }

        return $out;
    }

    /**
     * CLI/test rotation: PHP can't send Set-Cookie because there is no
     * real HTTP response, but the in-process session id is still rotated
     * so test assertions about "session was renewed" hold. The current
     * `$_SESSION` contents are preserved so flash/auth state survives
     * the rotation (matching `session_regenerate_id(true)` semantics).
     */
    private function rotateIdUnderCli(): void
    {
        $data = $_SESSION;
        @session_write_close();
        session_id(bin2hex(random_bytes(16)));
        @session_start();
        $_SESSION = $data;
    }

    /**
     * Drop the session if it's been idle past `idleSeconds` or older than
     * `absoluteSeconds` since auth. SPEC §M08.2.
     */
    private function enforceLifetimes(): void
    {
        if (!isset($_SESSION[self::KEY_USER])) {
            return;
        }
        $now = time();
        $lastActive = (int) ($_SESSION[self::KEY_LAST_ACTIVE] ?? 0);
        $authAt = (int) ($_SESSION[self::KEY_AUTH_AT] ?? 0);

        if ($lastActive !== 0 && ($now - $lastActive) > $this->idleSeconds) {
            $this->clear();

            return;
        }
        if ($authAt !== 0 && ($now - $authAt) > $this->absoluteSeconds) {
            $this->clear();
        }
    }
}
