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

/**
 * `/api/v1/admin/audit-log` end-to-end. Seeds rows directly so the
 * filter coverage doesn't depend on emission timing.
 */
final class AuditLogControllerTest extends AppTestCase
{
    public function testListEmpty(): void
    {
        $token = $this->createToken(TokenKind::Admin, Role::Viewer);
        $resp = $this->request('GET', '/api/v1/admin/audit-log', ['Authorization' => 'Bearer ' . $token]);
        self::assertSame(200, $resp->getStatusCode());
        $body = $this->decode($resp);
        self::assertSame([], $body['items']);
        self::assertSame(0, $body['total']);
    }

    public function testListWithFilters(): void
    {
        $now = (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->format('Y-m-d H:i:s');
        $this->seedAudit('user', '7', 'manual_block.created', 'manual_block', '1', '{"ip":"1.1.1.1"}', $now);
        $this->seedAudit('admin-token', '3', 'category.created', 'category', '2', '{"slug":"x"}', $now);
        $this->seedAudit('user', '7', 'allowlist.created', 'allowlist', '5', '{"ip":"2.2.2.2"}', $now);

        $token = $this->createToken(TokenKind::Admin, Role::Viewer);
        // Filter by actor_kind=user
        $resp = $this->request('GET', '/api/v1/admin/audit-log?actor_kind=user', ['Authorization' => 'Bearer ' . $token]);
        $body = $this->decode($resp);
        self::assertSame(2, $body['total']);
        foreach ($body['items'] as $item) {
            self::assertSame('user', $item['actor_kind']);
        }

        // Filter by action
        $resp = $this->request('GET', '/api/v1/admin/audit-log?action=category.created', ['Authorization' => 'Bearer ' . $token]);
        $body = $this->decode($resp);
        self::assertSame(1, $body['total']);
        self::assertSame('category.created', $body['items'][0]['action']);
        self::assertSame(['slug' => 'x'], $body['items'][0]['details']);

        // Filter by entity_type=manual_block
        $resp = $this->request('GET', '/api/v1/admin/audit-log?entity_type=manual_block', ['Authorization' => 'Bearer ' . $token]);
        $body = $this->decode($resp);
        self::assertSame(1, $body['total']);
        self::assertSame('manual_block', $body['items'][0]['entity_type']);
    }

    public function testSubjectFilterUnionsActorAndTarget(): void
    {
        $now = (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->format('Y-m-d H:i:s');

        // (1) admin updated reporter #5 — target=reporter, actor=user
        $this->seedAudit('user', '1', 'reporter.updated', 'reporter', '5', '{}', $now);
        // (2) reporter #5 emitted a report.received — actor=reporter, target=report
        $this->seedAudit('reporter', '5', 'report.received', 'report', '99', '{}', $now);
        // (3) different reporter — must NOT appear in subject_kind=reporter,subject_id=5
        $this->seedAudit('reporter', '6', 'report.received', 'report', '100', '{}', $now);
        // (4) unrelated row — must NOT appear
        $this->seedAudit('user', '1', 'manual_block.created', 'manual_block', '7', '{}', $now);

        $token = $this->createToken(TokenKind::Admin, Role::Viewer);
        $resp = $this->request(
            'GET',
            '/api/v1/admin/audit-log?subject_kind=reporter&subject_id=5',
            ['Authorization' => 'Bearer ' . $token],
        );
        $body = $this->decode($resp);
        self::assertSame(2, $body['total']);
        $actions = array_map(static fn (array $r): string => $r['action'], $body['items']);
        self::assertContains('reporter.updated', $actions);
        self::assertContains('report.received', $actions);
        self::assertNotContains('manual_block.created', $actions);
    }

    public function testSubjectKindWithoutIdReturns400(): void
    {
        $token = $this->createToken(TokenKind::Admin, Role::Viewer);
        $resp = $this->request(
            'GET',
            '/api/v1/admin/audit-log?subject_kind=reporter',
            ['Authorization' => 'Bearer ' . $token],
        );
        self::assertSame(400, $resp->getStatusCode());
        self::assertArrayHasKey('subject', $this->decode($resp)['details']);
    }

    public function testInvalidActorKindReturns400(): void
    {
        $token = $this->createToken(TokenKind::Admin, Role::Viewer);
        $resp = $this->request('GET', '/api/v1/admin/audit-log?actor_kind=potato', ['Authorization' => 'Bearer ' . $token]);
        self::assertSame(400, $resp->getStatusCode());
    }

    public function testRequiresViewer(): void
    {
        $resp = $this->request('GET', '/api/v1/admin/audit-log');
        self::assertSame(401, $resp->getStatusCode());
    }

    public function testOversizedFilterRejected(): void
    {
        // SEC_REVIEW F31: free-form filter strings are bounded at 128 chars.
        $token = $this->createToken(TokenKind::Admin, Role::Viewer);
        $long = str_repeat('a', 200);
        $resp = $this->request(
            'GET',
            '/api/v1/admin/audit-log?action=' . $long,
            ['Authorization' => 'Bearer ' . $token],
        );
        self::assertSame(400, $resp->getStatusCode());
        self::assertArrayHasKey('action', $this->decode($resp)['details']);

        $resp = $this->request(
            'GET',
            '/api/v1/admin/audit-log?entity_type=' . $long,
            ['Authorization' => 'Bearer ' . $token],
        );
        self::assertSame(400, $resp->getStatusCode());
        self::assertArrayHasKey('entity_type', $this->decode($resp)['details']);

        $resp = $this->request(
            'GET',
            '/api/v1/admin/audit-log?entity_id=' . $long,
            ['Authorization' => 'Bearer ' . $token],
        );
        self::assertSame(400, $resp->getStatusCode());
        self::assertArrayHasKey('entity_id', $this->decode($resp)['details']);

        $resp = $this->request(
            'GET',
            '/api/v1/admin/audit-log?subject_kind=' . $long . '&subject_id=5',
            ['Authorization' => 'Bearer ' . $token],
        );
        self::assertSame(400, $resp->getStatusCode());
        self::assertArrayHasKey('subject', $this->decode($resp)['details']);
    }

    public function testKindFilterCharsetGate(): void
    {
        // SEC_REVIEW F47: `entity_type` and `subject_kind` are matched
        // against `actor_kind` / `target_type` columns whose real values
        // are short snake/kebab identifiers. Reject anything outside
        // `^[a-z0-9][a-z0-9_-]*$` so wild bytes can't reach the
        // prepared statement (no SQLi today, but the zero-byte and
        // multi-megabyte cases waste planner work).
        $token = $this->createToken(TokenKind::Admin, Role::Viewer);

        // entity_type with uppercase / dot / space
        foreach (['Reporter', 'reporter.created', 'reporter manual', '-leading-dash', "report\nfaked"] as $bad) {
            $resp = $this->request(
                'GET',
                '/api/v1/admin/audit-log?entity_type=' . rawurlencode($bad),
                ['Authorization' => 'Bearer ' . $token],
            );
            self::assertSame(400, $resp->getStatusCode(), "expected 400 for entity_type={$bad}");
            self::assertArrayHasKey('entity_type', $this->decode($resp)['details']);
        }

        // subject_kind needs the same gate (paired with subject_id).
        foreach (['Reporter', 'reporter.created', 'rep orter', '-leading-dash'] as $bad) {
            $resp = $this->request(
                'GET',
                '/api/v1/admin/audit-log?subject_kind=' . rawurlencode($bad) . '&subject_id=5',
                ['Authorization' => 'Bearer ' . $token],
            );
            self::assertSame(400, $resp->getStatusCode(), "expected 400 for subject_kind={$bad}");
            self::assertArrayHasKey('subject', $this->decode($resp)['details']);
        }

        // Real kinds still pass through (smoke).
        foreach (['reporter', 'consumer', 'manual_block', 'admin-token', 'oidc_role_mapping'] as $good) {
            $resp = $this->request(
                'GET',
                '/api/v1/admin/audit-log?entity_type=' . rawurlencode($good),
                ['Authorization' => 'Bearer ' . $token],
            );
            self::assertSame(200, $resp->getStatusCode(), "expected 200 for entity_type={$good}");
        }
    }

    public function testDeepOffsetRejected(): void
    {
        // SEC_REVIEW F31: pagination is capped at 10 000 offset rows so a
        // Viewer can't force `LIMIT 200 OFFSET huge` deep scans.
        $token = $this->createToken(TokenKind::Admin, Role::Viewer);
        $resp = $this->request(
            'GET',
            '/api/v1/admin/audit-log?page=999999&page_size=200',
            ['Authorization' => 'Bearer ' . $token],
        );
        self::assertSame(400, $resp->getStatusCode());
        self::assertArrayHasKey('page', $this->decode($resp)['details']);
    }

    public function testDeepOffsetAtBoundaryAccepted(): void
    {
        // Right at offset = MAX_OFFSET (10 000) is fine; one more page over
        // is rejected.
        $token = $this->createToken(TokenKind::Admin, Role::Viewer);
        // page=51, page_size=200 → offset=10000 (boundary, allowed)
        $resp = $this->request(
            'GET',
            '/api/v1/admin/audit-log?page=51&page_size=200',
            ['Authorization' => 'Bearer ' . $token],
        );
        self::assertSame(200, $resp->getStatusCode());

        // page=52, page_size=200 → offset=10200 (over boundary, rejected)
        $resp = $this->request(
            'GET',
            '/api/v1/admin/audit-log?page=52&page_size=200',
            ['Authorization' => 'Bearer ' . $token],
        );
        self::assertSame(400, $resp->getStatusCode());
    }

    private function seedAudit(string $kind, ?string $actorId, string $action, string $type, string $id, string $details, string $when): void
    {
        $this->db->insert('audit_log', [
            'actor_kind' => $kind,
            'actor_id' => $actorId,
            'action' => $action,
            'target_type' => $type,
            'target_id' => $id,
            'details_json' => $details,
            'ip_address' => null,
            'created_at' => $when,
        ]);
    }
}
