<?php

declare(strict_types=1);

namespace App\EventSubscriber;

use App\Entity\AuditLog;
use App\Entity\Composant;
use App\Entity\CustomFieldValue;
use App\Entity\Machine;
use App\Entity\ModelType;
use App\Entity\Piece;
use App\Entity\Product;
use App\Entity\Site;
use App\Service\ActorProfileResolver;
use BackedEnum;
use DateTimeInterface;
use Doctrine\Common\Collections\Collection;
use Doctrine\Common\EventSubscriber;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityNotFoundException;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Events;
use Doctrine\ORM\UnitOfWork;
use Error;

use function is_array;
use function is_object;
use function is_scalar;
use function method_exists;

abstract class AbstractAuditSubscriber implements EventSubscriber
{
    public function __construct(
        protected readonly ActorProfileResolver $actorProfileResolver,
    ) {}

    public function getSubscribedEvents(): array
    {
        return [Events::onFlush];
    }

    public function onFlush(OnFlushEventArgs $args): void
    {
        $em = $args->getObjectManager();
        if (!$em instanceof EntityManagerInterface) {
            return;
        }

        $uow = $em->getUnitOfWork();

        // If any tracked entity has skipAudit=true, skip the entire subscriber.
        // This is set by EntityVersionService::restore() to avoid duplicate audit logs.
        foreach ($uow->getScheduledEntityUpdates() as $entity) {
            if ($this->supports($entity) && method_exists($entity, 'getSkipAudit') && $entity->getSkipAudit()) {
                return;
            }
        }

        $actorProfileId = $this->actorProfileResolver->resolve();
        $entityType     = $this->entityType();

        if ($this->hasCollectionTracking()) {
            $this->onFlushComplex($em, $uow, $actorProfileId, $entityType);
        } else {
            $this->onFlushSimple($em, $uow, $actorProfileId, $entityType);
        }
    }

    abstract protected function supports(object $entity): bool;

    abstract protected function entityType(): string;

    abstract protected function snapshotEntity(object $entity): array;

    /**
     * Override in subclasses that track custom field value changes.
     * Return the owner entity if the CFV belongs to the tracked entity type.
     */
    protected function getOwnerFromCustomFieldValue(CustomFieldValue $cfv): ?object
    {
        return null;
    }

    /**
     * Whether this subscriber uses the complex onFlush path (collection + custom field tracking).
     */
    protected function hasCollectionTracking(): bool
    {
        return false;
    }

    protected function persistAuditLog(EntityManagerInterface $em, AuditLog $log): void
    {
        $uow = $em->getUnitOfWork();
        $log->initializeAuditLog();
        $em->persist($log);

        $meta = $em->getClassMetadata(AuditLog::class);
        $uow->computeChangeSet($meta, $log);
    }

    /**
     * @param array<string, array{0:mixed, 1:mixed}> $changeSet
     *
     * @return array<string, array{from:mixed, to:mixed}>
     */
    protected function buildDiffFromChangeSet(array $changeSet): array
    {
        $diff = [];
        foreach ($changeSet as $field => [$oldValue, $newValue]) {
            if ('updatedAt' === $field || 'createdAt' === $field || 'version' === $field) {
                continue;
            }

            $normalizedOld = $this->normalizeValue($oldValue);
            $normalizedNew = $this->normalizeValue($newValue);

            if ($normalizedOld === $normalizedNew) {
                continue;
            }

            // Skip decimal formatting differences (e.g. "33.00" vs "33")
            if (is_numeric($normalizedOld) && is_numeric($normalizedNew) && (float) $normalizedOld === (float) $normalizedNew) {
                continue;
            }

            $diff[$field] = [
                'from' => $normalizedOld,
                'to'   => $normalizedNew,
            ];
        }

        return $diff;
    }

    /**
     * @param iterable<mixed> $items
     *
     * @return list<array{id: string, name: string}|string>
     */
    protected function normalizeCollection(iterable $items): array
    {
        $entries = [];
        $seen    = [];
        foreach ($items as $item) {
            if (is_object($item) && method_exists($item, 'getId')) {
                $id = $item->getId();
                if (null === $id || '' === $id || isset($seen[(string) $id])) {
                    continue;
                }
                $seen[(string) $id] = true;
                if (method_exists($item, 'getName')) {
                    $entries[] = ['id' => (string) $id, 'name' => (string) $item->getName()];
                } else {
                    $entries[] = (string) $id;
                }
            }
        }

        return $entries;
    }

