<?php

declare(strict_types=1);

namespace App\Module\Core\Infrastructure\Doctrine;

use App\Module\Core\Infrastructure\Audit\AuditLogWriter;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Attribute\AuditIgnore;
use DateTimeInterface;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Event\PostFlushEventArgs;
use Doctrine\ORM\Events;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\PersistentCollection;
use Doctrine\ORM\UnitOfWork;
use Psr\Log\LoggerInterface;
use ReflectionClass;
use ReflectionProperty;
use Throwable;

/**
 * Listener Doctrine qui produit les lignes d'audit pour les entites portant
 * l'attribut #[Auditable].
 *
 * Pipeline en deux temps :
 *  1. onFlush : on traverse UnitOfWork (insertions / updates / deletions) et
 *     on capture les changements en memoire. Aucune ecriture SQL cote audit
 *     a ce stade pour ne pas interferer avec la transaction ORM en cours.
 *  2. postFlush : on ecrit via AuditLogWriter (connexion DBAL dediee).
 *
 * Pattern swap-and-clear dans postFlush :
 *  - on copie localement la liste des evenements ;
 *  - on vide la propriete pendingLogs immediatement ;
 *  - on itere la copie.
 * Pourquoi : si une ecriture audit declenchait un flush re-entrant (cas rare,
 * ex: callback listener externe), l'etat de pendingLogs serait deja nettoye —
 * pas de double insertion, pas de boucle infinie.
 *
 * Erreurs silencieuses : un INSERT audit qui echoue est logue en error mais
 * jamais propage. Acceptable pour un CRM interne ; a reconsiderer si besoin
 * de garantie forte (dead-letter queue, retry).
 *
 * Collections (OneToMany / ManyToMany) :
 *  - Les modifications de collections sont tracees via
 *    `getScheduledCollectionUpdates()` et reportees comme un changement
 *    `{fieldName: {added: [ids], removed: [ids]}}` dans le changeset de
 *    l'entite proprietaire.
 *  - Si l'entite proprietaire est deja scheduled pour insertion, la diff
 *    est merge dans le snapshot create (en tant que liste d'IDs initiaux).
 *  - Si l'entite proprietaire est scheduled pour deletion, les collections
 *    associees sont ignorees (deja couvertes par le snapshot delete).
 *
 * Limitations connues :
 *  - Les ManyToOne sont tracees par ID (null-safe via `?->getId()`).
 *  - Les DELETE / UPDATE bulk DQL et les `Connection::executeStatement()`
 *    bruts BYPASSENT le listener : onFlush n'est jamais appele. Toute
 *    operation de purge/nettoyage qui doit etre auditee doit passer par
 *    `EntityManager::remove()` + `flush()`. Si un futur batch (ex: commande
 *    "purger users inactifs") utilise du DQL bulk, les suppressions ne
 *    seront pas dans `audit_log` — choix d'architecture explicite a faire.
 */
#[AsDoctrineListener(event: Events::onFlush)]
#[AsDoctrineListener(event: Events::postFlush)]
final class AuditListener
{
    /**
     * Cache par FQCN : true si la classe porte #[Auditable], false sinon.
     * Evite une ReflectionClass par entite a chaque flush.
     *
     * @var array<class-string, bool>
     */
    private array $auditableCache = [];

    /**
     * Cache par FQCN : liste des noms de proprietes ignorees (#[AuditIgnore]).
     *
     * @var array<class-string, list<string>>
     */
    private array $ignoredPropertiesCache = [];

    /**
     * Logs en attente d'ecriture (remplis en onFlush, consommes en postFlush).
     *
     * Pour les inserts, l'ID est assignee DURANT le flush : on capture la
     * reference de l'entite et on resout l'ID au moment du postFlush.
     *
     * @var list<array{entity: object, metadata: ClassMetadata, entityType: string, action: string, changes: array<string, mixed>, capturedId: ?string}>
     */
    private array $pendingLogs = [];

    public function __construct(
        private readonly AuditLogWriter $writer,
        private readonly LoggerInterface $logger,
    ) {}

