<?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\Tests\Http;

use App\Http\FatalErrorHandler;
use App\Tests\TestCase;
use RuntimeException;

/**
 * R01-N13: pin the front-controller safety net.
 *
 * The handler's two side-effecting concerns — emitting headers and
 * echoing the body — are both injected as callables, so we can capture
 * their arguments without touching real PHP `header()` or stdout.
 */
final class FatalErrorHandlerTest extends TestCase
{
    /** @var list<string> */
    private array $headers;
    private string $body;
    private bool $headersSent;
    private int $drainCalls;

    protected function setUp(): void
    {
        $this->headers     = [];
        $this->body        = '';
        $this->headersSent = false;
        $this->drainCalls  = 0;
    }

    public function testEmitWritesMinimal500WithSecurityHeadersInProduction(): void
    {
        FatalErrorHandler::emit(
            new RuntimeException('database is on fire'),
            'production',
            isHttps: true,
            emitHeader: $this->headerSink(),
            emitBody:   $this->bodySink(),
            headersSent: fn(): bool => $this->headersSent,
            drainBuffers: $this->drainSink(),
        );

        self::assertSame(1, $this->drainCalls, 'emit must drain buffers exactly once');

        self::assertContains('HTTP/1.1 500 Internal Server Error', $this->headers);
        self::assertContains('Content-Type: text/html; charset=utf-8', $this->headers);
        self::assertContains('X-Content-Type-Options: nosniff', $this->headers);
        self::assertContains('X-Frame-Options: DENY', $this->headers);
        self::assertContains('Referrer-Policy: strict-origin-when-cross-origin', $this->headers);
        // R01-N25: Flash / Acrobat cross-domain policy lockout.
        self::assertContains('X-Permitted-Cross-Domain-Policies: none', $this->headers);
        self::assertContains(
            'Strict-Transport-Security: max-age=31536000; includeSubDomains',
            $this->headers,
            'HSTS must ride along when isHttps=true',
        );
        $cspLine = $this->findHeader('Content-Security-Policy');
        self::assertNotNull($cspLine);
        self::assertStringContainsString("default-src 'self'",  $cspLine);
        self::assertStringContainsString("script-src 'self'",   $cspLine);
        self::assertStringContainsString("frame-ancestors 'none'", $cspLine);
        self::assertStringContainsString(
            "form-action 'self' https://login.microsoftonline.com",
            $cspLine,
            'OIDC redirect target must remain reachable from the error page',
        );
        self::assertStringContainsString(
            'report-uri /csp-report',
            $cspLine,
            'R01-N19: browsers must know where to send CSP violation reports',
        );

        self::assertStringContainsString('500 — Server error', $this->body);
        self::assertStringNotContainsString(
            'database is on fire',
            $this->body,
            'production must NOT leak the throwable message',
        );
        self::assertStringNotContainsString(
            'RuntimeException',
            $this->body,
            'production must NOT leak the throwable class',
        );
    }

    public function testEmitLeaksThrowableDetailInDevelopment(): void
    {
        FatalErrorHandler::emit(
            new RuntimeException('PDOException: no such table'),
            'development',
            isHttps: false,
            emitHeader: $this->headerSink(),
            emitBody:   $this->bodySink(),
            headersSent: fn(): bool => $this->headersSent,
            drainBuffers: $this->drainSink(),
        );

        self::assertStringContainsString('500 — Server error', $this->body);
        self::assertStringContainsString('RuntimeException',   $this->body);
        self::assertStringContainsString('no such table',      $this->body);
    }

    public function testEmitOmitsHstsWhenNotHttps(): void
    {
        FatalErrorHandler::emit(
            new RuntimeException('boom'),
            'production',
            isHttps: false,
            emitHeader: $this->headerSink(),
            emitBody:   $this->bodySink(),
            headersSent: fn(): bool => $this->headersSent,
            drainBuffers: $this->drainSink(),
        );

        foreach ($this->headers as $h) {
            self::assertStringStartsNotWith(
                'Strict-Transport-Security:',
                $h,
                'HSTS must not be emitted on plain HTTP',
            );
        }
    }

