<?php

declare(strict_types=1);

namespace App\Tests\Module\Catalog\Api;

use App\Module\Catalog\Domain\Entity\Category;
use Doctrine\DBAL\Connection;

/**
 * Tests Audit : l'attribut `#[Auditable]` porte sur Category, donc chaque
 * POST / PATCH / DELETE doit produire une ligne dans `audit_log` via le
 * AuditListener + AuditLogWriter (cf. spec audit-log.md).
 *
 * Verifications :
 *  - une ligne `entity_type='catalog.Category'` apparait apres chaque
 *    operation HTTP authentifiee comme admin ;
 *  - l'action est `create` / `update` (le soft delete est trace comme
 *    `update` puisque c'est un UPDATE Doctrine, cf. spec § 6.1) ;
 *  - `performed_by` est le username du user authentifie ;
 *  - `changes` est non vide (snapshot complet pour insert, diff pour update).
 *
 * Lecture via la connexion DBAL `audit` (pattern de AuditLogApiTest).
 *
 * @internal
 */
final class CategoryAuditTest extends AbstractCatalogApiTestCase
{
    private const string ENTITY_TYPE = 'catalog.Category';

    private ?Connection $auditConnection = null;

    protected function setUp(): void
    {
        parent::setUp();
        self::bootKernel();

        /** @var Connection $conn */
        $conn                  = self::getContainer()->get('doctrine.dbal.audit_connection');
        $this->auditConnection = $conn;
    }

    protected function tearDown(): void
    {
        if (null !== $this->auditConnection) {
            $this->auditConnection->close();
        }
        parent::tearDown();
    }

    public function testAuditLogOnCreate(): void
    {
        $type   = $this->createCategoryType();
        $client = $this->createAdminClient();

        $response = $client->request('POST', '/api/categories', [
            'headers' => ['Content-Type' => 'application/ld+json'],
            'json'    => [
                'name'         => self::TEST_CATEGORY_PREFIX.'audit_create',
                'categoryTypes' => ['/api/category_types/'.$type->getId()],
            ],
        ]);
        self::assertSame(201, $response->getStatusCode());
        $createdId = (string) $response->toArray()['id'];

        $rows = $this->fetchAuditRows($createdId, 'create');
        self::assertCount(1, $rows, 'Un audit_log "create" doit etre genere apres POST.');
        self::assertSame('admin', $rows[0]['performed_by']);

        $changes = $this->decodeChanges($rows[0]['changes']);
        // Snapshot complet : au moins le name doit etre dedans.
        self::assertArrayHasKey('name', $changes);
        self::assertSame(
            self::TEST_CATEGORY_PREFIX.'audit_create',
            $changes['name'] ?? null,
            'Le snapshot create doit porter le name persiste.',
        );
    }

    public function testAuditLogOnUpdate(): void
    {
        $category = $this->createCategory();
        $client   = $this->createAdminClient();

        $client->request('PATCH', '/api/categories/'.$category->getId(), [
            'headers' => ['Content-Type' => 'application/merge-patch+json'],
            'json'    => ['name' => self::TEST_CATEGORY_PREFIX.'audit_patched'],
        ]);
        self::assertResponseIsSuccessful();

        $rows = $this->fetchAuditRows((string) $category->getId(), 'update');
        self::assertGreaterThanOrEqual(1, count($rows), 'Un audit_log "update" doit etre genere apres PATCH.');
        // On prend la ligne la plus recente.
        $latest = $rows[0];
        self::assertSame('admin', $latest['performed_by']);

        $changes = $this->decodeChanges($latest['changes']);
        // L'update doit contenir la diff sur `name` : {old: ..., new: 'audit_patched'}.
        self::assertArrayHasKey('name', $changes);
        self::assertIsArray($changes['name']);
        self::assertArrayHasKey('new', $changes['name']);
        self::assertSame(self::TEST_CATEGORY_PREFIX.'audit_patched', $changes['name']['new']);
    }

    public function testAuditLogOnSoftDelete(): void
    {
        $category = $this->createCategory();
        $client   = $this->createAdminClient();

        $client->request('DELETE', '/api/categories/'.$category->getId());
        self::assertResponseStatusCodeSame(204);

        // Le soft delete = UPDATE Doctrine -> action 'update' en audit, avec
        // la diff sur deletedAt (RG-1.12 + spec § 6.1).
        $rows = $this->fetchAuditRows((string) $category->getId(), 'update');
        self::assertGreaterThanOrEqual(1, count($rows), 'Un audit_log doit tracer le soft delete (en tant qu\'update).');
        $latest  = $rows[0];
        $changes = $this->decodeChanges($latest['changes']);

        self::assertArrayHasKey('deletedAt', $changes, 'La diff doit contenir deletedAt.');
        self::assertIsArray($changes['deletedAt']);
        self::assertArrayHasKey('new', $changes['deletedAt']);
        self::assertNotNull(
            $changes['deletedAt']['new'],
            'deletedAt.new doit etre rempli (timestamp ISO ou tableau Doctrine).',
        );
    }

    public function testAuditLogPerformerCarriesAuthenticatedUsername(): void
    {
        // Manage user (non-admin) : prouve que performed_by suit l'auth, pas
        // un mock hardcode "admin".
        $type            = $this->createCategoryType();
        $manage          = $this->createManageClient();
        $client          = $manage['client'];
        $managerUsername = $manage['credentials']['username'];

        $response = $client->request('POST', '/api/categories', [
            'headers' => ['Content-Type' => 'application/ld+json'],
            'json'    => [
                'name'         => self::TEST_CATEGORY_PREFIX.'audit_manager',
                'categoryTypes' => ['/api/category_types/'.$type->getId()],
            ],
        ]);
        self::assertSame(201, $response->getStatusCode());
        $createdId = (string) $response->toArray()['id'];

        $rows = $this->fetchAuditRows($createdId, 'create');
        self::assertCount(1, $rows);
        self::assertSame(
            $managerUsername,
            $rows[0]['performed_by'],
            'performed_by doit refleter le user authentifie (pas l\'admin par defaut).',
        );
    }

    /**
     * @param Category::class lookups via entity_id + action
     *
     * @return list<array{id: string, entity_type: string, entity_id: string, action: string, changes: string, performed_by: string}>
     */
    private function fetchAuditRows(string $entityId, string $action): array
    {
        /** @var list<array<string, string>> $rows */
        return $this->auditConnection->fetchAllAssociative(
            'SELECT id, entity_type, entity_id, action, changes, performed_by '
            .'FROM audit_log '
            .'WHERE entity_type = :type AND entity_id = :id AND action = :action '
            .'ORDER BY performed_at DESC',
            [
                'type'   => self::ENTITY_TYPE,
                'id'     => $entityId,
                'action' => $action,
            ],
        );
    }

    /**
     * @return array<string, mixed>
     */
    private function decodeChanges(string $raw): array
    {
        /** @var array<string, mixed> $decoded */
        return json_decode($raw, true, flags: JSON_THROW_ON_ERROR);
    }
}
