<?php
/*
 * Copyright 2026 Alessandro Chiapparini <sprint_planer_web@chiapparini.org>
 * SPDX-License-Identifier: Apache-2.0
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * See the LICENSE file in the project root for the full license text.
 */

declare(strict_types=1);

namespace App\Auth;

use App\Domain\User;
use App\Http\Request;
use App\Http\Response;
use App\Http\TrustedProxies;
use App\Repositories\UserRepository;

/**
 * Session lifecycle + CSRF helpers.
 *
 * Session data:
 *   $_SESSION['user_id']     — int id, set after successful login
 *   $_SESSION['login_at']    — unix timestamp of login
 *   $_SESSION['last_active'] — unix timestamp of last request (R01-N08)
 *   $_SESSION['csrf_token']  — hex token, lazy-created; rotated on login
 *
 * Intentionally stateless utility methods; the caller hydrates the full User
 * from the repo on demand.
 */
final class SessionGuard
{
    public const COOKIE_NAME = 'sp_session';

    /**
     * R01-N08: maximum allowed gap (seconds) between two requests on a
     * signed-in session before the session is expired and the user has to
     * re-authenticate. 30 minutes — much shorter than the historical 8h
     * `gc_maxlifetime` cookie lifetime, which only governed garbage
     * collection and not session validity.
     */
    public const IDLE_TIMEOUT_SECONDS = 1800;

    public static function start(): void
    {
        if (session_status() === PHP_SESSION_ACTIVE) {
            return;
        }

        $savePath = getenv('SESSION_PATH');
        if (is_string($savePath) && $savePath !== '') {
            if (!is_dir($savePath)) {
                @mkdir($savePath, 0o700, true);
            }
            session_save_path($savePath);
        }

        // R01-N05: mark the cookie Secure when *either* the configured base
        // URL is HTTPS *or* the live request is effectively HTTPS (which
        // can include `X-Forwarded-Proto: https` from a trusted proxy —
        // see TrustedProxies). This way an operator who forgets to set
        // APP_BASE_URL still gets Secure cookies as soon as TLS is in
        // play, and the cookie isn't dropped to plain text just because
        // a single internal probe arrived over HTTP.
        $baseUrl = (string) (getenv('APP_BASE_URL') ?: '');
        $secure  = str_starts_with($baseUrl, 'https://')
            || TrustedProxies::fromEnv()->isHttps($_SERVER);

        ini_set('session.use_strict_mode', '1');
        ini_set('session.use_only_cookies', '1');
        ini_set('session.cookie_httponly', '1');
        ini_set('session.cookie_samesite', 'Lax');
        ini_set('session.cookie_secure', $secure ? '1' : '0');
        ini_set('session.gc_maxlifetime', '28800'); // 8h

        session_name(self::COOKIE_NAME);
        session_set_cookie_params([
            'lifetime' => 0,
            'path'     => '/',
            'httponly' => true,
            'samesite' => 'Lax',
            'secure'   => $secure,
        ]);
        session_start();

        // R01-N08: enforce idle timeout on signed-in sessions. Anonymous
        // sessions (e.g., the OIDC state/nonce stored before the bounce to
        // Entra) are left untouched so an in-flight login flow survives.
        if (self::expireIdleSession($_SESSION, time())) {
            @session_regenerate_id(true);
        }
    }

    public static function login(User $user): void
    {
        self::start();
        // Fresh id on privilege change, but preserve any pre-login state
        // (the OIDC client stores its nonce/state in the session).
        session_regenerate_id(true);
        $_SESSION['user_id']     = $user->id;
        $_SESSION['login_at']    = time();
        $_SESSION['last_active'] = time();
        // R01-N08: rotate the CSRF token on privilege change so a token
        // captured by a pre-login attacker (e.g. from the public homepage
        // form) cannot be replayed against the now-authenticated session.
        unset($_SESSION['csrf_token']);
    }

    public static function logout(): void
    {
        if (session_status() !== PHP_SESSION_ACTIVE) {
            self::start();
        }
        $_SESSION = [];
        if (ini_get('session.use_cookies')) {
            $p = session_get_cookie_params();
            setcookie(
                session_name(),
                '',
                [
                    'expires'  => time() - 42000,
                    'path'     => $p['path'],
                    'domain'   => $p['domain'],
                    'secure'   => $p['secure'],
                    'httponly' => $p['httponly'],
                    'samesite' => $p['samesite'] ?? 'Lax',
                ]
            );
        }
        session_destroy();
    }

    public static function currentUserId(): ?int
    {
        self::start();
        $id = $_SESSION['user_id'] ?? null;
        return is_int($id) ? $id : null;
    }

    public static function currentUser(UserRepository $users): ?User
    {
        $id = self::currentUserId();
        return $id === null ? null : $users->find($id);
    }

    public static function csrfToken(): string
    {
        self::start();
        if (empty($_SESSION['csrf_token']) || !is_string($_SESSION['csrf_token'])) {
            $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
        }
        return (string) $_SESSION['csrf_token'];
    }