    protected function safeGet(object $entity, string $method): mixed
    {
        try {
            return $entity->{$method}();
        } catch (Error) {
            return null;
        }
    }

    protected function normalizeValue(mixed $value): mixed
    {
        if (null === $value || is_scalar($value)) {
            return $value;
        }

        if ($value instanceof DateTimeInterface) {
            return $value->format(DateTimeInterface::ATOM);
        }

        if ($value instanceof BackedEnum) {
            return $value->value;
        }

        if ($value instanceof ModelType) {
            return [
                'id'   => $value->getId(),
                'name' => $value->getName(),
                'code' => $value->getCode(),
            ];
        }

        if ($value instanceof Product) {
            return [
                'id'        => $value->getId(),
                'name'      => $value->getName(),
                'reference' => $value->getReference(),
            ];
        }

        if ($value instanceof Site || $value instanceof Machine || $value instanceof Composant || $value instanceof Piece) {
            return [
                'id'   => $value->getId(),
                'name' => $value->getName(),
            ];
        }

        if ($value instanceof Collection) {
            return $this->normalizeCollection($value);
        }

        if (is_object($value) && method_exists($value, 'getId')) {
            return (string) $value->getId();
        }

        if (is_array($value)) {
            return $value;
        }

        return (string) $value;
    }

    /**
     * @param array<string, array{from:mixed, to:mixed}> $base
     * @param array<string, array{from:mixed, to:mixed}> $extra
     *
     * @return array<string, array{from:mixed, to:mixed}>
     */
    protected function mergeDiffs(array $base, array $extra): array
    {
        foreach ($extra as $field => $change) {
            $base[$field] = $change;
        }

        return $base;
    }

    /**
     * If the entity has a version, increment it and return the new value.
     * Recomputes the changeset so Doctrine picks up the version bump.
     */
    protected function incrementEntityVersion(object $entity, EntityManagerInterface $em, UnitOfWork $uow): ?int
    {
        if (!method_exists($entity, 'incrementVersion') || !method_exists($entity, 'getVersion')) {
            return null;
        }

        // If the version was already changed (e.g. by a sync strategy), don't double-increment
        $changeSet = $uow->getEntityChangeSet($entity);
        if (isset($changeSet['version'])) {
            return $entity->getVersion();
        }

        $entity->incrementVersion();
        $uow->recomputeSingleEntityChangeSet(
            $em->getClassMetadata($entity::class),
            $entity,
        );

        return $entity->getVersion();
    }

    /**
     * Get the current version without incrementing (for create actions).
     */
    protected function getEntityVersion(object $entity): ?int
    {
        if (!method_exists($entity, 'getVersion')) {
            return null;
        }

        return $entity->getVersion();
    }

    private function onFlushSimple(EntityManagerInterface $em, UnitOfWork $uow, ?string $actorProfileId, string $entityType): void
    {
        foreach ($uow->getScheduledEntityInsertions() as $entity) {
            if (!$this->supports($entity)) {
                continue;
            }

            $diff     = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
            $snapshot = $this->snapshotEntity($entity);
            $version  = $this->getEntityVersion($entity);
            $this->persistAuditLog($em, new AuditLog($entityType, (string) $entity->getId(), 'create', $diff, $snapshot, $actorProfileId, $version));
        }

        foreach ($uow->getScheduledEntityUpdates() as $entity) {
            if (!$this->supports($entity)) {
                continue;
            }

            $id = (string) $entity->getId();
            if ('' === $id) {
                continue;
            }

            $diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
            if ([] !== $diff) {
                $version  = $this->incrementEntityVersion($entity, $em, $uow);
                $snapshot = $this->snapshotEntity($entity);
                $this->persistAuditLog($em, new AuditLog($entityType, $id, 'update', $diff, $snapshot, $actorProfileId, $version));
            }
        }

        foreach ($uow->getScheduledEntityDeletions() as $entity) {
            if (!$this->supports($entity)) {
                continue;
            }

            $snapshot = $this->snapshotEntity($entity);
            $this->persistAuditLog($em, new AuditLog($entityType, (string) $entity->getId(), 'delete', null, $snapshot, $actorProfileId));
        }
    }

