<?php

declare(strict_types=1);

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

use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\Core\Domain\Entity\User;
use App\Module\Core\Domain\Exception\LastAdminProtectionException;
use App\Module\Core\Domain\Security\AdminHeadcountGuardInterface;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\PersistentCollection;
use LogicException;
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\BadRequestHttpException;

/**
 * Processor dedie a l'endpoint RBAC `PATCH /api/users/{id}/rbac`.
 *
 * Delegue la persistance au PersistProcessor Doctrine decore apres avoir
 * applique les gardes metier propres aux changements de droits. Cet endpoint
 * ne touche JAMAIS au mot de passe — c'est une separation volontaire avec le
 * UserPasswordHasherProcessor qui gere le endpoint profil `/api/users/{id}`.
 *
 * Gardes metier (dans l'ordre d'execution) :
 *  - Auto-suicide : un admin ne peut pas retirer son propre flag `isAdmin`.
 *    Cas particulier plus strict, avec message dedie.
 *  - Dernier admin global : impossible de retirer `isAdmin` si c'est le
 *    dernier administrateur de l'instance, meme par un tiers. Enforce via
 *    AdminHeadcountGuardInterface.
 *  - Permission sites.manage : si le payload mute la collection `sites`,
 *    la permission `sites.manage` est requise en plus de `core.users.manage`.
 *  - Coherence currentSite (ticket 2 module Sites) : apres persist des
 *    sites autorises, si le `currentSite` n'est plus dans la collection,
 *    il est repositionne automatiquement :
 *     a) repasse a `null` s'il pointait vers un site retire ;
 *     b) est auto-selectionne sur le premier site de `sites` s'il etait
 *        null alors que la collection vient d'etre modifiee et n'est pas vide.
 *    Un second flush est emis uniquement si la coherence a du etre corrigee.
 *    La garde coherence est skippee si ni les sites ni le currentSite n'ont
 *    change (evite le silent site-switch sur un PATCH ne touchant pas aux sites).
 *
 * Atomicite : persistProcessor->process() + ensureCurrentSiteConsistency() sont
 * executes dans une meme transaction wrapInTransaction pour eviter un etat
 * partiellement persiste en cas d'erreur entre les deux flush.
 *
 * @implements ProcessorInterface<User, User>
 */
final class UserRbacProcessor implements ProcessorInterface
{
    /**
     * Mapping cle-payload → (property-path PHP, accesseur, setter utilise pour
     * reattacher les items lors de la restauration). Permet au gardefou
     * anti-ecrasement de savoir quelles collections restaurer si elles sont
     * absentes du payload JSON.
     *
     * Note : la cle JSON "roles" correspond a la propriete PHP `rbacRoles`
     * (renommee via #[SerializedName] pour eviter la collision avec
     * UserInterface::getRoles()).
     *
     * @var array<string, array{getter: string, remover: string, adder: string}>
     */
    private const array COLLECTION_MAP = [
        'roles'             => ['getter' => 'getRbacRoles', 'remover' => 'removeRbacRole', 'adder' => 'addRbacRole'],
        'directPermissions' => ['getter' => 'getDirectPermissions', 'remover' => 'removeDirectPermission', 'adder' => 'addDirectPermission'],
        'sites'             => ['getter' => 'getSites', 'remover' => 'removeSite', 'adder' => 'addSite'],
    ];

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