    public function onFlush(OnFlushEventArgs $args): void
    {
        /** @var EntityManagerInterface $em */
        $em  = $args->getObjectManager();
        $uow = $em->getUnitOfWork();

        // Reset defensif en debut de cycle : si un flush precedent a leve une
        // exception, Doctrine n'appelle PAS postFlush et pendingLogs reste
        // rempli avec des changements jamais committes. Sans ce reset, un
        // flush ulterieur reussi ecrirait les fausses entrees dans audit_log.
        // Le swap-and-clear dans postFlush couvre deja les flushes re-entrants,
        // ce reset ne le fragilise donc pas.
        $this->pendingLogs = [];

        foreach ($uow->getScheduledEntityInsertions() as $entity) {
            $this->capturePendingLog($entity, $em, $uow, 'create');
        }

        foreach ($uow->getScheduledEntityUpdates() as $entity) {
            $this->capturePendingLog($entity, $em, $uow, 'update');
        }

        foreach ($uow->getScheduledEntityDeletions() as $entity) {
            $this->capturePendingLog($entity, $em, $uow, 'delete');
        }

        // Collections to-many (OneToMany / ManyToMany) : `getEntityChangeSet()`
        // ne les expose pas, il faut interroger `UnitOfWork` separement. On
        // merge la diff dans le log de l'entite proprietaire si elle est deja
        // scheduled, sinon on cree une entree "update" dediee.
        foreach ($uow->getScheduledCollectionUpdates() as $collection) {
            $this->captureCollectionChange($collection, $em, cleared: false);
        }

        foreach ($uow->getScheduledCollectionDeletions() as $collection) {
            $this->captureCollectionChange($collection, $em, cleared: true);
        }
    }

    public function postFlush(PostFlushEventArgs $args): void
    {
        // Swap-and-clear : protege d'un flush re-entrant (aucune double
        // insertion meme si un callback utilisateur re-declenche un flush).
        $logs              = $this->pendingLogs;
        $this->pendingLogs = [];

        foreach ($logs as $log) {
            // Pour les inserts, l'ID n'etait pas encore disponible en onFlush :
            // on la resout maintenant (Doctrine l'a hydratee pendant le flush).
            $entityId = $log['capturedId'] ?? $this->resolveEntityId($log['entity'], $log['metadata']);

            if (null === $entityId) {
                $this->logger->warning(
                    'AuditListener : impossible de resoudre l\'ID de l\'entite apres flush, entree ignoree',
                    ['entityType' => $log['entityType'], 'action' => $log['action']]
                );

                continue;
            }

            try {
                $this->writer->log(
                    $log['entityType'],
                    $entityId,
                    $log['action'],
                    $log['changes'],
                );
            } catch (Throwable $e) {
                // Erreur audit : logue mais ne crashe jamais le flux metier.
                $this->logger->error(
                    'Echec d\'ecriture audit_log',
                    [
                        'exception'  => $e,
                        'entityType' => $log['entityType'],
                        'entityId'   => $entityId,
                        'action'     => $log['action'],
                    ]
                );
            }
        }
    }

    private function capturePendingLog(object $entity, EntityManagerInterface $em, UnitOfWork $uow, string $action): void
    {
        // Resolution via ClassMetadata : `$entity::class` renvoie le FQCN du
        // proxy Doctrine pour une entite chargee en lazy (ex:
        // `Proxies\__CG__\App\Module\Core\Domain\Entity\User`) — `isAuditable()`
        // le verrait comme non-auditable car `#[Auditable]` n'est declare que
        // sur la classe parente.
        $metadata = $em->getClassMetadata($entity::class);
        $class    = $metadata->getName();

        if (!$this->isAuditable($class)) {
            return;
        }

        // Sur `delete`, on inclut aussi les collections to-many dans le
        // snapshot : c'est la derniere occasion de capturer l'etat complet
        // (ex: quelles permissions etaient rattachees au role supprime).
        // Sur `create`, les collections initiales sont rapportees via
        // captureCollectionChange quand l'entite est scheduled avec un
        // collection update dans le meme flush.
        $changes = match ($action) {
            'update' => $this->buildUpdateChanges($entity, $uow, $class),
            'create' => $this->buildSnapshot($entity, $metadata, $class, includeCollections: false),
            'delete' => $this->buildSnapshot($entity, $metadata, $class, includeCollections: true),
            default  => [],
        };

        if ('update' === $action && [] === $changes) {
            // Flush sans changement reel sur une entite auditable : on n'emet pas.
            return;
        }

        // Pour delete/update, l'ID est deja set en onFlush — on la capture
        // maintenant (apres postFlush, l'entite detachee peut perdre sa ref
        // dans l'identity map). Pour create (IDENTITY), l'ID est generee par
        // le flush — on differe a postFlush.
        $capturedId = 'create' === $action ? null : $this->resolveEntityId($entity, $metadata);

        $this->pendingLogs[] = [
            'entity'     => $entity,
            'metadata'   => $metadata,
            'entityType' => $this->formatEntityType($class),
            'action'     => $action,
            'changes'    => $changes,
            'capturedId' => $capturedId,
        ];
    }