    public function testEmitSkipsHeadersWhenAlreadySent(): void
    {
        // Mid-flight fatal: PHP has already written status + some headers.
        // We must NOT try to set new ones (PHP would warn) — but the body
        // append should still happen so users see *something*.
        $this->headersSent = true;

        FatalErrorHandler::emit(
            new RuntimeException('mid-flight'),
            'production',
            isHttps: true,
            emitHeader: $this->headerSink(),
            emitBody:   $this->bodySink(),
            headersSent: fn(): bool => $this->headersSent,
            drainBuffers: $this->drainSink(),
        );

        self::assertSame([], $this->headers);
        self::assertStringContainsString('500 — Server error', $this->body);
        self::assertSame(
            1,
            $this->drainCalls,
            'buffer must still be drained even when headers are already sent — partial output is dangerous',
        );
    }

    public function testEmitInvokesDrainBeforeWriting(): void
    {
        // Pin the drain → write ordering: if the drain ran AFTER the
        // body sink, the partial render the buffer holds would be
        // emitted first, in front of (or interleaved with) our 500
        // page. Capture the call order via a closure that records the
        // sequence and assert drain comes first.
        $order = [];
        FatalErrorHandler::emit(
            new RuntimeException('boom'),
            'production',
            isHttps: false,
            emitHeader: function (string $line, bool $replace) use (&$order): void {
                $order[] = 'header';
            },
            emitBody: function (string $body) use (&$order): void {
                $order[] = 'body';
            },
            headersSent: fn(): bool => false,
            drainBuffers: function () use (&$order): void {
                $order[] = 'drain';
            },
        );

        self::assertSame('drain', $order[0] ?? null, 'buffer drain must precede any header / body write');
        self::assertContains('body', $order);
    }

    public function testRenderBodyEscapesThrowableMessageInDev(): void
    {
        $body = FatalErrorHandler::renderBody(
            new RuntimeException('<script>alert("xss")</script>'),
            'development',
        );
        self::assertStringNotContainsString(
            '<script>alert("xss")</script>',
            $body,
            'dev message must be HTML-escaped before display',
        );
        self::assertStringContainsString('&lt;script&gt;', $body);
    }

    public function testSecurityHeadersHelperMatchesEmittedSet(): void
    {
        $h = FatalErrorHandler::securityHeaders(true);
        self::assertSame('nosniff',                                $h['X-Content-Type-Options']);
        self::assertSame('DENY',                                   $h['X-Frame-Options']);
        self::assertSame('strict-origin-when-cross-origin',        $h['Referrer-Policy']);
        self::assertSame('none',                                   $h['X-Permitted-Cross-Domain-Policies']);
        self::assertSame('max-age=31536000; includeSubDomains',    $h['Strict-Transport-Security']);
        self::assertArrayHasKey('Content-Security-Policy', $h);

        $hPlain = FatalErrorHandler::securityHeaders(false);
        self::assertArrayNotHasKey('Strict-Transport-Security', $hPlain);
        // R01-N25: the new header is unconditional — must ride along on
        // both HTTP and HTTPS responses, unlike HSTS.
        self::assertSame('none', $hPlain['X-Permitted-Cross-Domain-Policies']);
    }

    /** @return callable(string,bool):void */
    private function headerSink(): callable
    {
        return function (string $line, bool $replace): void {
            $this->headers[] = $line;
        };
    }

    /** @return callable(string):void */
    private function bodySink(): callable
    {
        return function (string $body): void {
            $this->body .= $body;
        };
    }

    /**
     * No-op drain that just counts calls. Lets us assert "drain was
     * invoked exactly once" without disturbing PHPUnit's own output
     * buffers (which it owns and forbids us from closing).
     *
     * @return callable():void
     */
    private function drainSink(): callable
    {
        return function (): void {
            $this->drainCalls++;
        };
    }

    private function findHeader(string $name): ?string
    {
        $needle = strtolower($name) . ':';
        foreach ($this->headers as $h) {
            if (str_starts_with(strtolower($h), $needle)) {
                return $h;
            }
        }
        return null;
    }
}