    private function onFlushComplex(EntityManagerInterface $em, UnitOfWork $uow, ?string $actorProfileId, string $entityType): void
    {
        $pendingUpdates   = [];
        $pendingSnapshots = [];
        $pendingEntities  = [];

        foreach ($uow->getScheduledEntityInsertions() as $entity) {
            if (!$this->supports($entity)) {
                continue;
            }

            $diff     = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
            $snapshot = $this->snapshotEntity($entity);
            $version  = $this->getEntityVersion($entity);
            $this->persistAuditLog($em, new AuditLog($entityType, (string) $entity->getId(), 'create', $diff, $snapshot, $actorProfileId, $version));
        }

        foreach ($uow->getScheduledEntityUpdates() as $entity) {
            if (!$this->supports($entity)) {
                continue;
            }

            $entityId = (string) $entity->getId();
            if ('' === $entityId) {
                continue;
            }

            $diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
            if ([] !== $diff) {
                $pendingUpdates[$entityId]   = $this->mergeDiffs($pendingUpdates[$entityId] ?? [], $diff);
                $pendingSnapshots[$entityId] = $this->snapshotEntity($entity);
                $pendingEntities[$entityId]  = $entity;
            }
        }

        foreach ($uow->getScheduledEntityDeletions() as $entity) {
            if (!$this->supports($entity)) {
                continue;
            }

            $snapshot = $this->snapshotEntity($entity);
            $this->persistAuditLog($em, new AuditLog($entityType, (string) $entity->getId(), 'delete', null, $snapshot, $actorProfileId));
        }

        // Note: scheduled collection updates/deletions are intentionally not
        // tracked here — constructeurs are now persisted as ConstructeurLink
        // entities (OneToMany), so Doctrine no longer fires collection events
        // for them. Custom field values are handled below.
        $this->collectCustomFieldValueChanges($uow, $pendingUpdates, $pendingSnapshots, $pendingEntities);

        foreach ($pendingUpdates as $entityId => $diff) {
            if ([] === $diff) {
                continue;
            }

            $entity = $pendingEntities[$entityId] ?? null;
            if (null === $entity || !$this->supports($entity)) {
                continue;
            }

            $version = $this->incrementEntityVersion($entity, $em, $uow);
            // Re-take snapshot after version increment so it captures the new version number
            $snapshot = $this->snapshotEntity($entity);
            $this->persistAuditLog($em, new AuditLog($entityType, $entityId, 'update', $diff, $snapshot, $actorProfileId, $version));
        }
    }

    private function collectCustomFieldValueChanges(
        UnitOfWork $uow,
        array &$pendingUpdates,
        array &$pendingSnapshots,
        array &$pendingEntities,
    ): void {
        foreach ($uow->getScheduledEntityInsertions() as $entity) {
            if ($entity instanceof CustomFieldValue) {
                $this->trackCustomFieldValueChange($entity, null, $entity->getValue(), $pendingUpdates, $pendingSnapshots, $pendingEntities);
            }
        }

        foreach ($uow->getScheduledEntityUpdates() as $entity) {
            if (!$entity instanceof CustomFieldValue) {
                continue;
            }
            $changeSet = $uow->getEntityChangeSet($entity);
            if (!isset($changeSet['value'])) {
                continue;
            }
            [$oldVal, $newVal] = $changeSet['value'];
            if ($oldVal !== $newVal) {
                $this->trackCustomFieldValueChange($entity, $oldVal, $newVal, $pendingUpdates, $pendingSnapshots, $pendingEntities);
            }
        }

        foreach ($uow->getScheduledEntityDeletions() as $entity) {
            if ($entity instanceof CustomFieldValue) {
                $this->trackCustomFieldValueChange($entity, $entity->getValue(), null, $pendingUpdates, $pendingSnapshots, $pendingEntities);
            }
        }
    }

    private function trackCustomFieldValueChange(
        CustomFieldValue $cfv,
        mixed $from,
        mixed $to,
        array &$pendingUpdates,
        array &$pendingSnapshots,
        array &$pendingEntities,
    ): void {
        $owner = $this->getOwnerFromCustomFieldValue($cfv);
        if (null === $owner) {
            return;
        }

        $ownerId = (string) $owner->getId();
        if ('' === $ownerId) {
            return;
        }

        try {
            $cfName = $cfv->getCustomField()->getName();
        } catch (EntityNotFoundException) {
            return;
        }
        $fieldName = 'customField:'.$cfName;
        $diff      = [$fieldName => ['from' => $from, 'to' => $to]];

        $pendingUpdates[$ownerId]   = $this->mergeDiffs($pendingUpdates[$ownerId] ?? [], $diff);
        $pendingSnapshots[$ownerId] = $this->snapshotEntity($owner);
        $pendingEntities[$ownerId]  = $owner;
    }
}
