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

use ErrorException;
use Throwable;

/**
 * R01-N13: front-controller safety net.
 *
 * `public/index.php` calls `ob_start()` at the very top so a stray
 * warning/notice cannot send headers before `Response::send()` runs.
 * The flush, however, sits in the happy path: a fatal error mid-request
 * (uncaught throwable, OOM, parse error in a lazily-loaded class) would
 * otherwise let PHP flush whatever was in the buffer to the response —
 * without the security headers (CSP, HSTS, X-Frame, etc.) the regular
 * path applies.
 *
 * Registering this handler:
 *
 *   - replaces an uncaught throwable with a minimal `500 Server error`
 *     HTML page, drained of any partial render that was buffered;
 *   - applies the same security headers a normal response would carry
 *     (so an error path is no weaker than the happy path);
 *   - in non-production environments the throwable's class + message is
 *     surfaced into the page; production gets a generic message.
 *
 * The class is pure-static and `emit()` accepts injectable header / body
 * sinks so tests can capture both without touching real PHP `header()` /
 * stdout state.
 */
final class FatalErrorHandler
{
    /**
     * Strict CSP shared with the happy-path response in `public/index.php`.
     * Tailwind is pre-compiled at image-build time; jQuery / jQuery UI are
     * gone; Alpine (CSP build), htmx, and SortableJS are vendored under
     * `/assets/js/vendor/`. So `script-src` and `style-src` are `'self'`
     * only. `form-action` permits the OIDC redirect to Entra.
     *
     * R01-N19: `report-uri /csp-report` routes browser-side violations
     * to `App\Controllers\CspReportController`, which audits each one as
     * `CSP_VIOLATION` / `csp_violation`. Report-uri is the legacy
     * directive but still honoured by every current browser; we are not
     * adding `report-to` (which would need a `Reporting-Endpoints`
     * header on every response and a second JSON parser path) until
     * the legacy form proves insufficient.
     */
    public const CSP =
        "default-src 'self'; "
        . "script-src 'self'; "
        . "style-src 'self'; "
        . "img-src 'self' data:; "
        . "font-src 'self' data:; "
        . "connect-src 'self'; "
        . "frame-ancestors 'none'; "
        . "base-uri 'self'; "
        . "form-action 'self' https://login.microsoftonline.com; "
        . "report-uri /csp-report";

    /**
     * Bit mask for "fatal in the sense that the request can no longer
     * complete normally". User errors and recoverable type errors are
     * included so a runtime `trigger_error(E_USER_ERROR)` from a
     * controller goes through the same minimal-500 path instead of
     * leaking a partial render.
     */
    private const FATAL_MASK = E_ERROR
        | E_PARSE
        | E_CORE_ERROR
        | E_COMPILE_ERROR
        | E_USER_ERROR
        | E_RECOVERABLE_ERROR;

    public static function register(string $appEnv, bool $isHttps): void
    {
        set_exception_handler(static function (Throwable $e) use ($appEnv, $isHttps): void {
            self::emit($e, $appEnv, $isHttps);
        });
        register_shutdown_function(static function () use ($appEnv, $isHttps): void {
            $last = error_get_last();
            if ($last === null) {
                return;
            }
            $type = (int) ($last['type'] ?? 0);
            if (($type & self::FATAL_MASK) === 0) {
                return;
            }
            $err = new ErrorException(
                (string) ($last['message'] ?? 'fatal error'),
                0,
                $type,
                (string) ($last['file'] ?? ''),
                (int) ($last['line'] ?? 0),
            );
            self::emit($err, $appEnv, $isHttps);
        });
    }

