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

use App\Http\Request;
use App\Http\Response;
use App\Services\AuditLogger;

/**
 * R01-N19: thin endpoint for browser-initiated CSP violation reports.
 *
 * The strict CSP in `App\Http\FatalErrorHandler::CSP` carries a
 * `report-uri /csp-report` directive, so any browser that blocks an
 * inline-style / inline-script / off-host fetch sends a JSON POST here.
 * The report shape is the legacy `report-uri` envelope:
 *
 *     POST /csp-report  Content-Type: application/csp-report
 *     {"csp-report": {"document-uri":"...","violated-directive":"...", ...}}
 *
 * We unwrap the inner `csp-report` (if present) and write a single audit
 * row with `action=CSP_VIOLATION`, `entity_type=csp_violation`. The audit
 * row's `after_json` is the report; `entity_id` is null. Always 204.
 *
 * Anonymous: the endpoint does NOT call any `SessionGuard` gate because
 * the browser fires reports without the session cookie context most of
 * the time. CSRF is intentionally NOT enforced — this is a browser
 * mechanism, not a user action; a forged report only costs one audit row,
 * bounded by the body cap below.
 *
 * Body cap: 16 KiB. Real CSP reports are well under 1 KiB; the cap is
 * generous enough that legitimate reports always fit, tight enough that
 * a hostile client cannot pump megabytes of garbage into `audit_log`.
 */
final class CspReportController
{
    public const MAX_BODY_BYTES = 16 * 1024;

    public function __construct(private readonly AuditLogger $audit)
    {
    }

    public function report(Request $req): Response
    {
        // Always answer 204 — a browser sending a report doesn't expect
        // anything actionable back, and we don't want to leak whether
        // the report was accepted vs. ignored.
        $reply = Response::text('', 204);

        if (strlen($req->rawBody) === 0 || strlen($req->rawBody) > self::MAX_BODY_BYTES) {
            return $reply;
        }

        $report = self::extractReport($req->rawBody);
        if ($report === null) {
            return $reply;
        }

        $this->audit->record(
            action:     'CSP_VIOLATION',
            entityType: 'csp_violation',
            entityId:   null,
            before:     null,
            after:      $report,
            userId:     null,
            userEmail:  null,
            ipAddress:  $req->ip(),
            userAgent:  $req->userAgent(),
        );

        return $reply;
    }

    /**
     * Decode the request body and return the inner CSP report payload.
     * Returns null if the body is not a JSON object or is structurally
     * unusable. Accepts both the bare `{...}` and the standard
     * `{"csp-report": {...}}` envelopes; in the latter case the inner
     * object is what gets recorded.
     *
     * @return array<string,mixed>|null
     */
    public static function extractReport(string $rawBody): ?array
    {
        try {
            /** @var mixed $decoded */
            $decoded = json_decode($rawBody, true, 8, JSON_THROW_ON_ERROR);
        } catch (\JsonException) {
            return null;
        }
        // Top-level JSON must be an object (assoc array). A bare list
        // `[…]` decodes to a list-shaped array, which is not what any
        // CSP report shape uses.
        if (!is_array($decoded) || array_is_list($decoded)) {
            return null;
        }

        if (isset($decoded['csp-report']) && is_array($decoded['csp-report'])) {
            return $decoded['csp-report'];
        }
        return $decoded;
    }
}
