<?php

declare(strict_types=1);

namespace App\Module\Transport\Infrastructure\ApiPlatform\State\Processor;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\Transport\Application\Service\CarrierFieldNormalizer;
use App\Module\Transport\Domain\Entity\Carrier;
use DateTimeImmutable;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\ORM\EntityManagerInterface;
use JsonException;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;

/**
 * Processor d'ecriture du repertoire transporteurs (M4, formulaire principal). Cf.
 * spec-back M4 § 4.3 / § 4.4 + RG-4.12 / RG-4.13 / RG-4.14. Jumeau du
 * SupplierProcessor (M2), recentre sur le perimetre WT4 (formulaire principal :
 * normalisation, gating archive, 409 doublon de nom).
 *
 * Sequence (POST / PATCH) :
 *  1. Gating archive (mode strict RG-4.14). La security d'operation laisse entrer
 *     `transport.carriers.manage` (Admin + Bureau) ; ce processor re-gate
 *     finement : un payload basculant `isArchived` exige `transport.carriers.archive`
 *     (Admin seul) -> 403, et une requete d'archivage ne peut modifier aucun autre
 *     champ -> 422. C'est ce qui empeche Bureau d'archiver (manage sans archive).
 *  2. Normalisation serveur (RG-4.13) via CarrierFieldNormalizer (name UPPER,
 *     liotPlates « ; »-normalise). Les champs personne / telephone / email sont
 *     portes par les sous-ressources Contact (WT7), pas par le formulaire principal.
 *  3. Pose / retrait de archivedAt (RG-4.14 true=now, false=null).
 *  4. Persistance via le persist_processor Doctrine, avec traduction des
 *     collisions d'unicite partielle (uq_carrier_name_active) en 409 (RG-4.12
 *     doublon de nom ; conflit de restauration).
 *
 * Les RG conditionnelles du formulaire principal (RG-4.01 certification obligatoire
 * sauf LIOT, RG-4.02 AUTRE -> decharge, RG-4.03 affrete -> indexation/benne/volume)
 * sont portees par un Assert\Callback + ->atPath() sur l'entite Carrier (joue par
 * API Platform AVANT ce processor), pour que chaque 422 porte un propertyPath
 * consommable par useFormErrors (mapping inline, pas un toast — convention ERP-101).
 *
 * @implements ProcessorInterface<Carrier, Carrier>
 */
final class CarrierProcessor implements ProcessorInterface
{
    /** Champs ecrivables du formulaire principal (groupe carrier:write:main). */
    private const array MAIN_FIELDS = [
        'name', 'qualimatCarrier', 'certificationType', 'isChartered',
        'indexationRate', 'containerType', 'volumeM3', 'dischargeDocument', 'liotPlates',
    ];

    /** Champ d'archivage (groupe carrier:write:archive). */
    private const string ARCHIVE_FIELD = 'isArchived';

    private const string PERM_ARCHIVE = 'transport.carriers.archive';

    /**
     * Memoisation du dernier corps de requete decode, clos par le contenu brut.
     * payloadKeys() est appele plusieurs fois par requete : on evite de rejouer
     * json_decode. La cle etant le contenu lui-meme et le calcul une fonction pure
     * de ce contenu, aucune fuite n'est possible entre requetes sur ce service
     * partage (un meme corps redonne les memes cles).
     */
    private ?string $decodedContent = null;

    /** @var list<string> Cles de premier niveau correspondant au corps memoise. */
    private array $decodedPayloadKeys = [];

    public function __construct(
        #[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
        private readonly ProcessorInterface $persistProcessor,
        private readonly CarrierFieldNormalizer $normalizer,
        private readonly Security $security,
        private readonly RequestStack $requestStack,
        private readonly EntityManagerInterface $em,
    ) {}

    public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
    {
        if (!$data instanceof Carrier) {
            return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
        }

        // Reinitialisation de la memoisation du payload en debut de traitement :
        // le service est partage (stateful), on repart du corps de LA requete
        // courante et on n'herite jamais des cles decodees d'une requete passee.
        $this->decodedContent     = null;
        $this->decodedPayloadKeys = [];

        $isArchiveRequest = $this->guardArchive($data, $this->writablePayloadKeys());

        $this->normalize($data);

        try {
            return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
        } catch (UniqueConstraintViolationException $e) {
            // Le seul index unique partiel est uq_carrier_name_active
            // (LOWER(name) parmi non-archives/non-deletes — § 2.6).
            if ($isArchiveRequest && false === $data->isArchived()) {
                // RG-4.14 : restauration en conflit avec un homonyme actif.
                throw new ConflictHttpException(
                    'Restauration impossible : un autre transporteur a pris le nom entre-temps.',
                    $e,
                );
            }

            // RG-4.12 : doublon de nom de transporteur.
            throw new ConflictHttpException(
                sprintf('Un transporteur nommé "%s" existe déjà.', (string) $data->getName()),
                $e,
            );
        }
    }