    /**
     * R01-N08: pure-static idle-window check. Public + parameterised so the
     * boundary (1800s) and the `>=` semantics can be tested without spinning
     * up a real PHP session.
     */
    public static function isIdleExpired(int $lastActive, int $now): bool
    {
        return ($now - $lastActive) >= self::IDLE_TIMEOUT_SECONDS;
    }

    /**
     * R01-N08: apply the idle policy to a session array.
     *
     * Returns true when the caller should rotate the session id (the
     * authenticated state was just dropped). Pre-login sessions — those
     * with no integer `user_id` — are left untouched, so an in-flight
     * OIDC bounce is not killed by the idle clock running on stale
     * `last_active` data from a previous logged-in session that has
     * since signed out.
     *
     * On a still-fresh signed-in session the caller's `last_active`
     * timestamp is bumped to `$now` so the idle window slides forward.
     *
     * @param array<string, mixed> $session  Mutated in-place ($_SESSION).
     */
    public static function expireIdleSession(array &$session, int $now): bool
    {
        $userId = $session['user_id'] ?? null;
        if (!is_int($userId)) {
            return false;
        }

        $lastActive = $session['last_active'] ?? null;
        if (is_int($lastActive) && self::isIdleExpired($lastActive, $now)) {
            // Drop only the authenticated keys; leave foreign state
            // (e.g. the OIDC library's nonce/state/PKCE) intact so a
            // re-login flow is not crippled by the timeout.
            unset(
                $session['user_id'],
                $session['login_at'],
                $session['last_active'],
                $session['csrf_token'],
            );
            return true;
        }

        $session['last_active'] = $now;
        return false;
    }

    /** Returns true when the request is GET/HEAD (not guarded) or the token matches. */
    public static function verifyCsrf(Request $req): bool
    {
        if (in_array($req->method, ['GET', 'HEAD', 'OPTIONS'], true)) {
            return true;
        }
        $token = $req->header('x-csrf-token');
        if ($token === null || $token === '') {
            $token = isset($req->post['_csrf']) ? (string) $req->post['_csrf'] : '';
        }
        if ($token === '') {
            return false;
        }
        return hash_equals(self::csrfToken(), $token);
    }

    /**
     * Return the signed-in User, or a redirect Response to /auth/login if not
     * authenticated. Controllers use:
     *
     *     $user = SessionGuard::requireAuth($this->users);
     *     if ($user instanceof Response) return $user;
     */
    public static function requireAuth(UserRepository $users): User|Response
    {
        $user = self::currentUser($users);
        return $user ?? Response::redirect('/auth/login');
    }

    /**
     * Same as requireAuth but also requires is_admin. Returns 403 when signed
     * in as a non-admin; a redirect to /auth/login when anonymous.
     */
    public static function requireAdmin(UserRepository $users): User|Response
    {
        $user = self::currentUser($users);
        if ($user === null) {
            return Response::redirect('/auth/login');
        }
        if (!$user->isAdmin) {
            return Response::text('Forbidden — admin access required', 403);
        }
        return $user;
    }

    /**
     * JSON-flavoured admin gate: auth + admin + CSRF. Returns the signed-in
     * User on success, or a JSON error envelope response per spec §7.
     */
    public static function requireAdminJson(Request $req, UserRepository $users): User|Response
    {
        $user = self::currentUser($users);
        if ($user === null) {
            return Response::err('unauthenticated', 'Sign in required', 401);
        }
        if (!$user->isAdmin) {
            return Response::err('forbidden', 'Admin access required', 403);
        }
        if (!self::verifyCsrf($req)) {
            return Response::err('csrf', 'CSRF token invalid', 403);
        }
        return $user;
    }

    /**
     * Form-flavoured admin gate: auth + admin + CSRF. Mirrors
     * `requireAdminJson` but speaks the form-controller dialect — anonymous
     * users get a redirect to /auth/login, non-admins get a 403 text
     * response, and a CSRF mismatch gets a 403 text response. Use this
     * inside form-handling controller actions to replace the recurring
     * three-block boilerplate (R02-N03).
     */
    public static function requireAdminForm(Request $req, UserRepository $users): User|Response
    {
        $user = self::requireAdmin($users);
        if ($user instanceof Response) {
            return $user;
        }
        if (!self::verifyCsrf($req)) {
            return Response::text('CSRF token invalid', 403);
        }
        return $user;
    }

    /**
     * JSON gate for endpoints any signed-in user may hit (Phase 18:
     * task-status writes are the first such surface). Auth + CSRF, no admin
     * requirement.
     */
    public static function requireAuthJson(Request $req, UserRepository $users): User|Response
    {
        $user = self::currentUser($users);
        if ($user === null) {
            return Response::err('unauthenticated', 'Sign in required', 401);
        }
        if (!self::verifyCsrf($req)) {
            return Response::err('csrf', 'CSRF token invalid', 403);
        }
        return $user;
    }
}