    public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
    {
        if (!$data instanceof User) {
            // Ce processor est wire exclusivement sur l'operation user_rbac_patch
            // qui cible User. Si on arrive ici avec autre chose, c'est une
            // misconfiguration qu'il faut faire remonter fort.
            throw new LogicException(sprintf(
                'UserRbacProcessor attend une instance de %s, %s recu.',
                User::class,
                get_debug_type($data),
            ));
        }

        $currentUser = $this->security->getUser();

        // Calcul partage entre les deux gardes : l'user perdait-il le flag admin ?
        $originalData  = $this->entityManager->getUnitOfWork()->getOriginalEntityData($data);
        $wasAdmin      = $originalData['isAdmin'] ?? null;
        $willLoseAdmin = true === $wasAdmin && false === $data->isAdmin();

        // Garde auto-suicide : cas particulier plus strict — l'user courant ne
        // peut pas retirer son propre flag admin, meme si d'autres admins existent.
        if ($willLoseAdmin && $currentUser instanceof User && $currentUser->getId() === $data->getId()) {
            throw new BadRequestHttpException(
                'Vous ne pouvez pas retirer vos propres droits administrateur.'
            );
        }

        // Garde dernier admin global : invariant general — impossible de retirer
        // isAdmin si cela laisserait l'instance sans administrateur.
        if ($willLoseAdmin) {
            try {
                $this->adminHeadcountGuard->ensureAtLeastOneAdminRemainsAfterDemotion($data);
            } catch (LastAdminProtectionException $exception) {
                throw new BadRequestHttpException($exception->getMessage(), $exception);
            }
        }

        // Detection de la mutation de la collection `sites` avant tout flush.
        // La collection est deja denormalisee dans $data quand process() est appele.
        // On utilise PersistentCollection::isDirty() pour savoir si l'ORM a detecte
        // une modification depuis le chargement initial (ajout/retrait d'elements).
        $sitesCollection  = $data->getSites();
        $sitesWereMutated = $sitesCollection instanceof PersistentCollection
            && $sitesCollection->isDirty();

        // Capture de l'ID du currentSite avant persist pour la detection post-flush.
        $originalCurrentSiteId = $data->getCurrentSite()?->getId();

        // Garde sites.manage : la modification de la collection de sites rattaches
        // a un user est une operation sensible qui requiert une permission distincte
        // de core.users.manage (evite le bypass de sites.manage via /rbac).
        if ($sitesWereMutated && !$this->security->isGranted('sites.manage')) {
            throw new AccessDeniedHttpException(
                'La modification des sites rattaches a un user requiert la permission sites.manage.'
            );
        }

        // Persistance + correction de coherence currentSite dans une seule transaction.
        // wrapInTransaction rollback automatiquement sur toute exception et la re-lance,
        // ce qui preserve le comportement attendu pour BadRequestHttpException.
        $result = null;
        $this->entityManager->wrapInTransaction(function () use (
            $data,
            $operation,
            $uriVariables,
            $context,
            $sitesWereMutated,
            $originalCurrentSiteId,
            &$result,
        ): void {
            // Garde anti-ecrasement (defense in depth) : PATCH merge-patch+json impose
            // que les cles absentes du payload ne mutent PAS les proprietes
            // correspondantes. La denormalisation API Platform ne respecte pas cet
            // invariant pour les collections ManyToMany — elle reinstancie une
            // ArrayCollection vide des que la cle n'est pas presente. Sans cette
            // garde, un client qui PATCHe juste `{ "isAdmin": true }` verrait toutes
            // ses roles/directPermissions/sites detruits.
            //
            // Execute dans la transaction (et non avant) : garantit que le snapshot
            // Doctrine lu pour restauration reflete le meme etat BDD que celui sur
            // lequel le persist va operer. Evite toute fenetre de race entre la
            // lecture du snapshot et le flush.
            $this->restoreAbsentCollections($data);

            $result = $this->persistProcessor->process($data, $operation, $uriVariables, $context);

            // Garde coherence currentSite (ticket 2 module Sites).
            // Post-persist : le champ `sites` a ete applique par le persist processor.
            // On s'assure que `currentSite` pointe toujours vers un site present
            // dans la collection ou est recale automatiquement — mais UNIQUEMENT si
            // les sites ou le currentSite ont effectivement ete touches dans ce PATCH.
            $currentSiteChangedByPersist = $originalCurrentSiteId !== $data->getCurrentSite()?->getId();
            if ($sitesWereMutated || $currentSiteChangedByPersist) {
                $this->ensureCurrentSiteConsistency($data);
            }
        });

        return $result;
    }

