<?php

declare(strict_types=1);

namespace App\Tests\Unit\Http;

use App\Http\CspMiddleware;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Slim\Psr7\Factory\ResponseFactory;
use Slim\Psr7\Factory\ServerRequestFactory;

final class CspMiddlewareTest extends TestCase
{
    public function testGeneratedNoncesAreUniqueAndUrlSafe(): void
    {
        $seen = [];
        for ($i = 0; $i < 50; $i++) {
            $nonce = CspMiddleware::generateNonce();
            self::assertMatchesRegularExpression('/^[A-Za-z0-9_-]+$/', $nonce);
            self::assertFalse(in_array($nonce, $seen, true), 'nonce should be unique');
            $seen[] = $nonce;
        }
    }

    public function testPolicyContainsNonceAndDropsUnsafeDirectives(): void
    {
        $nonce = 'TESTNONCE123';
        $policy = CspMiddleware::policy($nonce);

        self::assertStringContainsString("'nonce-{$nonce}'", $policy);
        self::assertStringNotContainsString("'unsafe-eval'", $policy);
        // `script-src` directive itself must not list `'unsafe-inline'`.
        $scriptSrc = self::extractDirective($policy, 'script-src');
        self::assertNotSame('', $scriptSrc, 'script-src directive missing');
        self::assertStringNotContainsString("'unsafe-inline'", $scriptSrc);
        self::assertStringContainsString("'self'", $scriptSrc);
        self::assertStringContainsString("'nonce-{$nonce}'", $scriptSrc);
        // SEC_REVIEW F62: `style-src` must NOT contain `'unsafe-inline'`.
        // Inline styles attribute selectors would otherwise let an
        // attacker exfiltrate secrets like the CSRF token char-by-char.
        $styleSrc = self::extractDirective($policy, 'style-src');
        self::assertNotSame('', $styleSrc, 'style-src directive missing');
        self::assertStringNotContainsString("'unsafe-inline'", $styleSrc);
        self::assertSame("style-src 'self'", $styleSrc);
        // Defence-in-depth: frame-ancestors / form-action / base-uri locked down.
        self::assertStringContainsString("frame-ancestors 'none'", $policy);
        self::assertStringContainsString("form-action 'self'", $policy);
        self::assertStringContainsString("base-uri 'self'", $policy);
    }

    private static function extractDirective(string $csp, string $name): string
    {
        foreach (explode(';', $csp) as $part) {
            $part = trim($part);
            if (str_starts_with($part, $name . ' ')) {
                return $part;
            }
        }

        return '';
    }

    public function testProcessSetsHeaderAndExposesNonceOnRequestAttribute(): void
    {
        $middleware = new CspMiddleware();
        $request = (new ServerRequestFactory())->createServerRequest('GET', '/');
        $rf = new ResponseFactory();

        $captured = null;
        $handler = new class ($rf, $captured) implements RequestHandlerInterface {
            public function __construct(
                private readonly ResponseFactory $rf,
                public ?string $captured,
            ) {
            }

            public function handle(ServerRequestInterface $request): ResponseInterface
            {
                $this->captured = $request->getAttribute(CspMiddleware::ATTR_NONCE);

                return $this->rf->createResponse(200);
            }
        };

        $response = $middleware->process($request, $handler);

        self::assertNotNull($handler->captured);
        self::assertNotSame('', $handler->captured);
        self::assertStringContainsString(
            "'nonce-{$handler->captured}'",
            $response->getHeaderLine('Content-Security-Policy'),
        );
    }
}