    /**
     * Discard buffered output and write a minimal 500 page with the same
     * security headers a regular response would carry.
     *
     * The four callable sinks are dependency-injection seams for tests:
     * pass closures that record into arrays / no-op the drain instead of
     * touching real PHP `header()` / stdout / output buffers. In
     * production all are null and the function uses real PHP I/O.
     *
     * @param ?callable(string,bool):void $emitHeader    ($name_with_value, $replace)
     * @param ?callable(string):void      $emitBody      ($body)
     * @param ?callable():bool            $headersSent
     * @param ?callable():void            $drainBuffers
     */
    public static function emit(
        Throwable $error,
        string $appEnv,
        bool $isHttps,
        ?callable $emitHeader = null,
        ?callable $emitBody = null,
        ?callable $headersSent = null,
        ?callable $drainBuffers = null,
    ): void {
        $drainBuffers = $drainBuffers ?? static function (): void {
            // Drain every level of buffered output so a partial render
            // doesn't leak in front of (or interleave with) the 500
            // page. ob_end_clean returns false at the bottom of the
            // stack — break out then so we don't busy-loop.
            while (ob_get_level() > 0) {
                if (@ob_end_clean() === false) {
                    break;
                }
            }
        };
        $drainBuffers();

        $headersSent = $headersSent ?? static fn(): bool => headers_sent();
        $emitHeader  = $emitHeader  ?? static function (string $line, bool $replace): void {
            header($line, $replace);
        };
        $emitBody    = $emitBody    ?? static function (string $body): void {
            echo $body;
        };

        if (!$headersSent()) {
            // http_response_code via header() so the test sink sees it too.
            $emitHeader('HTTP/1.1 500 Internal Server Error', true);
            $emitHeader('Content-Type: text/html; charset=utf-8', true);
            foreach (self::securityHeaders($isHttps) as $name => $value) {
                $emitHeader($name . ': ' . $value, true);
            }
            // Belt-and-braces: also bump the response code via the
            // dedicated PHP function. The header() call already covers
            // most SAPIs; this is harmless when redundant.
            @http_response_code(500);
        }

        $emitBody(self::renderBody($error, $appEnv));
    }

    /**
     * Headers applied to every response (happy path AND fatal). Returned
     * as a name=>value map so `public/index.php` can spread them onto a
     * `Response` and the fatal path can write them with `header()`.
     *
     * @return array<string,string>
     */
    public static function securityHeaders(bool $isHttps): array
    {
        $h = [
            'X-Content-Type-Options'              => 'nosniff',
            'X-Frame-Options'                     => 'DENY',
            'Referrer-Policy'                     => 'strict-origin-when-cross-origin',
            // R01-N25: defence-in-depth for legacy Adobe Flash / Acrobat
            // cross-origin policy lookups. Cheap header; tells those clients
            // (and any tooling that still inspects the file) that no
            // crossdomain.xml / clientaccesspolicy.xml is honoured under this
            // origin.
            'X-Permitted-Cross-Domain-Policies'   => 'none',
            'Content-Security-Policy'             => self::CSP,
        ];
        if ($isHttps) {
            $h['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains';
        }
        return $h;
    }

    /**
     * Minimal HTML body. In production the class+message is dropped on
     * the floor — operators have the audit log + the PHP error log and
     * the user gets nothing exploitable. In dev / staging the throwable
     * class + message is surfaced so a stack trace is one click away.
     */
    public static function renderBody(Throwable $error, string $appEnv): string
    {
        $detail = '';
        if ($appEnv !== 'production') {
            $payload = get_class($error) . ': ' . $error->getMessage();
            $detail  = '<pre>' . htmlspecialchars($payload, ENT_QUOTES, 'UTF-8') . '</pre>';
        }
        return "<!doctype html>\n"
            . "<html lang=\"en\"><head><meta charset=\"utf-8\">"
            . "<title>500 — Server error</title></head>"
            . "<body style=\"font-family:system-ui,sans-serif;max-width:40rem;margin:3rem auto;padding:0 1rem;\">"
            . "<h1>500 — Server error</h1>"
            . "<p>The application encountered a fatal error while processing your request. "
            . "The incident has been logged.</p>"
            . $detail
            . "</body></html>\n";
    }
}
