<?php

declare(strict_types=1);

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

use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\Core\Domain\Entity\User;
use App\Module\Technique\Application\Service\ProviderFieldNormalizer;
use App\Module\Technique\Application\Validator\ProviderAccountingCompletenessValidator;
use App\Module\Technique\Domain\Entity\Provider;
use App\Shared\Domain\Contract\SiteInterface;
use DateTimeImmutable;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\PersistentCollection;
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;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList;

/**
 * Processor d'ecriture du repertoire prestataires (M3). Cf. spec-back M3 § 4.3 /
 * § 4.4 + RG-3.10 / RG-3.13 / RG-3.14 / RG-3.15 / RG-3.17. Jumeau du
 * SupplierProcessor (M2), avec deux differences structurantes (§ 3.1) :
 *  - PAS d'onglet Information (aucun champ description / competitors / ...) ni de
 *    validation de completude comptable -> le prestataire est minimal ;
 *  - le formulaire principal porte `sites` (M2M provider_site, RG-3.03), soumis au
 *    CLOISONNEMENT D'ECRITURE par site (RG-3.17, § 2.13) : un user sans
 *    `sites.bypass_scope` ne peut attacher que les sites de ses `user_site`.
 *
 * Sequence (POST / PATCH) :
 *  1. Autorisation additionnelle par groupe d'onglet (mode strict RG-3.15). La
 *     security d'operation du PATCH est elargie a `manage` OU `accounting.manage`
 *     pour laisser entrer le role Compta ; ce processor re-gate alors finement :
 *     - champ comptable modifie dans le payload -> exige accounting.manage (403) ;
 *     - champ main (companyName / categories / sites) modifie -> exige manage
 *       (guardManage, 403) : empeche Compta d'editer un autre onglet ;
 *     - champ isArchived dans le payload -> exige archive (RG-3.13, 403) et
 *       interdit toute autre modification dans la meme requete (RG-3.13, 422).
 *  2. Cloisonnement d'ECRITURE des sites (RG-3.17 / RG-3.03) : tout site attache
 *     hors des `user_site` de l'appelant non-bypass -> 422 sur `sites`.
 *  3. Normalisation serveur (RG-3.11) via ProviderFieldNormalizer (companyName).
 *  4. Pose / retrait de archivedAt (RG-3.13 true=now, RG-3.14 false=null).
 *  5. Persistance via le persist_processor Doctrine, avec traduction des
 *     collisions d'unicite en 409 (RG-3.10 doublon de nom ; RG-3.14 conflit de
 *     restauration).
 *
 * Les RG inter-champs RG-3.07 (Virement -> banque), RG-3.08 (LCR -> >= 1 RIB) et
 * RG-3.09 (categorie de type PRESTATAIRE) sont portees par des Assert\Callback +
 * ->atPath() sur les entites Provider / ProviderAddress (jouees par API Platform
 * AVANT ce processor), pour que chaque 422 porte un propertyPath consommable par
 * extractApiViolations (mapping inline, pas un toast — convention ERP-101). Le 409
 * sur DELETE du dernier RIB en LCR (volet ecriture de RG-3.08) est porte par le
 * ProviderRibProcessor (ERP-135).
 *
 * @implements ProcessorInterface<Provider, Provider>
 */
final class ProviderProcessor implements ProcessorInterface
{
    /** Champs de l'onglet principal (groupe provider:write:main). */
    private const array MAIN_FIELDS = [
        'companyName', 'categories', 'sites',
    ];

    /** Champs de l'onglet Comptabilite (groupe provider:write:accounting). */
    private const array ACCOUNTING_FIELDS = [
        'siren', 'accountNumber', 'tvaMode', 'nTva', 'paymentDelay',
        'paymentType', 'bank',
    ];

    /**
     * Champs comptables obligatoires a la validation complete de l'onglet
     * (spec-front M3 § Onglet Comptabilite — miroir M1/M2). bank est exclu :
     * conditionnel (RG-3.07).
     */
    private const array ACCOUNTING_REQUIRED_FIELDS = [
        'siren', 'accountNumber', 'tvaMode', 'nTva', 'paymentDelay', 'paymentType',
    ];

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

