<?php

declare(strict_types=1);

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

use ApiPlatform\Metadata\DeleteOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\Transport\Application\Service\CarrierFieldNormalizer;
use App\Module\Transport\Domain\Entity\Carrier;
use App\Module\Transport\Domain\Entity\CarrierContact;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList;

use function count;
use function is_string;

/**
 * Processor d'ecriture de la sous-ressource Contact d'un transporteur (M4,
 * spec-back § 4.5). Jumeau du SupplierContactProcessor (M2), recentre sur le
 * perimetre ERP-160, AVEC deux specificites M4 : RG-4.08 (≥ 1 champ rempli, max
 * 2 telephones) portee a la fois par le CHECK BDD chk_carrier_contact_filled et
 * par ce Processor.
 *
 * Sequence :
 *  - POST / PATCH : rattachement au transporteur parent (linkParent),
 *    normalisation serveur RG-4.13 (prenom/nom Title Case, email lowercase),
 *    mapping du tableau d'ecriture `phones` -> phonePrimary/phoneSecondary
 *    (max 2, chiffres uniquement), puis garde RG-4.08 (≥ 1 champ) avant
 *    persistance.
 *  - DELETE : aucune regle metier specifique (suppression physique directe).
 *
 * RG-4.08 vit ICI (double du CHECK BDD) pour transformer une violation SQL (500
 * generique) en 422 propre rattachee au champ `firstName` (mapping inline
 * ERP-101). Le « max 2 telephones » est rattache au champ `phones` : seul
 * point de saisie des numeros (les colonnes phonePrimary/phoneSecondary sont en
 * lecture seule).
 *
 * La security d'operation (transport.carriers.manage) est appliquee par API
 * Platform en amont, de meme que la validation Symfony des contraintes d'attribut
 * (Assert\Email, Assert\Length...).
 *
 * @implements ProcessorInterface<CarrierContact, null|CarrierContact>
 */
final class CarrierContactProcessor implements ProcessorInterface
{
    /** RG-4.08 : nombre maximal de telephones par contact. */
    private const int MAX_PHONES = 2;

    /** Longueur max d'un telephone normalise (colonne VARCHAR(20)). */
    private const int PHONE_MAX_LENGTH = 20;

    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 CarrierFieldNormalizer $normalizer,
        private readonly EntityManagerInterface $em,
    ) {}

    public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
    {
        if (!$data instanceof CarrierContact) {
            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->normalize($data);
        $this->applyPhones($data);
        $this->validateAtLeastOneField($data);

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

    /**
     * Rattache le contact au transporteur parent de la sous-ressource POST
     * (/carriers/{carrierId}/contacts) : la relation n'est pas peuplee
     * automatiquement par le Link sur une ecriture. Sur PATCH (entite existante),
     * le transporteur est deja present -> no-op.
     */
    private function linkParent(CarrierContact $contact, array $uriVariables): void
    {
        if (null !== $contact->getCarrier()) {
            return;
        }

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

        $carrier = $carrierId instanceof Carrier
            ? $carrierId
            : $this->em->getRepository(Carrier::class)->find($carrierId);

        // 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 carrier_id NOT NULL).
        if (!$carrier instanceof Carrier) {
            throw new NotFoundHttpException('Transporteur introuvable.');
        }

        $contact->setCarrier($carrier);
    }

    /**
     * Normalisation serveur RG-4.13 des champs texte. Toutes les methodes du
     * normalizer sont null-safe : une chaine vide apres trim devient null (donc la
     * garde RG-4.08 detecte bien « champ non rempli »). Les telephones sont
     * traites a part (applyPhones).
     */
    private function normalize(CarrierContact $contact): void
    {
        $contact->setFirstName($this->normalizer->normalizePersonName($contact->getFirstName()));
        $contact->setLastName($this->normalizer->normalizePersonName($contact->getLastName()));
        $contact->setJobTitle($this->blankToNull($contact->getJobTitle()));
        $contact->setEmail($this->normalizer->normalizeEmail($contact->getEmail()));
    }

    /**
     * Mappe le tableau d'ecriture `phones` (max 2, RG-4.08) vers phonePrimary /
     * phoneSecondary apres normalisation RG-4.13 (chiffres uniquement). Les
     * numeros vides (sans chiffre) sont ecartes. null = champ non fourni (PATCH
     * partiel) -> on ne touche pas aux telephones existants. Un 3e numero
     * exploitable, ou un numero trop long (> colonne VARCHAR(20)), -> 422 sur
     * `phones`.
     */
    private function applyPhones(CarrierContact $contact): void
    {
        $phones = $contact->getPhones();
        if (null === $phones) {
            return;
        }

        $normalized = [];
        foreach ($phones as $phone) {
            $digits = $this->normalizer->normalizePhone(is_string($phone) ? $phone : null);
            if (null !== $digits) {
                $normalized[] = $digits;
            }
        }

        $violations = new ConstraintViolationList();
        if (self::MAX_PHONES < count($normalized)) {
            $violations->add(new ConstraintViolation(
                'Un contact ne peut comporter plus de deux téléphones.',
                null,
                [],
                $contact,
                'phones',
                $phones,
            ));
        }
        foreach ($normalized as $digits) {
            if (self::PHONE_MAX_LENGTH < mb_strlen($digits)) {
                $violations->add(new ConstraintViolation(
                    'Un numéro de téléphone ne peut dépasser '.self::PHONE_MAX_LENGTH.' caractères.',
                    null,
                    [],
                    $contact,
                    'phones',
                    $phones,
                ));

                break;
            }
        }

        if (0 < $violations->count()) {
            throw new ValidationException($violations);
        }

        $contact->setPhonePrimary($normalized[0] ?? null);
        $contact->setPhoneSecondary($normalized[1] ?? null);
        // Nettoie le champ virtuel (non persiste, mais evite toute fuite ulterieure).
        $contact->setPhones(null);
    }

    /**
     * RG-4.08 : un bloc Contact est valide des qu'au moins 1 champ est rempli
     * (firstName, lastName, jobTitle, phonePrimary ou email — meme perimetre que
     * le CHECK BDD chk_carrier_contact_filled, qui exclut phoneSecondary). Double
     * garde : leve une 422 propre rattachee a `firstName` plutot qu'une 500 SQL.
     * Joue apres normalisation + mapping telephones, donc les chaines vides sont
     * deja ramenees a null.
     */
    private function validateAtLeastOneField(CarrierContact $contact): void
    {
        if (
            null === $contact->getFirstName()
            && null === $contact->getLastName()
            && null === $contact->getJobTitle()
            && null === $contact->getPhonePrimary()
            && null === $contact->getEmail()
        ) {
            $violations = new ConstraintViolationList();
            $violations->add(new ConstraintViolation(
                'Renseignez au moins un champ pour le contact.',
                null,
                [],
                $contact,
                'firstName',
                null,
            ));

            throw new ValidationException($violations);
        }
    }

    /**
     * Trim + chaine vide -> null (la fonction n'est pas normalisee en casse,
     * contrairement aux noms de personne). Garantit que RG-4.08 detecte un champ
     * « non rempli » meme si le client envoie une chaine vide.
     */
    private function blankToNull(?string $value): ?string
    {
        if (null === $value) {
            return null;
        }

        $value = trim($value);

        return '' === $value ? null : $value;
    }
}