    /**
     * Capture la modification d'une collection to-many.
     *
     * Strategie de merge :
     *  - Si l'entite proprietaire est deja scheduled pour `delete` → ignore
     *    (redondant avec le snapshot delete deja produit).
     *  - Si l'entite est deja scheduled pour `create` → on ajoute le champ
     *    collection au snapshot initial, sous forme de liste d'IDs ajoutes.
     *  - Si l'entite est deja scheduled pour `update` → on merge la diff
     *    {added, removed} dans le changeset existant.
     *  - Sinon → on cree une nouvelle entree `update` dediee pour l'entite
     *    proprietaire (cas d'une collection modifiee sans autre changement
     *    sur l'entite elle-meme, ex : ajout d'une permission a un role).
     *
     * @param bool $cleared true si la collection entiere est supprimee
     *                      (getScheduledCollectionDeletions) — tous les
     *                      items du snapshot sont consideres comme retires
     */
    private function captureCollectionChange(PersistentCollection $collection, EntityManagerInterface $em, bool $cleared): void
    {
        $owner = $collection->getOwner();
        if (null === $owner) {
            return;
        }

        // Voir capturePendingLog : meme contournement proxy Doctrine.
        $class = $em->getClassMetadata($owner::class)->getName();
        if (!$this->isAuditable($class)) {
            return;
        }

        $fieldName = $collection->getMapping()->fieldName;
        if (in_array($fieldName, $this->getIgnoredProperties($class), true)) {
            return;
        }

        if ($cleared) {
            $added   = [];
            $removed = array_map(
                fn ($item): mixed => $this->normalizeValue($item),
                $collection->getSnapshot(),
            );
        } else {
            $added = array_map(
                fn ($item): mixed => $this->normalizeValue($item),
                $collection->getInsertDiff(),
            );
            $removed = array_map(
                fn ($item): mixed => $this->normalizeValue($item),
                $collection->getDeleteDiff(),
            );
        }

        if ([] === $added && [] === $removed) {
            return;
        }

        // Chercher un log deja en attente pour cette entite, pour merger la
        // diff au lieu de creer une entree d'audit redondante.
        foreach ($this->pendingLogs as $idx => $log) {
            if ($log['entity'] !== $owner) {
                continue;
            }

            if ('delete' === $log['action']) {
                // Deletion de l'entite : la collection suit mecaniquement,
                // pas d'entree dediee (le snapshot delete contient deja
                // l'etat a supprimer).
                return;
            }

            if ('create' === $log['action']) {
                // Insertion : le snapshot create ne contient pas les
                // collections (buildSnapshot ignore les to-many). On ajoute
                // donc la liste des items initiaux comme IDs, pour avoir
                // une trace complete de l'etat a la creation. array_values
                // garantit un array JSON (pas un objet) si les cles du diff
                // ne sont pas sequentielles.
                $this->pendingLogs[$idx]['changes'][$fieldName] = array_values($added);

                return;
            }

            // Update : on merge dans le changeset existant.
            $this->pendingLogs[$idx]['changes'][$fieldName] = [
                'added'   => array_values($added),
                'removed' => array_values($removed),
            ];

            return;
        }

        // Aucun log existant : l'entite n'a eu QUE des changements de
        // collection. On cree une entree update minimale.
        $metadata = $em->getClassMetadata($class);

        $this->pendingLogs[] = [
            'entity'     => $owner,
            'metadata'   => $metadata,
            'entityType' => $this->formatEntityType($class),
            'action'     => 'update',
            'changes'    => [$fieldName => [
                'added'   => array_values($added),
                'removed' => array_values($removed),
            ]],
            'capturedId' => $this->resolveEntityId($owner, $metadata),
        ];
    }

    /**
     * Build du changeset "update" : {champ: {old, new}} a partir de
     * `UnitOfWork::getEntityChangeSet()`. ManyToOne : on log l'ID,
     * null-safe via `?->getId()`.
     *
     * @return array<string, array{old: mixed, new: mixed}>
     */
    private function buildUpdateChanges(object $entity, UnitOfWork $uow, string $class): array
    {
        $changeSet       = $uow->getEntityChangeSet($entity);
        $ignored         = $this->getIgnoredProperties($class);
        $filteredChanges = [];

        foreach ($changeSet as $field => [$oldValue, $newValue]) {
            if (in_array($field, $ignored, true)) {
                continue;
            }

            $filteredChanges[$field] = [
                'old' => $this->normalizeValue($oldValue),
                'new' => $this->normalizeValue($newValue),
            ];
        }

        return $filteredChanges;
    }

