<?php

declare(strict_types=1);

namespace App\Tests\Module\Core\Infrastructure\Doctrine;

use App\Module\Core\Domain\Entity\Permission;
use App\Module\Core\Domain\Entity\Role;
use App\Module\Core\Domain\Entity\User;
use App\Module\Core\Infrastructure\Doctrine\AuditListener;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use ReflectionProperty;
use stdClass;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;

/**
 * Tests d'integration de l'AuditListener.
 *
 * Contrairement aux tests unitaires du writer, on fait tourner le kernel
 * complet pour verifier que le listener est bien cable et que les attributs
 * #[Auditable] / #[AuditIgnore] sur User sont respectes jusqu'a l'insert
 * final dans audit_log.
 *
 * Strategie de nettoyage : chaque test supprime ses fixtures dans tearDown
 * (pas de rollback transactionnel DAMA sur ce projet).
 *
 * @internal
 */
final class AuditListenerTest extends KernelTestCase
{
    private EntityManagerInterface $em;

    private Connection $auditConnection;

    /** @var list<int> IDs de users crees par le test (nettoyage en tearDown) */
    private array $createdUserIds = [];

    /** @var list<int> IDs de roles crees par le test (nettoyage en tearDown) */
    private array $createdRoleIds = [];

    private string $testRunTag;

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

        /** @var EntityManagerInterface $em */
        $em       = self::getContainer()->get('doctrine')->getManager();
        $this->em = $em;

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

