<?php

declare(strict_types=1);

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

use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\Commercial\Application\Service\ClientFieldNormalizer;
use App\Module\Commercial\Application\Validator\ClientAccountingCompletenessValidator;
use App\Module\Commercial\Domain\Entity\Client;
use App\Shared\Domain\Contract\CategoryInterface;
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 clients (M1). Cf. spec-back M1 § 2.8 /
 * § 2.9 / § 4.3 / § 4.4 + RG-1.03 a RG-1.28 (RG-1.01/1.02 supprimees : contact inline retire).
 *
 * Sequence (POST / PATCH) :
 *  1. Autorisation additionnelle par groupe d'onglet. La security d'operation
 *     du PATCH a ete elargie (ERP-74) 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 (RG-1.28, 403) ;
 *     - champ main/information modifie -> exige manage (guardManage, 403) : empeche
 *       Compta d'editer un autre onglet que la Comptabilite (§ 2.7) ;
 *     - champ isArchived dans le payload -> exige archive (RG-1.22, 403) et
 *       interdit toute autre modification dans la meme requete (RG-1.22, 422).
 *  2. Normalisation serveur (RG-1.18 a 1.21) via ClientFieldNormalizer.
 *  3. Regles metier : RG-1.03 (distributor/broker
 *     exclusifs + type de categorie), RG-1.12 (Virement -> banque),
 *     RG-1.13 (LCR -> >= 1 RIB). L'onglet Information est entierement facultatif
 *     (RG-1.04 retiree : plus d'obligation, y compris pour le role Commerciale).
 *  4. Pose / retrait de archivedAt (RG-1.22 true=now, RG-1.23 false=null).
 *  5. Persistance via le persist_processor Doctrine, avec traduction des
 *     collisions d'unicite en 409 (RG-1.16 doublon de nom ; RG-1.23 conflit de
 *     restauration).
 *
 * Note : la validation Symfony (Assert\NotBlank, Assert\Email, Assert\Count sur
 * categories...) est jouee par API Platform AVANT ce processor ; on n'y traite
 * donc que les regles non exprimables en simples contraintes d'attribut.
 *
 * @implements ProcessorInterface<Client, Client>
 */
final class ClientProcessor implements ProcessorInterface
{
    /** Champs de l'onglet principal (groupe client:write:main). */
    private const array MAIN_FIELDS = [
        'companyName', 'distributor', 'broker', 'triageService', 'categories',
    ];

    /** Champs de l'onglet Information (groupe client:write:information). */
    private const array INFORMATION_FIELDS = [
        'description', 'competitors', 'foundedAt', 'employeesCount',
        'revenueAmount', 'directorName', 'profitAmount',
    ];

    /** Champs de l'onglet Comptabilite (groupe client: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 § Onglet Comptabilite). bank est exclu : conditionnel (RG-1.12).
     */
    private const array ACCOUNTING_REQUIRED_FIELDS = [
        'siren', 'accountNumber', 'tvaMode', 'nTva', 'paymentDelay', 'paymentType',
    ];

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

    private const string PERM_MANAGE            = 'commercial.clients.manage';
    private const string PERM_ACCOUNTING_MANAGE = 'commercial.clients.accounting.manage';
    private const string PERM_ARCHIVE           = 'commercial.clients.archive';

