<?php
declare(strict_types=1);

namespace App\Security;

final class CsrfService
{
    public const VALID = 'valid';
    public const MISSING_CANDIDATE = 'missing_candidate';
    public const MISSING_SESSION_TOKEN = 'missing_session_token';
    public const MISMATCH = 'mismatch';

    public function __construct(
        private readonly string $sessionKey = '_csrf_token',
    ) {
    }

    public function token(): string
    {
        $this->ensureSession();

        $token = $_SESSION[$this->sessionKey] ?? null;

        if (!is_string($token) || $token === '') {
            $token = bin2hex(random_bytes(32));
            $_SESSION[$this->sessionKey] = $token;
        }

        return $token;
    }

    public function validate(string $candidate): bool
    {
        return $this->validationStatus($candidate) === self::VALID;
    }

    public function validationStatus(string $candidate): string
    {
        $this->ensureSession();

        if ($candidate === '') {
            return self::MISSING_CANDIDATE;
        }

        $token = $_SESSION[$this->sessionKey] ?? null;
        if (!is_string($token) || $token === '') {
            return self::MISSING_SESSION_TOKEN;
        }

        return hash_equals($token, $candidate) ? self::VALID : self::MISMATCH;
    }

    public function rotate(): string
    {
        $this->ensureSession();

        $token = bin2hex(random_bytes(32));
        $_SESSION[$this->sessionKey] = $token;

        return $token;
    }

    private function ensureSession(): void
    {
        if (session_status() === PHP_SESSION_NONE) {
            $this->configureCookieParams();
            session_start();
        }
    }

    private function configureCookieParams(): void
    {
        // A 30-day "remember me" cookie is useless if the server drops the session
        // after the 24-minute default. A private save_path also keeps the file out
        // of the shared /tmp that shared hosts sweep with their own cron.
        $sessionPath = function_exists('app_root_path') ? app_root_path('storage', 'sessions') : '';
        if ($sessionPath !== '' && (is_dir($sessionPath) || @mkdir($sessionPath, 0700, true) || is_dir($sessionPath))) {
            @ini_set('session.save_path', $sessionPath);
        }
        @ini_set('session.gc_maxlifetime', (string) (30 * 86400));

        $current = session_get_cookie_params();
        $path = self::cookiePath();
        $https = self::isHttps();

        session_set_cookie_params([
            'lifetime' => (int) ($current['lifetime'] ?? 0),
            'path' => $path,
            'domain' => (string) ($current['domain'] ?? ''),
            'secure' => $https,
            'httponly' => true,
            'samesite' => 'Lax',
        ]);
    }

    public static function cookiePath(?string $basePath = null): string
    {
        $path = $basePath;
        if ($path === null) {
            $path = function_exists('app_base_path') ? app_base_path() : '';
        }
        $path = trim((string) $path);
        if ($path === '' || $path === '/') {
            return '/';
        }

        return '/' . trim(str_replace('\\', '/', $path), '/');
    }

    public static function isHttps(): bool
    {
        $https = $_SERVER['HTTPS'] ?? null;

        if (is_string($https)) {
            $https = strtolower($https);

            if ($https !== '' && $https !== 'off' && $https !== '0') {
                return true;
            }
        }

        if ($https === '1' || $https === 1 || $https === true) {
            return true;
        }

        $forwarded = strtolower(trim((string) ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '')));
        if ($forwarded === 'https' || (int) ($_SERVER['SERVER_PORT'] ?? 0) === 443) {
            return true;
        }

        return false;
    }
}
