<?php

declare(strict_types=1);

namespace App\Http;

use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;

/**
 * Per-session CSRF token. Generated on first GET, validated with
 * constant-time comparison on every state-changing request.
 *
 * Token sources accepted (in order):
 *   1. `X-CSRF-Token` header (htmx, AJAX).
 *   2. `csrf_token` form field (form-encoded body).
 *   3. `csrf_token` JSON body field (`Content-Type: application/json`).
 *
 * Safe methods (GET / HEAD / OPTIONS) skip validation; they only ensure
 * the session-side token exists so the next form render has one to
 * embed.
 *
 * SEC_REVIEW F54 — defence in depth on top of the constant-time token
 * compare:
 *   - `Origin` / `Referer` header check. State-changing requests with
 *     a present-but-cross-origin `Origin` (or, when `Origin` is absent
 *     but `Referer` is present, with a cross-origin `Referer`) are
 *     refused with 403 BEFORE the token check, so a cross-origin
 *     attacker who somehow stole the token cookie still can't fire a
 *     same-token cross-origin POST. Both headers absent → fall
 *     through to the token check (legitimate older clients).
 *   - JSON body extraction. The middleware now also pulls the token
 *     out of a JSON body when `Content-Type: application/json`, so a
 *     future fetch-with-JSON PUT/PATCH endpoint inherits CSRF
 *     protection without a per-route shim.
 */
final class CsrfMiddleware implements MiddlewareInterface
{
    public const SESSION_KEY = '_csrf';
    public const ATTR_TOKEN = 'csrf_token';

    private const SAFE_METHODS = ['GET', 'HEAD', 'OPTIONS'];

    public function __construct(private readonly ResponseFactoryInterface $responseFactory)
    {
    }

    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
    {
        $token = $this->ensureToken();

        if (!in_array(strtoupper($request->getMethod()), self::SAFE_METHODS, true)) {
            // SEC_REVIEW F54: same-origin Origin/Referer check before
            // the token compare. Refuses a cross-origin POST even if
            // the token cookie were somehow exfiltrated.
            if (!self::isSameOrigin($request)) {
                return $this->forbidden('cross-origin request refused');
            }

            $supplied = $this->extractToken($request);
            if ($supplied === null || !hash_equals($token, $supplied)) {
                return $this->forbidden('missing or invalid CSRF token');
            }
        }

        return $handler->handle($request->withAttribute(self::ATTR_TOKEN, $token));
    }

    private function ensureToken(): string
    {
        if (!isset($_SESSION[self::SESSION_KEY]) || !is_string($_SESSION[self::SESSION_KEY])) {
            $_SESSION[self::SESSION_KEY] = bin2hex(random_bytes(32));
        }

        return (string) $_SESSION[self::SESSION_KEY];
    }

    private function extractToken(ServerRequestInterface $request): ?string
    {
        $header = $request->getHeaderLine('X-CSRF-Token');
        if ($header !== '') {
            return $header;
        }
        $body = $request->getParsedBody();
        if (is_array($body) && isset($body['csrf_token']) && is_string($body['csrf_token'])) {
            return $body['csrf_token'];
        }
        // SEC_REVIEW F54: JSON body. `Slim` doesn't auto-parse JSON
        // unless the bodyparsing middleware was wired for it; even
        // when it is, defensive re-parse covers PUT/PATCH endpoints
        // that read raw streams.
        $contentType = strtolower($request->getHeaderLine('Content-Type'));
        if (str_contains($contentType, 'application/json')) {
            $raw = (string) $request->getBody();
            if ($raw !== '') {
                $decoded = json_decode($raw, true);
                if (is_array($decoded) && isset($decoded['csrf_token']) && is_string($decoded['csrf_token'])) {
                    return $decoded['csrf_token'];
                }
            }
        }

        return null;
    }

    /**
     * SEC_REVIEW F54: same-origin gate. Returns true iff the request's
     * `Origin` (preferred) or `Referer` (fallback) matches the
     * scheme+host+port the request itself was made to. Both headers
     * absent → returns true (legitimate older clients / direct curl).
     * The token check still runs in that case; this is purely an
     * additional layer.
     *
     * Modern browsers always send `Origin` on POST/PUT/PATCH/DELETE,
     * so the absent-both branch is effectively the curl/test path.
     */
    private static function isSameOrigin(ServerRequestInterface $request): bool
    {
        $expected = self::originOf($request->getUri());
        if ($expected === null) {
            // Request URI has no host (CLI / test path / malformed
            // entrypoint). Real browser traffic always carries a Host;
            // fail open here so test setups don't have to thread a
            // fake host through every fixture, and rely on the token
            // check as the sole gate in this case.
            return true;
        }

        $origin = trim($request->getHeaderLine('Origin'));
        if ($origin !== '' && $origin !== 'null') {
            return self::normalizeOrigin($origin) === $expected;
        }

        $referer = trim($request->getHeaderLine('Referer'));
        if ($referer !== '') {
            $refOrigin = self::originFromUrl($referer);
            if ($refOrigin === null) {
                return false;
            }

            return $refOrigin === $expected;
        }

        // Neither header present — defer to the token check. Modern
        // browsers always send `Origin` on POST/PUT/PATCH/DELETE, so
        // this branch is the curl / programmatic / very-old-browser
        // path; the token check is sufficient there.
        return true;
    }

    private static function originOf(\Psr\Http\Message\UriInterface $uri): ?string
    {
        $scheme = strtolower($uri->getScheme());
        $host = strtolower($uri->getHost());
        if ($host === '') {
            return null;
        }
        $port = $uri->getPort();
        $portPart = self::isDefaultPort($scheme, $port) ? '' : ':' . $port;

        return $scheme . '://' . $host . $portPart;
    }

    private static function originFromUrl(string $url): ?string
    {
        $parts = parse_url($url);
        if ($parts === false || !isset($parts['scheme'], $parts['host'])) {
            return null;
        }
        $scheme = strtolower((string) $parts['scheme']);
        $host = strtolower((string) $parts['host']);
        $port = isset($parts['port']) ? (int) $parts['port'] : null;
        $portPart = self::isDefaultPort($scheme, $port) ? '' : ':' . $port;

        return $scheme . '://' . $host . $portPart;
    }

    private static function normalizeOrigin(string $origin): string
    {
        // `Origin: https://example.com:443` → strip default port; lower-case.
        $parts = parse_url($origin);
        if ($parts === false || !isset($parts['scheme'], $parts['host'])) {
            return strtolower($origin);
        }
        $scheme = strtolower((string) $parts['scheme']);
        $host = strtolower((string) $parts['host']);
        $port = isset($parts['port']) ? (int) $parts['port'] : null;
        $portPart = self::isDefaultPort($scheme, $port) ? '' : ':' . $port;

        return $scheme . '://' . $host . $portPart;
    }

    private static function isDefaultPort(string $scheme, ?int $port): bool
    {
        if ($port === null) {
            return true;
        }

        return ($scheme === 'https' && $port === 443) || ($scheme === 'http' && $port === 80);
    }

    private function forbidden(string $reason): ResponseInterface
    {
        $response = $this->responseFactory->createResponse(403);
        $response->getBody()->write('Forbidden: ' . $reason);

        return $response->withHeader('Content-Type', 'text/plain');
    }
}