        // Tag unique par run pour filtrer les lignes audit_log produites
        // exclusivement par ce test (la table n'a ni truncate ni rollback).
        $this->testRunTag = 'audittest'.bin2hex(random_bytes(4));
    }

    protected function tearDown(): void
    {
        // Suppression explicite des users crees (cascade sur user_role /
        // user_site via les ORM mappings) + nettoyage des lignes audit
        // correspondantes pour ne pas polluer les runs suivants.
        if ([] !== $this->createdUserIds) {
            foreach ($this->createdUserIds as $id) {
                $user = $this->em->find(User::class, $id);
                if (null !== $user) {
                    $this->em->remove($user);
                }
            }
            $this->em->flush();
        }

        if ([] !== $this->createdRoleIds) {
            foreach ($this->createdRoleIds as $id) {
                $role = $this->em->find(Role::class, $id);
                if (null !== $role) {
                    $this->em->remove($role);
                }
            }
            $this->em->flush();
            // Nettoie egalement les lignes audit de ces roles (entity_id est
            // une colonne text, on delete en boucle pour simplifier le binding).
            foreach ($this->createdRoleIds as $id) {
                $this->auditConnection->executeStatement(
                    'DELETE FROM audit_log WHERE entity_type = \'core.Role\' AND entity_id = :id',
                    ['id' => (string) $id],
                );
            }
        }

        $this->auditConnection->executeStatement(
            "DELETE FROM audit_log WHERE entity_type = 'core.User' AND changes->>'username' LIKE :tag",
            ['tag' => $this->testRunTag.'%'],
        );

        // Libere la connexion PG : en test, le kernel reboote par test et
        // sans close explicite, la connexion `audit` reste ouverte jusqu'au
        // TTL Doctrine, saturant le pool sur une suite de 200+ tests.
        $this->auditConnection->close();

        parent::tearDown();
    }

    public function testLogsCreateOnUserInsertion(): void
    {
        $user = $this->makeUser();

        $this->em->persist($user);
        $this->em->flush();
        $this->createdUserIds[] = $user->getId();

        $rows = $this->fetchAuditRows($user->getId());

        $this->assertCount(1, $rows, 'Une ligne audit attendue a la creation');
        $row = $rows[0];
        $this->assertSame('core.User', $row['entity_type']);
        $this->assertSame('create', $row['action']);
        $this->assertSame((string) $user->getId(), $row['entity_id']);

        $changes = json_decode($row['changes'], true, 512, JSON_THROW_ON_ERROR);
        $this->assertArrayHasKey('username', $changes);
        $this->assertArrayNotHasKey('password', $changes, 'password doit etre #[AuditIgnore]');
        $this->assertArrayNotHasKey('plainPassword', $changes, 'plainPassword doit etre #[AuditIgnore]');
    }

    public function testLogsUpdateWithDiff(): void
    {
        $user = $this->makeUser();
        $this->em->persist($user);
        $this->em->flush();
        $this->createdUserIds[] = $user->getId();

        // Reset de la baseline : on ne garde que la ligne update.
        $this->auditConnection->executeStatement(
            'DELETE FROM audit_log WHERE entity_id = :id AND entity_type = \'core.User\'',
            ['id' => (string) $user->getId()],
        );

        $user->setIsAdmin(true);
        $this->em->flush();

        $rows = $this->fetchAuditRows($user->getId());
        $this->assertCount(1, $rows);
        $this->assertSame('update', $rows[0]['action']);

        $changes = json_decode($rows[0]['changes'], true, 512, JSON_THROW_ON_ERROR);
        $this->assertArrayHasKey('isAdmin', $changes);
        $this->assertSame(false, $changes['isAdmin']['old']);
        $this->assertSame(true, $changes['isAdmin']['new']);
    }

    public function testLogsDeleteSnapshot(): void
    {
        $user = $this->makeUser();
        $this->em->persist($user);
        $this->em->flush();
        $userId = $user->getId();

        $this->em->remove($user);
        $this->em->flush();

        $rows = $this->fetchAuditRows($userId);
        // Deux lignes : la creation + la suppression.
        $actions = array_column($rows, 'action');
        $this->assertContains('delete', $actions);

        $deleteRow = $rows[array_search('delete', $actions, true)];
        $changes   = json_decode($deleteRow['changes'], true, 512, JSON_THROW_ON_ERROR);
        $this->assertArrayHasKey('username', $changes);
        $this->assertArrayNotHasKey('password', $changes);

        // On nettoie a la main les lignes restantes (user deja delete).
        $this->auditConnection->executeStatement(
            'DELETE FROM audit_log WHERE entity_id = :id AND entity_type = \'core.User\'',
            ['id' => (string) $userId],
        );
    }

    /**
     * Regression test : une entite recuperee via `getReference()` (proxy /
     * ghost object lazy) doit etre auditee avec le FQCN canonique. Sur
     * Doctrine ORM 3 + PHP 8.4, les lazy ghosts preservent `::class` reel
     * — mais sous Doctrine 2 ou en cas de retour a un `__CG__\` proxy,
     * l'audit doit toujours resoudre la classe via `ClassMetadata` et
     * jamais aboutir a un `entity_type` de type `Proxies\__CG__\...\User`.
     */
    public function testLogsUpdateOnProxyEntity(): void
    {
        $user = $this->makeUser();
        $this->em->persist($user);
        $this->em->flush();
        $userId                 = (int) $user->getId();
        $this->createdUserIds[] = $userId;

        // Detache puis recupere via getReference : sur Doctrine 2, renvoie
        // un `Proxies\__CG__\...\User` ; sur Doctrine 3 + PHP 8.4 le ghost
        // object reste instance de la classe reelle — dans tous les cas la
        // resolution via ClassMetadata doit produire un audit correct.
        $this->em->clear();

        $proxy = $this->em->getReference(User::class, $userId);
        self::assertNotNull($proxy);

        // Reset de la baseline : on ne garde que la ligne update du proxy.
        $this->auditConnection->executeStatement(
            'DELETE FROM audit_log WHERE entity_id = :id AND entity_type = \'core.User\'',
            ['id' => (string) $userId],
        );

        $proxy->setIsAdmin(true);
        $this->em->flush();

        $rows = $this->fetchAuditRows($userId);
        self::assertCount(1, $rows, 'La mutation sur un proxy doit etre auditee.');
        self::assertSame('update', $rows[0]['action']);
        // L'entity_type doit etre le FQCN canonique, pas celui du proxy.
        self::assertSame('core.User', $rows[0]['entity_type']);
    }

    /**
     * Verifie que l'ajout d'une permission a un role est bien audite sous
     * la forme `{permissions: {added: [id], removed: []}}`. Regression test
     * pour le bug "ManyToMany collections ignorees par getEntityChangeSet".
     */
    public function testLogsManyToManyCollectionAddition(): void
    {
        $roleCode = 'audittest_'.bin2hex(random_bytes(3));
        $role     = new Role($roleCode, 'Test role '.$roleCode);
        $this->em->persist($role);
        $this->em->flush();
        $roleId                 = (int) $role->getId();
        $this->createdRoleIds[] = $roleId;

        // Reset baseline : on ne veut que le log de l'update de collection.
        $this->auditConnection->executeStatement(
            'DELETE FROM audit_log WHERE entity_type = \'core.Role\' AND entity_id = :id',
            ['id' => (string) $roleId],
        );

        // Recupere une permission existante (fixtures garantissent core.users.view).
        $permission = $this->em->getRepository(Permission::class)->findOneBy(['code' => 'core.users.view']);
        self::assertNotNull($permission, 'Fixture core.users.view manquante.');

        $role->addPermission($permission);
        $this->em->flush();

        $rows = $this->fetchRoleAuditRows($roleId);
        self::assertCount(1, $rows, 'Une ligne update attendue pour l\'ajout de permission.');
        self::assertSame('update', $rows[0]['action']);

        $changes = json_decode($rows[0]['changes'], true, 512, JSON_THROW_ON_ERROR);
        self::assertArrayHasKey('permissions', $changes, 'Le changeset doit contenir le champ "permissions".');
        self::assertSame([], $changes['permissions']['removed']);
        self::assertSame([(int) $permission->getId()], $changes['permissions']['added']);
    }

    /**
     * Symetrique : retirer une permission d'un role est audite sous
     * `{permissions: {added: [], removed: [id]}}`.
     */
    public function testLogsManyToManyCollectionRemoval(): void
    {
        $permission = $this->em->getRepository(Permission::class)->findOneBy(['code' => 'core.users.view']);
        self::assertNotNull($permission);

        $roleCode = 'audittest_'.bin2hex(random_bytes(3));
        $role     = new Role($roleCode, 'Test role '.$roleCode);
        $role->addPermission($permission);
        $this->em->persist($role);
        $this->em->flush();
        $roleId                 = (int) $role->getId();
        $this->createdRoleIds[] = $roleId;

        // Reset baseline.
        $this->auditConnection->executeStatement(
            'DELETE FROM audit_log WHERE entity_type = \'core.Role\' AND entity_id = :id',
            ['id' => (string) $roleId],
        );

        $role->removePermission($permission);
        $this->em->flush();

        $rows = $this->fetchRoleAuditRows($roleId);
        self::assertCount(1, $rows);
        $changes = json_decode($rows[0]['changes'], true, 512, JSON_THROW_ON_ERROR);
        self::assertSame([], $changes['permissions']['added']);
        self::assertSame([(int) $permission->getId()], $changes['permissions']['removed']);
    }

    /**
     * Regression test : supprimer un role avec des permissions attachees doit
     * preserver la liste des permissions dans le snapshot delete. C'etait le
     * trou principal du fix ManyToMany initial (reviewer Codex round 2).
     */
    public function testDeleteSnapshotIncludesManyToManyIds(): void
    {
        $permission = $this->em->getRepository(Permission::class)->findOneBy(['code' => 'core.users.view']);
        self::assertNotNull($permission);

        $roleCode = 'audittest_'.bin2hex(random_bytes(3));
        $role     = new Role($roleCode, 'Delete test '.$roleCode);
        $role->addPermission($permission);
        $this->em->persist($role);
        $this->em->flush();
        $roleId = (int) $role->getId();

        $this->em->remove($role);
        $this->em->flush();

        $rows = $this->fetchRoleAuditRows($roleId);
        // create + update (permission ajoutee) + delete attendus.
        $actions = array_column($rows, 'action');
        self::assertContains('delete', $actions);

        $deleteRow = $rows[array_search('delete', $actions, true)];
        $changes   = json_decode($deleteRow['changes'], true, 512, JSON_THROW_ON_ERROR);

        // Le snapshot delete doit contenir la liste des IDs de permissions
        // attachees au role au moment de la suppression.
        self::assertArrayHasKey('permissions', $changes);
        self::assertSame([(int) $permission->getId()], $changes['permissions']);

        // Nettoyage manuel (le role est deja supprime, on ne peut plus passer par $this->em).
        $this->auditConnection->executeStatement(
            'DELETE FROM audit_log WHERE entity_type = \'core.Role\' AND entity_id = :id',
            ['id' => (string) $roleId],
        );
    }

    /**
     * Regression : quand un flush precedent a leve une exception avant
     * d'atteindre postFlush, `$pendingLogs` reste rempli avec des changements
     * jamais committes. Le flush suivant doit les ecraser, pas les fusionner —
     * sinon audit_log contient des lignes pour des evenements qui n'ont pas
     * eu lieu en base.
     *
     * Reproduction : on injecte manuellement une entree orpheline dans le
     * listener (comme si un flush precedent l'avait capturee puis avait plante),
     * on declenche un flush valide, et on verifie que l'orpheline n'apparait
     * jamais dans audit_log.
     */
    public function testOnFlushResetsStalePendingLogsFromFailedPreviousFlush(): void
    {
        /** @var AuditListener $listener */
        $listener = self::getContainer()->get(AuditListener::class);

        // Injecte une entree orpheline : comme si onFlush avait capture ce
        // changement, puis que le flush avait plante avant postFlush.
        $reflection = new ReflectionProperty($listener, 'pendingLogs');
        $reflection->setValue($listener, [[
            'entity'     => new stdClass(),
            'metadata'   => null,
            'entityType' => 'test.StaleEntity',
            'action'     => 'create',
            'changes'    => ['fake' => ['old' => null, 'new' => 'stale']],
            'capturedId' => 'stale-id-'.$this->testRunTag,
        ]]);

        // Flush valide qui DOIT re-initialiser pendingLogs avant de capturer
        // ses propres changements.
        $user = $this->makeUser();
        $this->em->persist($user);
        $this->em->flush();
        $this->createdUserIds[] = $user->getId();

        $staleRows = $this->auditConnection->fetchAllAssociative(
            'SELECT * FROM audit_log WHERE entity_type = :t',
            ['t' => 'test.StaleEntity'],
        );

        self::assertCount(
            0,
            $staleRows,
            'Une entree orpheline d\'un flush precedent ne doit pas fuiter dans audit_log.',
        );

        // Sanity : le vrai flush a quand meme bien ecrit sa propre ligne.
        $userRows = $this->fetchAuditRows($user->getId());
        self::assertCount(1, $userRows);
        self::assertSame('create', $userRows[0]['action']);
    }

    /**
     * @return list<array{id: string, entity_type: string, entity_id: string, action: string, changes: string}>
     */
    private function fetchAuditRows(int $userId): array
    {
        // @var list<array{id: string, entity_type: string, entity_id: string, action: string, changes: string}> $rows
        return $this->auditConnection->fetchAllAssociative(
            'SELECT id, entity_type, entity_id, action, changes FROM audit_log WHERE entity_type = :type AND entity_id = :id ORDER BY performed_at ASC',
            ['type' => 'core.User', 'id' => (string) $userId],
        );
    }

    private function makeUser(): User
    {
        /** @var UserPasswordHasherInterface $hasher */
        $hasher = self::getContainer()->get(UserPasswordHasherInterface::class);

        $user = new User();
        $user->setUsername($this->testRunTag.'_'.bin2hex(random_bytes(2)));
        $user->setIsAdmin(false);
        $user->setPassword($hasher->hashPassword($user, 'testpass'));

        return $user;
    }

    /**
     * @return list<array{id: string, entity_type: string, entity_id: string, action: string, changes: string}>
     */
    private function fetchRoleAuditRows(int $roleId): array
    {
        // @var list<array{id: string, entity_type: string, entity_id: string, action: string, changes: string}> $rows
        return $this->auditConnection->fetchAllAssociative(
            'SELECT id, entity_type, entity_id, action, changes FROM audit_log WHERE entity_type = :type AND entity_id = :id ORDER BY performed_at ASC',
            ['type' => 'core.Role', 'id' => (string) $roleId],
        );
    }
}
