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

use App\Controllers\CspReportController;
use App\Http\Request;
use App\Services\AuditLogger;
use App\Tests\TestCase;
use PHPUnit\Framework\Attributes\DataProvider;
use ReflectionClass;

/**
 * R01-N19: pin the contract of CspReportController.
 *
 * The controller has two halves:
 *
 *   - `extractReport()` — pure decoder: bytes → array | null. Easy to
 *     pin with a data-providery sweep over good and adversarial inputs.
 *   - `report()` — IO half: takes a Request, writes one audit row, hands
 *     back a 204. Validated end-to-end against a real (in-memory) DB so
 *     we catch shape drift in the audit insert at the same time.
 */
final class CspReportControllerTest extends TestCase
{
    public function testExtractReportUnwrapsCspReportEnvelope(): void
    {
        $body = json_encode(['csp-report' => [
            'document-uri'      => 'https://app.example.com/sprints/1',
            'violated-directive' => "script-src 'self'",
            'blocked-uri'       => 'https://attacker.example/x.js',
        ]], JSON_THROW_ON_ERROR);

        $out = CspReportController::extractReport($body);

        self::assertIsArray($out);
        self::assertSame('https://app.example.com/sprints/1', $out['document-uri']);
        self::assertSame("script-src 'self'", $out['violated-directive']);
        self::assertArrayNotHasKey('csp-report', $out, 'inner object is unwrapped, not nested');
    }

    public function testExtractReportAcceptsBareJsonObject(): void
    {
        $body = '{"document-uri":"https://x.example/","violated-directive":"img-src"}';
        $out  = CspReportController::extractReport($body);

        self::assertIsArray($out);
        self::assertSame('https://x.example/', $out['document-uri']);
    }

    /**
     * @return list<array{0:string,1:string}>
     */
    public static function malformedBodies(): array
    {
        return [
            ['',                                             'empty body'],
            ['not json at all',                              'free text'],
            ['"a string, not an object"',                    'JSON string scalar'],
            ['42',                                           'JSON number scalar'],
            ['null',                                         'JSON null'],
            ['[1,2,3]',                                      'JSON array (top-level non-object)'],
            ['{"csp-report":"not an object"}',               'csp-report not an array → fall through to outer object'],
        ];
    }

    #[DataProvider('malformedBodies')]
    public function testExtractReportRejectsNonObjectAndMalformedBodies(string $body, string $label): void
    {
        $out = CspReportController::extractReport($body);

        // The csp-report-not-an-object case falls through to the outer object,
        // which IS an object — so we accept that one as a non-null array.
        if ($body === '{"csp-report":"not an object"}') {
            self::assertIsArray($out, "{$label}: outer object survives unwrap miss");
            self::assertSame('not an object', $out['csp-report']);
            return;
        }

        self::assertNull($out, "{$label}: should be unparseable");
    }

    public function testReportWritesAuditRowAnd204(): void
    {
        $pdo    = $this->makeDb();
        $audit  = new AuditLogger($pdo);
        $ctrl   = new CspReportController($audit);

        $report = json_encode(['csp-report' => [
            'document-uri'        => 'https://app.example.com/audit',
            'referrer'            => '',
            'violated-directive'  => "script-src 'self'",
            'effective-directive' => "script-src",
            'original-policy'     => "default-src 'self'; report-uri /csp-report",
            'disposition'         => 'enforce',
            'blocked-uri'         => 'inline',
            'line-number'         => 12,
            'source-file'         => 'https://app.example.com/audit',
            'status-code'         => 200,
            'script-sample'       => '',
        ]], JSON_THROW_ON_ERROR);

        $req = $this->makeRequest('POST', '/csp-report', $report, [
            'content-type' => 'application/csp-report',
            'user-agent'   => 'Mozilla/5.0 (TestRunner)',
        ], '203.0.113.10');

        $resp = $ctrl->report($req);

        self::assertSame(204, $resp->status);
        self::assertSame('', $resp->body);

        $rows = $pdo->query(
            'SELECT action, entity_type, entity_id, before_json, after_json,
                    user_id, user_email, ip_address, user_agent
               FROM audit_log'
        )->fetchAll();

        self::assertCount(1, $rows, 'exactly one audit row written');
        $row = $rows[0];
        self::assertSame('CSP_VIOLATION', $row['action']);
        self::assertSame('csp_violation', $row['entity_type']);
        self::assertNull($row['entity_id']);
        self::assertNull($row['before_json']);
        self::assertNull($row['user_id']);
        self::assertNull($row['user_email']);
        self::assertSame('203.0.113.10', $row['ip_address']);
        self::assertSame('Mozilla/5.0 (TestRunner)', $row['user_agent']);

        $after = json_decode((string) $row['after_json'], true);
        self::assertIsArray($after);
        self::assertSame('https://app.example.com/audit', $after['document-uri']);
        self::assertSame("script-src 'self'", $after['violated-directive']);
        self::assertArrayNotHasKey('csp-report', $after);
    }