    private const string PERM_MANAGE            = 'technique.providers.manage';
    private const string PERM_ACCOUNTING_MANAGE = 'technique.providers.accounting.manage';
    private const string PERM_ARCHIVE           = 'technique.providers.archive';

    private const string PERM_BYPASS_SCOPE = 'sites.bypass_scope';

    /**
     * Memoisation du dernier corps de requete decode, clos par le contenu brut
     * (cf. SupplierProcessor) : payloadKeys() est appele plusieurs fois par requete,
     * on evite de rejouer json_decode. Cle = contenu lui-meme, calcul pur -> aucune
     * fuite entre requetes sur ce service partage.
     */
    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 ProviderFieldNormalizer $normalizer,
        private readonly Security $security,
        private readonly RequestStack $requestStack,
        private readonly EntityManagerInterface $em,
        private readonly ProviderAccountingCompletenessValidator $accountingValidator,
    ) {}

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

        // Reinitialisation de la memoisation du payload : le service est partage
        // (stateful), on repart du corps de LA requete courante.
        $this->decodedContent     = null;
        $this->decodedPayloadKeys = [];

        $writableKeys = $this->writablePayloadKeys();

        $isArchiveRequest = $this->guardArchive($data, $writableKeys);
        $this->guardAccounting($data);
        $this->guardSiteScope($data);

        $this->normalize($data);

        // guardManage apres normalize : la comparaison « change vs etat persiste »
        // des champs texte (companyName) se fait sur des valeurs normalisees des
        // deux cotes (l'etat persiste l'a deja ete).
        $this->guardManage($data);

        // Completude de l'onglet Comptabilite (apres normalize : les chaines vides
        // sont deja ramenees a null). Joue uniquement sur une soumission d'onglet.
        $this->validateAccountingCompleteness($data);

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

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

    /**
     * RG-3.13 / RG-3.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. Restreint a la mise a jour d'un prestataire existant ET au seul
     * cas ou isArchived change vraiment (cf. SupplierProcessor).
     *
     * @param list<string> $writableKeys cles ecrivables du payload (hors @* et champs inconnus)
     */
    private function guardArchive(Provider $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-3.13 : 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-3.13 (true -> now) / RG-3.14 (false -> null).
        $data->setArchivedAt($data->isArchived() ? new DateTimeImmutable() : null);

        return true;
    }

    /**
     * RG-3.15 : la modification effective d'un champ comptable exige
     * accounting.manage, sinon 403 sur l'ensemble du payload (mode strict, pas de
     * filtrage silencieux). On ne gate que si un champ change reellement par
     * rapport a l'etat persiste (POST/PATCH renvoyant des champs comptables
     * inchanges ne declenche pas de 403 parasite). Le message precise le premier
     * champ fautif.
     */
    private function guardAccounting(Provider $data): void
    {
        $changed = $this->changedAccountingFields($data);

        if ([] === $changed) {
            return;
        }

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

    /**
     * § 2.9 / RG-3.15 : la modification effective d'un champ « metier » (onglet
     * principal : companyName / categories / sites) exige
     * `technique.providers.manage`. Sans cette permission -> 403 sur l'ensemble du
     * payload (mode strict, miroir de guardAccounting). C'est ce qui empeche le
     * role Compta — qui entre dans le PATCH via `accounting.manage` (security
     * d'operation elargie) — d'editer autre chose que l'onglet Comptabilite.
     *
     * Ne s'applique qu'aux mises a jour (entite geree) : la creation (POST) est
     * deja gardee par la security d'operation `manage`.
     */
    private function guardManage(Provider $data): void
    {
        if (!$this->em->contains($data)) {
            return;
        }

        $changed = $this->changedBusinessFields($data);

        if ([] === $changed) {
            return;
        }

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

    /**
     * RG-3.17 / RG-3.03 (cloisonnement d'ECRITURE — § 2.13) : un user SANS
     * `sites.bypass_scope` ne peut attacher au prestataire que des sites figurant
     * dans ses propres `user_site`. Tout site hors perimetre -> 422 sur `sites`
     * (propertyPath consommable inline, convention ERP-101). Un user `bypass_scope`
     * (Admin auto) peut attacher n'importe quel site.
     *
     * Interaction avec SiteCollectionScopedExtension (module Sites) : pour un user
     * sans `sites.bypass_scope` NI `sites.read_ref`, la resolution de l'IRI de site
     * hors perimetre echoue DEJA en amont (item Site « introuvable » -> 400
     * anti-enumeration), avant ce processor. Cette garde reste donc l'enforcement
     * AUTORITAIRE de RG-3.17 pour le cas particulier d'un user `sites.read_ref`
     * (qui peut resoudre N'IMPORTE quel site comme referentiel transverse mais ne
     * doit rattacher que ses propres sites), et une defense en profondeur sinon.
     *
     * Ne joue que si `sites` est effectivement soumis : POST (entite non geree,
     * sites obligatoires RG-3.03) ou PATCH portant la cle `sites`. Un PATCH qui ne
     * touche pas aux sites n'est pas re-valide (les sites ont ete cloisonnes a leur
     * pose). La validation porte sur l'ETAT RESULTANT (data.getSites()).
     */
    private function guardSiteScope(Provider $data): void
    {
        if ($this->security->isGranted(self::PERM_BYPASS_SCOPE)) {
            return;
        }

        // sites non soumis sur un PATCH : rien a cloisonner.
        if ($this->em->contains($data) && !in_array('sites', $this->payloadKeys(), true)) {
            return;
        }

        $allowedSiteIds = $this->currentUserSiteIds();

        foreach ($data->getSites() as $site) {
            if (!$site instanceof SiteInterface) {
                continue;
            }
            if (!in_array($site->getId(), $allowedSiteIds, true)) {
                $this->throwSitesViolation($data);
            }
        }
    }

    /**
     * Identifiants des sites rattaches a l'utilisateur courant (`user_site`).
     * Vide si pas d'user authentifie (cas defensif : la security d'operation
     * garantit deja l'authentification).
     *
     * @return list<int>
     */
    private function currentUserSiteIds(): array
    {
        $user = $this->security->getUser();
        if (!$user instanceof User) {
            return [];
        }

        $ids = [];
        foreach ($user->getSites() as $site) {
            if ($site instanceof SiteInterface && null !== $site->getId()) {
                $ids[] = $site->getId();
            }
        }

        return $ids;
    }

    /**
     * Champs « metier » (onglet principal : companyName / categories / sites) dont
     * la valeur courante differe de l'etat persiste. Scalaires compares par valeur ;
     * collections M2M (categories / sites) comparees par ensemble d'identifiants
     * (cf. collectionChanged) — la simple presence dans le payload ne suffit pas,
     * sous peine de 403 parasite sur un PATCH representation complete.
     *
     * @return list<string>
     */
    private function changedBusinessFields(Provider $data): array
    {
        $changed = [];

        if ($this->fieldChanged($data, 'companyName', $data->getCompanyName())) {
            $changed[] = 'companyName';
        }

        if ($this->collectionChanged($data, 'categories', $data->getCategories()->toArray())) {
            $changed[] = 'categories';
        }

        if ($this->collectionChanged($data, 'sites', $data->getSites()->toArray())) {
            $changed[] = 'sites';
        }

        return $changed;
    }

    /**
     * Vrai si une collection M2M (`categories` ou `sites`) differe reellement de
     * l'etat persiste. Ces collections ne sont pas tracees par
     * getOriginalEntityData : on compare par identifiants (independamment de
     * l'ordre) le snapshot de la PersistentCollection (etat charge) a l'etat
     * courant (apres application du payload). Symetrique des scalaires : seul un
     * changement effectif compte, pas la simple presence dans le payload.
     *
     * - POST / entite non geree : fournir la collection est un acte metier
     *   (branche defensive, guardManage ne s'execute que sur entite geree).
     * - cle absente du payload (PATCH partiel) : aucun changement.
     *
     * @param array<int, object> $current
     */
    private function collectionChanged(Provider $data, string $field, array $current): bool
    {
        if (!$this->em->contains($data)) {
            return true;
        }

        if (!in_array($field, $this->payloadKeys(), true)) {
            return false;
        }

        $collection = 'categories' === $field ? $data->getCategories() : $data->getSites();

        // Hors PersistentCollection (cas limite hors flux PATCH reel) : faute d'etat
        // persiste comparable, on se rabat sur la presence payload.
        if (!$collection instanceof PersistentCollection) {
            return true;
        }

        return $this->idSet($current) !== $this->idSet($collection->getSnapshot());
    }

    /**
     * Ensemble trie des identifiants d'une liste d'entites — pour une comparaison
     * par valeur independante de l'ordre.
     *
     * @param array<int, object> $entities
     *
     * @return list<mixed>
     */
    private function idSet(array $entities): array
    {
        $ids = array_map(
            static fn (object $entity): mixed => method_exists($entity, 'getId')
                ? $entity->getId()
                : spl_object_id($entity),
            array_values($entities),
        );
        sort($ids);

        return $ids;
    }

    /**
     * Champs comptables dont la valeur courante differe de l'etat persiste. Les
     * relations (tvaMode, paymentDelay, paymentType, bank) sont comparees par
     * identite d'objet : l'identity map Doctrine renvoie la meme instance tant que
     * la reference est inchangee.
     *
     * @return list<string>
     */
    private function changedAccountingFields(Provider $data): array
    {
        $changed = [];

        foreach (self::ACCOUNTING_FIELDS as $field) {
            $newValue = match ($field) {
                'siren'         => $data->getSiren(),
                'accountNumber' => $data->getAccountNumber(),
                'tvaMode'       => $data->getTvaMode(),
                'nTva'          => $data->getNTva(),
                'paymentDelay'  => $data->getPaymentDelay(),
                'paymentType'   => $data->getPaymentType(),
                'bank'          => $data->getBank(),
            };

            if ($this->fieldChanged($data, $field, $newValue)) {
                $changed[] = $field;
            }
        }

        return $changed;
    }

    /**
     * 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(Provider $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(Provider $data): array
    {
        if (!$this->em->contains($data)) {
            return [];
        }

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

    /**
     * Normalisation serveur du formulaire principal (RG-3.11). Seul companyName est
     * porte par le Provider (les champs de contact sont normalises par le processor
     * de sous-ressource ProviderContact, ticket dedie). Le setter non-nullable n'est
     * touche que si une valeur est presente, pour ne jamais ecraser l'existant lors
     * d'un PATCH partiel.
     */
    private function normalize(Provider $data): void
    {
        if (null !== $data->getCompanyName()) {
            $data->setCompanyName((string) $this->normalizer->normalizeCompanyName($data->getCompanyName()));
        }
    }

    /**
     * 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. Base du 422 d'archivage (RG-3.13).
     *
     * @return list<string>
     */
    private function writablePayloadKeys(): array
    {
        $writable = array_merge(
            self::MAIN_FIELDS,
            self::ACCOUNTING_FIELDS,
            [self::ARCHIVE_FIELD],
        );

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

    /**
     * Cles de premier niveau effectivement envoyees par le client (payload JSON
     * brut). Pour un PATCH merge-patch+json, ce sont les seuls champs modifies.
     *
     * @return list<string>
     */
    /**
     * Completude de l'onglet Comptabilite (miroir SupplierProcessor) : ne se
     * declenche que si TOUS les champs requis sont presents dans le payload
     * (= soumission d'onglet, pas un PATCH partiel cible). Delegue au validateur
     * qui leve une 422 listant chaque champ manquant (mapping inline ERP-101).
     */
    private function validateAccountingCompleteness(Provider $data): void
    {
        if ([] !== array_diff(self::ACCOUNTING_REQUIRED_FIELDS, $this->payloadKeys())) {
            return;
        }

        $this->accountingValidator->validate($data);
    }

    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')) : [];
    }

    /**
     * Leve une 422 portant une violation unique sur `sites` — meme rendu Hydra que
     * les contraintes Symfony, consommable inline par extractApiViolations (ERP-101).
     *
     * @return never
     */
    private function throwSitesViolation(Provider $root): void
    {
        $violations = new ConstraintViolationList();
        $violations->add(new ConstraintViolation(
            'Vous ne pouvez rattacher que des sites auxquels vous avez accès.',
            null,
            [],
            $root,
            'sites',
            null,
        ));

        throw new ValidationException($violations);
    }
}
