<?php

declare(strict_types=1);

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

use ApiPlatform\Metadata\DeleteOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\Core\Domain\Entity\User;
use App\Module\Technique\Domain\Entity\Provider;
use App\Module\Technique\Domain\Entity\ProviderAddress;
use App\Module\Technique\Infrastructure\Security\ProviderSiteScopeChecker;
use App\Shared\Domain\Contract\SiteInterface;
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\NotFoundHttpException;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList;

/**
 * Processor d'ecriture de la sous-ressource Adresse d'un prestataire (M3,
 * spec-back § 4.5). Jumeau du SupplierAddressProcessor (M2), recentre sur le
 * perimetre ERP-135, AVEC une garde supplementaire propre au M3 : le
 * cloisonnement d'ECRITURE des sites de l'adresse (§ 2.13).
 *
 * Sequence :
 *  - POST / PATCH : rattachement au prestataire parent puis cloisonnement des
 *    sites de l'adresse (RG-3.05 / § 2.13). Les regles de l'onglet Adresse sont
 *    garanties en amont par des contraintes sur l'entite, jouees par API Platform
 *    avant ce processor : RG-3.06 (code postal, Assert\Regex), RG-3.05 (>= 1 site,
 *    Assert\Count), RG-3.09 (categorie de type PRESTATAIRE, Assert\Callback
 *    ProviderAddress::validateCategoryType).
 *  - DELETE : aucune regle metier specifique (suppression physique directe).
 *
 * La security de l'operation (technique.providers.manage) est appliquee par API
 * Platform en amont, de meme que la validation Symfony des contraintes d'attribut.
 *
 * @implements ProcessorInterface<ProviderAddress, null|ProviderAddress>
 */
final class ProviderAddressProcessor implements ProcessorInterface
{
    private const string PERM_BYPASS_SCOPE = 'sites.bypass_scope';

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

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

        if ($operation instanceof DeleteOperationInterface) {
            return $this->removeProcessor->process($data, $operation, $uriVariables, $context);
        }

        $this->linkParent($data, $uriVariables);
        $this->guardSiteScope($data);

        return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
    }

    /**
     * Rattache l'adresse au prestataire parent de la sous-ressource POST
     * (/providers/{providerId}/addresses) : la relation n'est pas peuplee
     * automatiquement par le Link sur une ecriture. Sur PATCH, no-op.
     */
    private function linkParent(ProviderAddress $address, array $uriVariables): void
    {
        if (null !== $address->getProvider()) {
            return;
        }

        $providerId = $uriVariables['providerId'] ?? null;
        if (null === $providerId) {
            return;
        }

        $provider = $providerId instanceof Provider
            ? $providerId
            : $this->em->getRepository(Provider::class)->find($providerId);

        // read:false sur le POST : sans stade lecture, un parent introuvable n'est
        // plus intercepte en amont -> 404 explicite (sinon 500 au persist sur la
        // contrainte provider_id NOT NULL).
        if (!$provider instanceof Provider) {
            throw new NotFoundHttpException('Prestataire introuvable.');
        }

        // Cloisonnement par site (§ 2.13 / RG-3.17) : interdiction de creer une
        // sous-ressource sur un prestataire hors du perimetre de l'user -> 404
        // (anti-enumeration). Distinct du guardSiteScope ci-dessous, qui cloisonne
        // les sites ATTACHES a l'adresse (et non l'acces au prestataire parent).
        $this->scopeChecker->assertInScope($provider);

        $address->setProvider($provider);
    }

    /**
     * RG-3.05 / § 2.13 (cloisonnement d'ECRITURE — decision Matthieu 11/06) : un
     * user SANS `sites.bypass_scope` ne peut attacher a CHAQUE adresse 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) peut attacher n'importe quel site. Miroir de
     * ProviderProcessor::guardSiteScope, applique ici a la sous-ressource adresse.
     *
     * Ne joue que si `sites` est effectivement soumis : POST (entite non geree,
     * sites obligatoires RG-3.05) 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 (address.getSites()).
     */
    private function guardSiteScope(ProviderAddress $address): void
    {
        if ($this->security->isGranted(self::PERM_BYPASS_SCOPE)) {
            return;
        }

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

        $allowedSiteIds = $this->currentUserSiteIds();

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

    /**
     * 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;
    }

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

        $content = $request->getContent();
        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(ProviderAddress $address): void
    {
        $violations = new ConstraintViolationList();
        $violations->add(new ConstraintViolation(
            'Vous ne pouvez rattacher que des sites auxquels vous avez accès.',
            null,
            [],
            $address,
            'sites',
            null,
        ));

        throw new ValidationException($violations);
    }
}
