<?php

declare(strict_types=1);

namespace App\Tests\Integration\Audit;

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

/**
 * Confirms that successful state-changing admin endpoints emit exactly
 * one row in `audit_log` and that the actor attribution honours the
 * SPEC §8 invariant: service-token + impersonation -> actor_kind=user;
 * raw admin token -> actor_kind=admin-token.
 */
final class AuditEmissionTest extends AppTestCase
{
    public function testManualBlockCreateEmitsAuditViaAdminToken(): void
    {
        $token = $this->createToken(TokenKind::Admin, Role::Admin);
        $resp = $this->request(
            'POST',
            '/api/v1/admin/manual-blocks',
            [
                'Authorization' => 'Bearer ' . $token,
                'Content-Type' => 'application/json',
            ],
            (string) json_encode(['kind' => 'ip', 'ip' => '203.0.113.42', 'reason' => 'admin-token-test']),
        );
        self::assertSame(201, $resp->getStatusCode());

        $row = $this->db->fetchAssociative(
            "SELECT actor_kind, actor_id, action, target_type, details_json FROM audit_log WHERE action = 'manual_block.created' ORDER BY id DESC LIMIT 1"
        );
        self::assertIsArray($row);
        self::assertSame('admin-token', $row['actor_kind']);
        self::assertSame('manual_block', $row['target_type']);
        $details = json_decode((string) $row['details_json'], true);
        self::assertIsArray($details);
        self::assertSame('203.0.113.42', $details['ip']);
        self::assertSame('admin-token-test', $details['reason']);
    }

    public function testManualBlockCreateEmitsAuditAttributedToImpersonatedUser(): void
    {
        $userId = $this->createUser(Role::Admin, isLocal: true);
        $service = $this->createToken(TokenKind::Service);
        $resp = $this->request(
            'POST',
            '/api/v1/admin/manual-blocks',
            [
                'Authorization' => 'Bearer ' . $service,
                'X-Acting-User-Id' => (string) $userId,
                'Content-Type' => 'application/json',
            ],
            (string) json_encode(['kind' => 'ip', 'ip' => '203.0.113.43', 'reason' => 'via-ui']),
        );
        self::assertSame(201, $resp->getStatusCode());

        $row = $this->db->fetchAssociative(
            "SELECT actor_kind, actor_id FROM audit_log WHERE action = 'manual_block.created' ORDER BY id DESC LIMIT 1"
        );
        self::assertIsArray($row);
        self::assertSame('user', $row['actor_kind']);
        self::assertSame((string) $userId, $row['actor_id']);
    }

    public function testTokenCreateNeverPutsRawTokenInAudit(): void
    {
        $admin = $this->createToken(TokenKind::Admin, Role::Admin);
        $reporterId = $this->createReporter('rep-audit');
        $resp = $this->request(
            'POST',
            '/api/v1/admin/tokens',
            ['Authorization' => 'Bearer ' . $admin, 'Content-Type' => 'application/json'],
            (string) json_encode(['kind' => 'reporter', 'reporter_id' => $reporterId]),
        );
        self::assertSame(201, $resp->getStatusCode());
        $body = $this->decode($resp);
        $rawToken = (string) $body['raw_token'];

        $row = $this->db->fetchAssociative(
            "SELECT details_json FROM audit_log WHERE action = 'token.created' ORDER BY id DESC LIMIT 1"
        );
        self::assertIsArray($row);
        $json = (string) $row['details_json'];
        self::assertStringNotContainsString($rawToken, $json);
        // Prefix is allowed.
        $details = json_decode($json, true);
        self::assertIsArray($details);
        self::assertArrayHasKey('prefix', $details);
        self::assertArrayHasKey('kind', $details);
    }

    public function testFailedValidationDoesNotEmitAudit(): void
    {
        $admin = $this->createToken(TokenKind::Admin, Role::Admin);
        $resp = $this->request(
            'POST',
            '/api/v1/admin/manual-blocks',
            ['Authorization' => 'Bearer ' . $admin, 'Content-Type' => 'application/json'],
            (string) json_encode(['kind' => 'ip', 'ip' => 'not-an-ip']),
        );
        self::assertSame(400, $resp->getStatusCode());

        $count = (int) $this->db->fetchOne("SELECT COUNT(*) FROM audit_log WHERE action = 'manual_block.created'");
        self::assertSame(0, $count);
    }

    public function testCategoryCreateUpdateDeleteAudit(): void
    {
        $admin = $this->createToken(TokenKind::Admin, Role::Admin);
        $resp = $this->request(
            'POST',
            '/api/v1/admin/categories',
            ['Authorization' => 'Bearer ' . $admin, 'Content-Type' => 'application/json'],
            (string) json_encode([
                'slug' => 'audit_test',
                'name' => 'Audit Test',
                'decay_function' => 'linear',
                'decay_param' => 30.0,
            ]),
        );
        self::assertSame(201, $resp->getStatusCode());
        $created = $this->decode($resp);
        $id = (int) $created['id'];

        $resp = $this->request(
            'PATCH',
            '/api/v1/admin/categories/' . $id,
            ['Authorization' => 'Bearer ' . $admin, 'Content-Type' => 'application/json'],
            (string) json_encode(['name' => 'Audit Test Renamed']),
        );
        self::assertSame(200, $resp->getStatusCode());

        $resp = $this->request(
            'DELETE',
            '/api/v1/admin/categories/' . $id,
            ['Authorization' => 'Bearer ' . $admin],
        );
        self::assertSame(204, $resp->getStatusCode());

        $rows = $this->db->fetchAllAssociative(
            "SELECT action FROM audit_log WHERE target_type = 'category' AND target_id = ? ORDER BY id ASC",
            [(string) $id],
        );
        $actions = array_map(static fn ($r) => $r['action'], $rows);
        self::assertSame(['category.created', 'category.updated', 'category.deleted'], $actions);
    }
}