    /**
     * RG-4.14 : si le payload bascule reellement isArchived, exige la permission
     * archive (403), interdit toute autre modification (422) et pose/retire
     * archivedAt. Retourne true si la requete est une requete d'archivage.
     *
     * Le gating est restreint a la mise a jour d'un transporteur existant ET au
     * seul cas ou isArchived change vraiment : un POST (entite non encore geree
     * par l'ORM) ou un PATCH « representation complete » renvoyant isArchived
     * inchange ne doit declencher ni 403 ni 422 parasite.
     *
     * @param list<string> $writableKeys cles ecrivables du payload (hors @* et champs inconnus)
     */
    private function guardArchive(Carrier $data, array $writableKeys): bool
    {
        // POST / entite non geree : l'archivage est une action de mise a jour.
        if (!$this->em->contains($data)) {
            return false;
        }

        // isArchived inchange par rapport a l'etat persiste : pas une requete
        // d'archivage (cas du PATCH representation complete).
        if (!$this->fieldChanged($data, 'isArchived', $data->isArchived())) {
            return false;
        }

        if (!$this->security->isGranted(self::PERM_ARCHIVE)) {
            throw new AccessDeniedHttpException(sprintf(
                'Le champ "%s" requiert la permission "%s".',
                self::ARCHIVE_FIELD,
                self::PERM_ARCHIVE,
            ));
        }

        // RG-4.14 : une requete d'archivage ne modifie aucun autre champ ecrivable.
        if ([] !== array_diff($writableKeys, [self::ARCHIVE_FIELD])) {
            throw new UnprocessableEntityHttpException(
                'Une requête d\'archivage ne peut modifier aucun autre champ que "isArchived".',
            );
        }

        // RG-4.14 (true -> now) / (false -> null).
        $data->setArchivedAt($data->isArchived() ? new DateTimeImmutable() : null);

        return true;
    }

    /**
     * Normalisation serveur du formulaire principal (RG-4.13). name (non-nullable)
     * et liotPlates (cas LIOT) sont les seuls champs texte du formulaire principal ;
     * le contact (personne / telephone / email) est normalise par son propre
     * processor (sous-ressource, WT7). Les setters ne sont touches que si une valeur
     * est presente, pour ne jamais ecraser l'existant lors d'un PATCH partiel.
     */
    private function normalize(Carrier $data): void
    {
        if (null !== $data->getName()) {
            $data->setName((string) $this->normalizer->normalizeName($data->getName()));
        }

        if (null !== $data->getLiotPlates()) {
            $data->setLiotPlates($this->normalizer->normalizeLiotPlates($data->getLiotPlates()));
        }
    }

    /**
     * Cles ecrivables effectivement presentes dans le payload : on retire les cles
     * JSON-LD (@id, @context...) et tout champ non rattache a un groupe d'ecriture
     * connu. C'est la base du 422 d'archivage (RG-4.14) — sans elles, un PATCH
     * « representation complete » porteur de @id ferait croire a une modification
     * multi-champs.
     *
     * @return list<string>
     */
    private function writablePayloadKeys(): array
    {
        $writable = array_merge(self::MAIN_FIELDS, [self::ARCHIVE_FIELD]);

        return array_values(array_intersect($this->payloadKeys(), $writable));
    }

    /**
     * Vrai si la valeur courante d'un champ differe de l'etat persiste. Pour une
     * entite non geree (creation/POST), l'etat persiste est vide : toute valeur
     * non-null est alors un changement.
     */
    private function fieldChanged(Carrier $data, string $field, mixed $newValue): bool
    {
        $original = $this->originalData($data);

        return $newValue !== ($original[$field] ?? null);
    }

    /**
     * Snapshot des valeurs persistees de l'entite (telles que chargees, avant
     * application du payload). Vide pour une entite non geree (POST).
     *
     * @return array<string, mixed>
     */
    private function originalData(Carrier $data): array
    {
        if (!$this->em->contains($data)) {
            return [];
        }

        return $this->em->getUnitOfWork()->getOriginalEntityData($data);
    }

    /**
     * Cles de premier niveau effectivement envoyees par le client (payload JSON
     * brut), filtrage compris. Pour un PATCH merge-patch+json, ce sont les seuls
     * champs modifies.
     *
     * @return list<string>
     */
    private function payloadKeys(): array
    {
        $request = $this->requestStack->getCurrentRequest();
        if (null === $request) {
            return [];
        }

        $content = $request->getContent();

        // Cache hit : meme corps brut que le dernier decodage -> memes cles.
        if ($content === $this->decodedContent) {
            return $this->decodedPayloadKeys;
        }

        $this->decodedContent     = $content;
        $this->decodedPayloadKeys = $this->extractPayloadKeys($content);

        return $this->decodedPayloadKeys;
    }

    /**
     * Decode le corps brut et en extrait les cles de premier niveau (chaines).
     * Corps vide ou JSON invalide -> aucune cle.
     *
     * @return list<string>
     */
    private function extractPayloadKeys(string $content): array
    {
        if ('' === $content) {
            return [];
        }

        try {
            $decoded = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
        } catch (JsonException) {
            return [];
        }

        return is_array($decoded) ? array_values(array_filter(array_keys($decoded), 'is_string')) : [];
    }
}