    /**
     * Applique deux corrections post-persist sur `currentSite` :
     *  - si l'actuel n'est plus dans `sites` apres update → repasse a null ;
     *  - si null et `sites` non vide → auto-selectionne le premier site
     *    (coherent avec le choix de ne jamais laisser un user rattache a
     *    plusieurs sites sans contexte courant apres une mutation effective).
     *
     * N'emet un flush additionnel que si une correction a ete necessaire :
     * pas de cout DB sur la majorite des requetes /rbac qui ne touchent pas
     * aux sites.
     *
     * Cette methode ne doit etre appelee que si les sites ont reellement
     * ete mutes dans la requete courante (voir logique dans process()).
     */
    private function ensureCurrentSiteConsistency(User $user): void
    {
        $currentSite = $user->getCurrentSite();
        $sites       = $user->getSites();
        $changed     = false;

        if (null !== $currentSite && !$user->hasSite($currentSite)) {
            $user->setCurrentSite(null);
            $changed = true;
        }

        if (null === $user->getCurrentSite() && !$sites->isEmpty()) {
            $user->setCurrentSite($sites->first() ?: null);
            $changed = true;
        }

        if ($changed) {
            $this->entityManager->flush();
        }
    }

    /**
     * Pour chaque collection RBAC (roles, directPermissions, sites) absente du
     * payload JSON, restaure l'etat d'origine a partir du snapshot Doctrine et
     * marque la collection comme non-dirty. Idempotent : si la cle est presente
     * dans le payload, no-op (la denormalisation fait foi).
     *
     * Cas d'usage : un client qui PATCHe partiellement (`{ "isAdmin": true }`)
     * ne doit pas voir ses autres collections reinitialisees. API Platform
     * reinstancie par defaut une collection vide pour les cles absentes, ce
     * qui casse la semantique de merge-patch+json.
     *
     * Pas de fallback si la collection d'origine n'est pas une PersistentCollection
     * (ex: User fraichement construit) : dans ce cas aucune restauration n'est
     * possible puisqu'il n'y a pas d'etat persiste a restaurer.
     */
    private function restoreAbsentCollections(User $user): void
    {
        $request = $this->requestStack->getCurrentRequest();
        if (null === $request) {
            return;
        }

        $rawBody = $request->getContent();
        if ('' === $rawBody) {
            return;
        }

        /** @var null|array<string, mixed> $payload */
        $payload = json_decode($rawBody, true);
        if (!is_array($payload)) {
            return;
        }

        foreach (self::COLLECTION_MAP as $jsonKey => $accessors) {
            // La garde ne doit sauter la restauration que si le payload fournit
            // un VRAI tableau pour cette cle. Un `null`, un scalaire ou un autre
            // type doivent etre traites comme "cle absente" : sinon un payload
            // `{"sites": null}` contourne la restauration et laisse API Platform
            // vider la collection silencieusement (bypass de la garde).
            if (array_key_exists($jsonKey, $payload) && is_array($payload[$jsonKey])) {
                continue;
            }

            /** @var Collection<int, object> $currentCollection */
            $currentCollection = $user->{$accessors['getter']}();

            if (!$currentCollection instanceof PersistentCollection) {
                continue;
            }

            // Force l'initialisation LAZY avant de lire le snapshot : pour une
            // association fetch=LAZY (ex: User::$sites), la PersistentCollection
            // existe mais son snapshot est vide tant que la collection n'a pas
            // ete materialisee. Sans cet init, `getSnapshot()` renvoie `[]` et
            // la boucle de restauration ci-dessous appelle `remover()` sur
            // chaque item charge par `toArray()` → **vide silencieusement la
            // collection** au lieu de la preserver. Idempotent si deja initialisee.
            if (!$currentCollection->isInitialized()) {
                $currentCollection->initialize();
            }

            // Snapshot = etat charge depuis la BDD avant denormalisation.
            // On restaure en retirant les items actuels et en ajoutant les
            // originaux via l'adder/remover pour que les collections inverses
            // (ex: Site::users) restent coherentes.
            $snapshot = $currentCollection->getSnapshot();

            foreach ($currentCollection->toArray() as $currentItem) {
                if (!in_array($currentItem, $snapshot, true)) {
                    $user->{$accessors['remover']}($currentItem);
                }
            }

            foreach ($snapshot as $originalItem) {
                if (!$currentCollection->contains($originalItem)) {
                    $user->{$accessors['adder']}($originalItem);
                }
            }

            // Marquer comme non-dirty pour que Doctrine ne detecte pas de diff
            // et n'emette pas de requete UPDATE inutile sur la table de jointure.
            $currentCollection->takeSnapshot();
        }
    }
}