    public function testReportWithEmptyBodyWritesNoAuditRow(): void
    {
        $pdo   = $this->makeDb();
        $audit = new AuditLogger($pdo);
        $ctrl  = new CspReportController($audit);

        $req  = $this->makeRequest('POST', '/csp-report', '', [], '198.51.100.1');
        $resp = $ctrl->report($req);

        self::assertSame(204, $resp->status);
        self::assertSame(
            0,
            (int) $pdo->query('SELECT COUNT(*) FROM audit_log')->fetchColumn(),
            'empty body → no row',
        );
    }

    public function testReportRejectsBodyOverCap(): void
    {
        $pdo   = $this->makeDb();
        $audit = new AuditLogger($pdo);
        $ctrl  = new CspReportController($audit);

        // Build a JSON object whose serialized form exceeds the 16 KiB cap.
        $padding = str_repeat('a', CspReportController::MAX_BODY_BYTES + 1);
        $body    = json_encode(['csp-report' => ['document-uri' => $padding]], JSON_THROW_ON_ERROR);
        self::assertGreaterThan(CspReportController::MAX_BODY_BYTES, strlen($body));

        $req  = $this->makeRequest('POST', '/csp-report', $body, [
            'content-type' => 'application/csp-report',
        ], '198.51.100.2');
        $resp = $ctrl->report($req);

        self::assertSame(204, $resp->status, 'still 204 — endpoint reveals nothing');
        self::assertSame(
            0,
            (int) $pdo->query('SELECT COUNT(*) FROM audit_log')->fetchColumn(),
            'oversized body → no row',
        );
    }

    public function testReportRejectsNonJsonBody(): void
    {
        $pdo   = $this->makeDb();
        $audit = new AuditLogger($pdo);
        $ctrl  = new CspReportController($audit);

        $req  = $this->makeRequest('POST', '/csp-report', 'plain text junk', [
            'content-type' => 'text/plain',
        ], '198.51.100.3');
        $resp = $ctrl->report($req);

        self::assertSame(204, $resp->status);
        self::assertSame(
            0,
            (int) $pdo->query('SELECT COUNT(*) FROM audit_log')->fetchColumn(),
        );
    }

    public function testMaxBodyBytesIsExactly16KiB(): void
    {
        // Drift fence: a future bump must update REVIEW_01.md and the
        // controller phpdoc together with the constant.
        self::assertSame(16 * 1024, CspReportController::MAX_BODY_BYTES);
    }

    /**
     * @param array<string,string> $headers lower-cased names
     */
    private function makeRequest(
        string $method,
        string $path,
        string $rawBody,
        array $headers = [],
        string $remoteAddr = '127.0.0.1',
    ): Request {
        // The Request constructor is `public readonly` — we hand-build it
        // via reflection so we don't have to mutate $_SERVER / $_POST.
        $server = [
            'REMOTE_ADDR'    => $remoteAddr,
            'REQUEST_METHOD' => $method,
            'REQUEST_URI'    => $path,
        ];

        $r = (new ReflectionClass(Request::class))->newInstanceArgs([
            'method'  => $method,
            'path'    => $path,
            'query'   => [],
            'post'    => [],
            'rawBody' => $rawBody,
            'headers' => $headers,
            'server'  => $server,
        ]);
        return $r;
    }
}
