<?php

declare(strict_types=1);

namespace App\Tests\Integration\Admin;

use App\Domain\Auth\Role;
use App\Domain\Auth\TokenKind;
use App\Tests\Integration\Support\AppTestCase;

/**
 * SEC_REVIEW F52 — admin CRUD endpoints must strip C0/C1 control
 * characters from free-form string fields (`name`, `description`,
 * `reason`) before they land in `audit_log.target_label` /
 * `details_json`. NULs, newlines and ANSI escapes otherwise enable
 * log-injection (`\n[CRIT] fake event`) and terminal-escape attacks
 * on log viewers.
 *
 * One test per controller is enough — the helper is shared in
 * `AdminControllerSupport` and applied at every relevant call site.
 */
final class InputControlCharStrippingTest extends AppTestCase
{
    public function testReporterNameAndDescriptionAreStripped(): void
    {
        $token = $this->createToken(TokenKind::Admin, role: Role::Admin);

        $resp = $this->request(
            'POST',
            '/api/v1/admin/reporters',
            ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
            (string) json_encode([
                'name' => "web\x00prod\n01\u{007f}",
                'description' => "first\nwebserver\x07with\x00control\u{001b}chars",
                'trust_weight' => 1.0,
            ]),
        );
        self::assertSame(201, $resp->getStatusCode());
        $body = $this->decode($resp);
        self::assertControlBytesGone($body['name']);
        self::assertControlBytesGone($body['description']);
        // Visible payload (post-scrub) round-trips byte-for-byte.
        self::assertSame('webprod01', $body['name']);
        self::assertSame('firstwebserverwithcontrolchars', $body['description']);

        // Audit log target_label / details_json must also be clean.
        $audit = $this->db->fetchAssociative(
            "SELECT target_label, details_json FROM audit_log WHERE action = 'reporter.created' AND target_id = ?",
            [(string) $body['id']],
        );
        self::assertIsArray($audit);
        self::assertSame('webprod01', $audit['target_label']);
        self::assertControlBytesGone((string) $audit['details_json']);
    }

    public function testConsumerNameIsStripped(): void
    {
        $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
        $policyId = (int) $this->db->fetchOne(
            'SELECT id FROM policies WHERE name = :name',
            ['name' => 'moderate'],
        );

        $resp = $this->request(
            'POST',
            '/api/v1/admin/consumers',
            ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
            (string) json_encode([
                'name' => "fw\x00edge\nok\u{007f}",
                'description' => "edge\nrouter\x00",
                'policy_id' => $policyId,
            ]),
        );
        self::assertSame(201, $resp->getStatusCode());
        $body = $this->decode($resp);
        self::assertControlBytesGone($body['name']);
        self::assertControlBytesGone($body['description']);
        self::assertSame('fwedgeok', $body['name']);
        self::assertSame('edgerouter', $body['description']);
    }

    public function testCategoryNameIsStripped(): void
    {
        $token = $this->createToken(TokenKind::Admin, role: Role::Admin);

        $resp = $this->request(
            'POST',
            '/api/v1/admin/categories',
            ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
            (string) json_encode([
                'slug' => 'sec_review_f52',
                'name' => "Brute\x00 force\n attempts",
                'description' => "Repeated\nlogin\x00fails",
                'decay_function' => 'linear',
                'decay_param' => 14,
            ]),
        );
        self::assertSame(201, $resp->getStatusCode());
        $body = $this->decode($resp);
        self::assertControlBytesGone($body['name']);
        self::assertControlBytesGone($body['description']);
        self::assertSame('Brute force attempts', $body['name']);
    }

    public function testManualBlockReasonIsStripped(): void
    {
        $token = $this->createToken(TokenKind::Admin, role: Role::Operator);

        $resp = $this->request(
            'POST',
            '/api/v1/admin/manual-blocks',
            ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
            (string) json_encode([
                'kind' => 'ip',
                'ip' => '198.51.100.42',
                'reason' => "abuse\n\x00alert\x07more\u{007f}",
            ]),
        );
        self::assertSame(201, $resp->getStatusCode());
        $body = $this->decode($resp);
        self::assertControlBytesGone($body['reason']);
        self::assertSame('abusealertmore', $body['reason']);
    }

    public function testAllowlistReasonIsStripped(): void
    {
        $token = $this->createToken(TokenKind::Admin, role: Role::Operator);

        $resp = $this->request(
            'POST',
            '/api/v1/admin/allowlist',
            ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
            (string) json_encode([
                'kind' => 'ip',
                'ip' => '203.0.113.99',
                'reason' => "trusted\nsource\x00ok",
            ]),
        );
        self::assertSame(201, $resp->getStatusCode());
        $body = $this->decode($resp);
        self::assertControlBytesGone($body['reason']);
        self::assertSame('trustedsourceok', $body['reason']);
    }

    public function testReporterUpdateAlsoStrips(): void
    {
        // Defence-in-depth: update path must apply the same scrub.
        $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
        $reporterId = $this->createReporter('web-update-strip');

        $resp = $this->request(
            'PATCH',
            "/api/v1/admin/reporters/{$reporterId}",
            ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
            (string) json_encode([
                'name' => "renamed\x00\n",
                'description' => "later\u{001b}",
            ]),
        );
        self::assertSame(200, $resp->getStatusCode());
        $body = $this->decode($resp);
        self::assertControlBytesGone($body['name']);
        self::assertControlBytesGone($body['description']);
        self::assertSame('renamed', $body['name']);
        self::assertSame('later', $body['description']);
    }

    private static function assertControlBytesGone(string $value): void
    {
        // Empty regex match → no C0/C1/DEL bytes in the value. The
        // ESC byte (0x1B) is the lead-in for ANSI escape sequences;
        // its absence neutralises terminal-interpretation attacks
        // even if the trailing `[31m`-style payload remains as
        // visible text.
        self::assertSame(
            0,
            preg_match('/[\x00-\x1f\x7f-\x9f]/u', $value),
            sprintf('control byte found in %s', json_encode($value)),
        );
    }
}