    /**
     * Memoisation du dernier corps de requete decode, clos par le contenu brut.
     * payloadKeys() est appele plusieurs fois par requete (writablePayloadKeys,
     * categoriesChanged...) : on evite de rejouer json_decode a chaque appel. 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 ClientFieldNormalizer $normalizer,
        private readonly ClientAccountingCompletenessValidator $accountingValidator,
        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 Client) {
            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 = [];

        $writableKeys = $this->writablePayloadKeys();

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

        $this->normalize($data);

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

        $this->validateDistributorBroker($data);
        $this->validateAccountingConsistency($data);
        $this->validateAccountingCompleteness($data);

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

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

    /**
     * RG-1.22 / RG-1.23 : 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 client 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(Client $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-1.22 : 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-1.22 (true -> now) / RG-1.23 (false -> null).
        $data->setArchivedAt($data->isArchived() ? new DateTimeImmutable() : null);

        return true;
    }

    /**
     * RG-1.28 : 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 : un POST/PATCH renvoyant des champs comptables
     * inchanges (ou null en creation) ne declenche pas de 403 parasite. Le
     * message precise le premier champ fautif.
     */
    private function guardAccounting(Client $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.7 / RG-1.28 (ERP-74) : la modification effective d'un champ « metier »
     * (onglets principal ou Information) exige `commercial.clients.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`, donc inutile de la
     * re-gater ici (et un POST par un porteur de `manage` passerait de toute
     * facon).
     */
    private function guardManage(Client $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,
            ));
        }
    }

    /**
     * Champs « metier » (onglets principal + Information, hors comptabilite et
     * archivage) dont la valeur courante differe de l'etat persiste. Memes
     * regles de comparaison que changedAccountingFields (scalaires par valeur,
     * relations ManyToOne distributor/broker par identite via l'identity map).
     *
     * Cas particulier `categories` (M2M) : non trace par getOriginalEntityData,
     * compare par valeur via le snapshot de la PersistentCollection (cf.
     * categoriesChanged) — la simple presence dans le payload ne suffit pas, sous
     * peine de 403 parasite sur un PATCH representation complete reincluant des
     * categories inchangees.
     *
     * @return list<string>
     */
    private function changedBusinessFields(Client $data): array
    {
        $newValues = [
            'companyName'    => $data->getCompanyName(),
            'distributor'    => $data->getDistributor(),
            'broker'         => $data->getBroker(),
            'triageService'  => $data->isTriageService(),
            'description'    => $data->getDescription(),
            'competitors'    => $data->getCompetitors(),
            'foundedAt'      => $data->getFoundedAt(),
            'employeesCount' => $data->getEmployeesCount(),
            'revenueAmount'  => $data->getRevenueAmount(),
            'directorName'   => $data->getDirectorName(),
            'profitAmount'   => $data->getProfitAmount(),
        ];

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

        if ($this->categoriesChanged($data)) {
            $changed[] = 'categories';
        }

        return $changed;
    }

    /**
     * Vrai si l'ensemble des categories (M2M) differe reellement de l'etat
     * persiste. La collection n'etant pas tracee par getOriginalEntityData, on
     * compare par identifiants (independamment de l'ordre) le snapshot de la
     * PersistentCollection (etat charge depuis la base) a l'etat courant (apres
     * application du payload). Symetrique de changedAccountingFields : seul un
     * changement effectif compte, pas la simple presence dans le payload.
     *
     * - POST / entite non geree : fournir des categories est un acte metier
     *   (comportement historique conserve) — branche defensive, guardManage ne
     *   s'execute de toute facon que sur entite geree.
     * - categories absent du payload (PATCH partiel) : aucun changement.
     */
    private function categoriesChanged(Client $data): bool
    {
        if (!$this->em->contains($data)) {
            return true;
        }

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

        $collection = $data->getCategories();

        // 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->categoryIdSet($collection->toArray())
            !== $this->categoryIdSet($collection->getSnapshot());
    }

    /**
     * Ensemble trie des identifiants d'une liste de categories — pour une
     * comparaison par valeur independante de l'ordre.
     *
     * @param array<int, object> $categories
     *
     * @return list<mixed>
     */
    private function categoryIdSet(array $categories): array
    {
        $ids = array_map(
            static fn (object $category): mixed => method_exists($category, 'getId')
                ? $category->getId()
                : spl_object_id($category),
            array_values($categories),
        );
        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(Client $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(Client $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(Client $data): array
    {
        if (!$this->em->contains($data)) {
            return [];
        }

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

    /**
     * Normalisation serveur du formulaire principal (RG-1.18). Seul companyName
     * subsiste cote Client depuis la suppression du contact inline (les champs de
     * contact — noms, telephones, email — sont normalises par ClientContactProcessor).
     * 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(Client $data): void
    {
        if (null !== $data->getCompanyName()) {
            $data->setCompanyName((string) $this->normalizer->normalizeCompanyName($data->getCompanyName()));
        }
    }

    /**
     * RG-1.03 : distributor et broker mutuellement exclusifs ; un distributor
     * doit referencer un client portant la categorie de code DISTRIBUTEUR (idem
     * broker -> COURTIER). Depuis ERP-78, le filtrage se fait sur le code de la
     * Category (et non plus sur le type, devenu unique CLIENT).
     */
    private function validateDistributorBroker(Client $data): void
    {
        $distributor = $data->getDistributor();
        $broker      = $data->getBroker();

        if (null !== $distributor && null !== $broker) {
            $this->throwViolation(
                'distributor',
                'Un client ne peut pas être rattaché à la fois à un distributeur et à un courtier.',
                $data,
            );
        }

        if (null !== $distributor && !$this->hasCategoryCode($distributor, 'DISTRIBUTEUR')) {
            $this->throwViolation(
                'distributor',
                'Le distributeur référencé doit être un client de catégorie DISTRIBUTEUR.',
                $data,
            );
        }

        if (null !== $broker && !$this->hasCategoryCode($broker, 'COURTIER')) {
            $this->throwViolation(
                'broker',
                'Le courtier référencé doit être un client de catégorie COURTIER.',
                $data,
            );
        }
    }

    /**
     * RG-1.12 : Virement -> banque obligatoire. RG-1.13 : LCR -> au moins un RIB.
     */
    private function validateAccountingConsistency(Client $data): void
    {
        $paymentCode = $data->getPaymentType()?->getCode();

        if ('VIREMENT' === $paymentCode && null === $data->getBank()) {
            $this->throwViolation(
                'bank',
                'La banque est obligatoire pour le type de règlement Virement.',
                $data,
            );
        }

        if ('LCR' === $paymentCode && $data->getRibs()->isEmpty()) {
            $this->throwViolation(
                'paymentType',
                'Au moins un RIB est obligatoire pour le type de règlement LCR.',
                $data,
            );
        }
    }

    /**
     * spec-front § Onglet Comptabilite : a la validation COMPLETE de l'onglet
     * (les six champs obligatoires presents dans le payload — le front les envoie
     * toujours ensemble), chacun doit etre renseigne, sinon 422 par champ. On ne
     * declenche pas sur un PATCH ciblant un sous-ensemble de champs comptables :
     * ce n'est pas une validation d'onglet (edition ponctuelle preservee). bank /
     * RIB restent geres par validateAccountingConsistency (RG-1.12 / RG-1.13).
     *
     * Colonnes nullable en base + validateur contextuel : un Assert\NotBlank sur
     * l'entite casserait le POST de l'onglet principal, qui n'envoie aucun champ
     * comptable.
     */
    private function validateAccountingCompleteness(Client $data): void
    {
        // Declenche uniquement si TOUS les champs requis sont presents dans le
        // payload (= soumission d'onglet, pas un PATCH partiel cible).
        if ([] !== array_diff(self::ACCOUNTING_REQUIRED_FIELDS, $this->payloadKeys())) {
            return;
        }

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

    /**
     * Vrai si au moins une categorie du client porte le code donne. S'appuie sur
     * CategoryInterface::getCode() (pas d'import de Category — regle ABSOLUE n°1).
     */
    private function hasCategoryCode(Client $client, string $code): bool
    {
        foreach ($client->getCategories() as $category) {
            if ($category instanceof CategoryInterface && $category->getCode() === $code) {
                return true;
            }
        }

        return false;
    }

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

    /**
     * Leve une ValidationException (HTTP 422) portant une violation unique sur
     * la propriete visee — meme rendu Hydra que les contraintes Symfony.
     *
     * @return never
     */
    private function throwViolation(string $property, string $message, Client $root): void
    {
        $violations = new ConstraintViolationList();
        $violations->add(new ConstraintViolation($message, null, [], $root, $property, null));

        throw new ValidationException($violations);
    }
}