    /**
     * Build d'un snapshot complet (create / delete) : lit toutes les
     * proprietes non-ignorees via Reflection.
     *
     * @param bool $includeCollections si true, les associations to-many sont
     *                                 aussi snapshotees (liste d'IDs). Utilise
     *                                 uniquement sur `delete` pour preserver
     *                                 l'etat des relations au moment de la
     *                                 suppression. En create, on laisse
     *                                 captureCollectionChange enrichir le
     *                                 snapshot si une collection est modifiee
     *                                 dans le meme flush.
     *
     * @return array<string, mixed>
     */
    private function buildSnapshot(object $entity, ClassMetadata $metadata, string $class, bool $includeCollections): array
    {
        $ignored  = $this->getIgnoredProperties($class);
        $snapshot = [];

        foreach ($metadata->getFieldNames() as $field) {
            if (in_array($field, $ignored, true)) {
                continue;
            }

            $snapshot[$field] = $this->normalizeValue($metadata->getFieldValue($entity, $field));
        }

        foreach ($metadata->getAssociationNames() as $assoc) {
            if (in_array($assoc, $ignored, true)) {
                continue;
            }

            if ($metadata->isSingleValuedAssociation($assoc)) {
                $related          = $metadata->getFieldValue($entity, $assoc);
                $snapshot[$assoc] = null !== $related && method_exists($related, 'getId')
                    ? $related->getId()
                    : null;

                continue;
            }

            if (!$includeCollections) {
                continue;
            }

            // Collection to-many : snapshot = liste d'IDs. On itere la
            // Collection (PersistentCollection ou ArrayCollection) pour
            // obtenir les elements. Pour un delete, la collection est deja
            // chargee (Doctrine en a besoin pour les cascades).
            $collection = $metadata->getFieldValue($entity, $assoc);
            if (!is_iterable($collection)) {
                continue;
            }
            $ids = [];
            foreach ($collection as $item) {
                $ids[] = $this->normalizeValue($item);
            }
            $snapshot[$assoc] = $ids;
        }

        return $snapshot;
    }

    private function isAuditable(string $class): bool
    {
        if (array_key_exists($class, $this->auditableCache)) {
            return $this->auditableCache[$class];
        }

        $reflection                   = new ReflectionClass($class);
        $isAuditable                  = [] !== $reflection->getAttributes(Auditable::class);
        $this->auditableCache[$class] = $isAuditable;

        return $isAuditable;
    }

    /**
     * @return list<string>
     */
    private function getIgnoredProperties(string $class): array
    {
        if (array_key_exists($class, $this->ignoredPropertiesCache)) {
            return $this->ignoredPropertiesCache[$class];
        }

        $ignored    = [];
        $reflection = new ReflectionClass($class);

        foreach ($reflection->getProperties(ReflectionProperty::IS_PROTECTED | ReflectionProperty::IS_PRIVATE | ReflectionProperty::IS_PUBLIC) as $property) {
            if ([] !== $property->getAttributes(AuditIgnore::class)) {
                $ignored[] = $property->getName();
            }
        }

        $this->ignoredPropertiesCache[$class] = $ignored;

        return $ignored;
    }

    /**
     * Transforme un FQCN `App\Module\Core\Domain\Entity\User` en `core.User`.
     *
     * Format `module.Entity` pour eviter les collisions inter-modules.
     */
    private function formatEntityType(string $class): string
    {
        if (1 === preg_match('#^App\\\Module\\\(?<module>[^\\\]+)\\\.+\\\(?<entity>[^\\\]+)$#', $class, $matches)) {
            return strtolower($matches['module']).'.'.$matches['entity'];
        }

        // Fallback : on retourne le FQCN complet si la regex ne matche pas
        // (entite hors structure modulaire — ne devrait pas arriver).
        return $class;
    }

    private function resolveEntityId(object $entity, ClassMetadata $metadata): ?string
    {
        $identifier = $metadata->getIdentifierValues($entity);
        if ([] === $identifier) {
            return null;
        }

        // Cle composee : on concatene les valeurs. Cas rare sur le projet.
        return implode('-', array_map(static fn ($v) => (string) $v, $identifier));
    }

    /**
     * Normalise une valeur pour encodage JSON stable.
     */
    private function normalizeValue(mixed $value): mixed
    {
        if ($value instanceof DateTimeInterface) {
            return $value->format(DateTimeInterface::ATOM);
        }

        if (is_object($value)) {
            // Relation to-one non parsee par buildSnapshot (cas update sur
            // un champ qui devient un objet) : on tente getId() si possible.
            if (method_exists($value, 'getId')) {
                return $value->getId();
            }

            return (string) $value;
        }

        return $value;
    }
}
