<?php

declare(strict_types=1);

namespace App\Module\Transport\Domain\Entity;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\Transport\Infrastructure\ApiPlatform\State\Processor\CarrierProcessor;
use App\Module\Transport\Infrastructure\ApiPlatform\State\Provider\CarrierProvider;
use App\Module\Transport\Infrastructure\Doctrine\DoctrineCarrierRepository;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Entity\UploadedDocument;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Attribute\SerializedName;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;

/**
 * Transporteur (M4 Transport) — entite racine du repertoire transporteurs,
 * jumelle de Supplier (M2) / Provider (M3). Porte le formulaire principal, le
 * lien editable vers le referentiel QUALIMAT (§ 2.5), l'archivage
 * (is_archived / archived_at) et le soft-delete technique prepare mais non
 * expose au M4 (deleted_at).
 *
 * Perimetre WT4 (ERP-158) = formulaire principal en ecriture. L'#[ApiResource]
 * expose desormais Post + Patch (via CarrierProcessor : normalisation RG-4.13,
 * gating archive mode strict RG-4.14, 409 doublon de nom RG-4.12) en plus du
 * contrat de lecture pose au WT3. Les proprietes du formulaire principal portent
 * leur groupe d'ecriture (carrier:write:main / carrier:write:archive) et leurs
 * contraintes Assert ; les RG conditionnelles (RG-4.01 certification obligatoire
 * sauf LIOT, RG-4.02 AUTRE -> decharge, RG-4.03 affrete -> indexation/benne/volume)
 * sont portees par validateMainFormConsistency (Assert\Callback + ->atPath()).
 * Les sous-ressources d'ecriture (adresses/contacts/prix) arrivent aux worktrees
 * suivants (WT6/7/8). Les invariants BDD (NOT NULL, CHECK enum, FK, unicite
 * partielle) restent garantis par la migration Version20260615150000.
 *
 * Contrat de serialisation (RETEX M1, 3 maillons — spec § 4.0) :
 *  - LISTE (carrier:read + qualimat:read + default:read) : name, certificationType,
 *    qualimatCarrier (statut/validite — RG-4.04), updatedAt.
 *  - DETAIL (+ carrier:item:read + embeds client/supplier/site...) : sous-collections
 *    addresses / contacts / prices embarquees, avec les entites cross-module
 *    (Client/Supplier/Site/adresses) serialisees via leurs read-groups.
 *
 * Pas de #[ORM\UniqueConstraint] : l'unicite du nom (RG-4.12) est portee par
 * l'index partiel fonctionnel uq_carrier_name_active (LOWER(name) WHERE
 * is_archived = FALSE AND deleted_at IS NULL), inexprimable en attribut ORM.
 */
#[ApiResource(
    operations: [
        new GetCollection(
            security: "is_granted('transport.carriers.view')",
            // Liste : embarque qualimatCarrier (ManyToOne, fetch-join sur cette
            // seule relation cote repository — § 2.11) pour le statut/date de
            // validite QUALIMAT (RG-4.04). Aucune sous-collection en liste.
            normalizationContext: ['groups' => ['carrier:read', 'qualimat:read', 'default:read']],
            provider: CarrierProvider::class,
        ),
        new Get(
            security: "is_granted('transport.carriers.view')",
            // Detail : transporteur + qualimatCarrier + sous-collections embarquees
            // (addresses / contacts / prices). Les relations cross-module des prix
            // (client / supplier / sites / adresses) sont embarquees via leurs
            // read-groups (client:read / supplier:read / ... — bugs #1/#2 M1).
            normalizationContext: ['groups' => [
                'carrier:read',
                'carrier:item:read',
                'qualimat:read',
                'client:read',
                'client_address:read',
                'supplier:read',
                'supplier_address:read',
                'site:read',
                'default:read',
            ]],
            provider: CarrierProvider::class,
        ),
        new Post(
            // Creation du formulaire principal (RG-4.01 -> RG-4.03 / RG-4.12 /
            // RG-4.13). La reponse 201 ne renvoie que les scalaires principaux +
            // id : le front enchaine ensuite les sous-ressources par onglet.
            security: "is_granted('transport.carriers.manage')",
            normalizationContext: ['groups' => ['carrier:read', 'default:read']],
            denormalizationContext: ['groups' => ['carrier:write:main']],
            processor: CarrierProcessor::class,
        ),
        new Patch(
            // Security elargie au seul `manage` (Admin + Bureau). Le CarrierProcessor
            // re-gate ensuite l'archivage : un payload basculant isArchived exige
            // `transport.carriers.archive` (Admin seul -> Bureau recoit 403, mode
            // strict RG-4.14).
            security: "is_granted('transport.carriers.manage')",
            normalizationContext: ['groups' => ['carrier:read', 'default:read']],
            denormalizationContext: ['groups' => ['carrier:write:main', 'carrier:write:archive']],
            provider: CarrierProvider::class,
            processor: CarrierProcessor::class,
        ),
        // Pas de Delete au M4 (HP-M4-C). Archivage via PATCH { isArchived: true }.
    ],
)]
#[ORM\Entity(repositoryClass: DoctrineCarrierRepository::class)]
#[ORM\Table(name: 'carrier')]
#[ORM\Index(name: 'idx_carrier_is_archived', columns: ['is_archived'])]
#[ORM\Index(name: 'idx_carrier_deleted_at', columns: ['deleted_at'])]
#[ORM\Index(name: 'idx_carrier_qualimat', columns: ['qualimat_carrier_id'])]
#[ORM\Index(name: 'idx_carrier_discharge_document', columns: ['discharge_document_id'])]
#[ORM\Index(name: 'idx_carrier_created_by', columns: ['created_by'])]
#[ORM\Index(name: 'idx_carrier_updated_by', columns: ['updated_by'])]
#[Auditable]
class Carrier implements TimestampableInterface, BlamableInterface
{
    use TimestampableBlamableTrait;

    /** RG-4.01 : nom du cas special « compte-propre » LIOT (comparaison insensible a la casse). */
    private const string LIOT_NAME = 'LIOT';

    /** RG-4.02 : valeur de certification imposant le champ Decharge. */
    private const string CERTIFICATION_AUTRE = 'AUTRE';

    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    #[Groups(['carrier:read'])]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    #[Assert\NotBlank(message: 'Le nom du transporteur est obligatoire.', normalizer: 'trim')]
    #[Assert\Length(
        min: 2,
        max: 255,
        minMessage: 'Le nom du transporteur doit contenir au moins {{ limit }} caractères.',
        maxMessage: 'Le nom du transporteur ne peut dépasser {{ limit }} caractères.',
        normalizer: 'trim',
    )]
    #[Groups(['carrier:read', 'carrier:write:main'])]
    private ?string $name = null;

    /** Lien editable vers le referentiel QUALIMAT (saisie assistee RG-4.01, § 2.5). */
    #[ORM\ManyToOne(targetEntity: QualimatCarrier::class)]
    #[ORM\JoinColumn(name: 'qualimat_carrier_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
    #[Groups(['carrier:read', 'carrier:write:main'])]
    private ?QualimatCarrier $qualimatCarrier = null;

    /** QUALIMAT|GMP_PLUS|OVOCOM|COMPTE_PROPRE|AUTRE ; null en cas LIOT (RG-4.01). */
    #[ORM\Column(length: 20, nullable: true)]
    #[Assert\Choice(
        choices: ['QUALIMAT', 'GMP_PLUS', 'OVOCOM', 'COMPTE_PROPRE', 'AUTRE'],
        message: 'Type de certification invalide.',
    )]
    // Obligatoire SAUF en cas LIOT (champ masque) : controle conditionnel dans
    // validateMainFormConsistency (RG-4.01). La longueur est bornee par le Choice
    // (whitelist EntityConstraintsHaveFrenchMessageTest::EXCLUDED_LENGTH_MIRROR).
    #[Groups(['carrier:read', 'carrier:write:main'])]
    private ?string $certificationType = null;

    #[ORM\Column(name: 'is_chartered', options: ['default' => false])]
    #[Groups(['carrier:write:main'])]
    private bool $isChartered = false;

    /** % d'indexation — obligatoire si affrete (RG-4.03, validateMainFormConsistency). */
    #[ORM\Column(name: 'indexation_rate', type: 'decimal', precision: 5, scale: 2, nullable: true)]
    #[Groups(['carrier:read', 'carrier:write:main'])]
    private ?string $indexationRate = null;

    /** BENNE|FOND_MOUVANT — obligatoire si affrete (RG-4.03). */
    #[ORM\Column(name: 'container_type', length: 12, nullable: true)]
    #[Assert\Choice(choices: ['BENNE', 'FOND_MOUVANT'], message: 'Type de contenant invalide.')]
    // Longueur bornee par le Choice (whitelist EXCLUDED_LENGTH_MIRROR).
    #[Groups(['carrier:read', 'carrier:write:main'])]
    private ?string $containerType = null;

    /** Volume m3 — obligatoire si affrete (RG-4.03). */
    #[ORM\Column(name: 'volume_m3', type: 'decimal', precision: 10, scale: 2, nullable: true)]
    #[Groups(['carrier:read', 'carrier:write:main'])]
    private ?string $volumeM3 = null;

    /** Decharge (upload, visible si certificationType = AUTRE — RG-4.02). Infra Shared (§ 2.7). */
    #[ORM\ManyToOne(targetEntity: UploadedDocument::class)]
    #[ORM\JoinColumn(name: 'discharge_document_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
    #[Groups(['carrier:read', 'carrier:write:main'])]
    private ?UploadedDocument $dischargeDocument = null;

    /** Immatriculations LIOT separees par « ; » (cas LIOT — RG-4.01). */
    #[ORM\Column(name: 'liot_plates', type: 'text', nullable: true)]
    #[Groups(['carrier:read', 'carrier:write:main'])]
    private ?string $liotPlates = null;

    // === Sous-collections — EMBARQUEES dans le DETAIL (read-group sur le getter) ===
    /** @var Collection<int, CarrierAddress> */
    #[ORM\OneToMany(mappedBy: 'carrier', targetEntity: CarrierAddress::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
    private Collection $addresses;

    /** @var Collection<int, CarrierContact> */
    #[ORM\OneToMany(mappedBy: 'carrier', targetEntity: CarrierContact::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
    private Collection $contacts;

    /** @var Collection<int, CarrierPrice> */
    #[ORM\OneToMany(mappedBy: 'carrier', targetEntity: CarrierPrice::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
    private Collection $prices;

    // === Archive / Soft delete ===
    // Le groupe de LECTURE est declare sur le getter isArchived() avec
    // SerializedName('isArchived') (piege booleen #3 M1) ; le groupe d'ECRITURE
    // vit sur la propriete pour que la denormalisation cible setIsArchived.
    #[ORM\Column(name: 'is_archived', options: ['default' => false])]
    #[Groups(['carrier:write:archive'])]
    private bool $isArchived = false;

    #[ORM\Column(name: 'archived_at', type: 'datetime_immutable', nullable: true)]
    #[Groups(['carrier:read'])]
    private ?DateTimeImmutable $archivedAt = null;

    #[ORM\Column(name: 'deleted_at', type: 'datetime_immutable', nullable: true)]
    private ?DateTimeImmutable $deletedAt = null;

    public function __construct()
    {
        $this->addresses = new ArrayCollection();
        $this->contacts  = new ArrayCollection();
        $this->prices    = new ArrayCollection();
    }

    /**
     * Coherence conditionnelle du formulaire principal (RG-4.01 / RG-4.02 /
     * RG-4.03). Decision figee (miroir M2 RG-2.07/2.08) : ces RG inter-champs
     * passent par une contrainte d'entite (Assert\Callback + ->atPath()) et NON par
     * le CarrierProcessor, afin que chaque 422 porte un propertyPath exploitable
     * par useFormErrors (mapping inline sous le champ, pas un toast — ERP-101).
     * Jouee par API Platform AVANT le processor, sur POST comme sur PATCH.
     *
     * Cas LIOT (RG-4.01) : seul liotPlates est pertinent ; les autres champs sont
     * masques cote front et le back ne les valide pas (« stocke ce qu'il recoit,
     * pas de 422 sur la presence residuelle »). Le nom est compare en majuscules
     * car la normalisation UPPER n'intervient qu'au processor (apres validation).
     */
    #[Assert\Callback]
    public function validateMainFormConsistency(ExecutionContextInterface $context): void
    {
        if (self::LIOT_NAME === mb_strtoupper(trim((string) $this->name), 'UTF-8')) {
            return;
        }

        // RG-4.01 : certification obligatoire hors cas LIOT.
        if (null === $this->certificationType || '' === $this->certificationType) {
            $context->buildViolation('Le type de certification est obligatoire.')
                ->atPath('certificationType')
                ->addViolation()
            ;
        }

        // RG-4.02 : certification AUTRE -> decharge obligatoire.
        if (self::CERTIFICATION_AUTRE === $this->certificationType && null === $this->dischargeDocument) {
            $context->buildViolation('La décharge est obligatoire pour une certification « Autre ».')
                ->atPath('dischargeDocument')
                ->addViolation()
            ;
        }

        // RG-4.03 : affrete -> indexation + benne/fond mouvant + volume obligatoires.
        if ($this->isChartered) {
            if (null === $this->indexationRate) {
                $context->buildViolation('Le taux d\'indexation est obligatoire pour un transporteur affrété.')
                    ->atPath('indexationRate')
                    ->addViolation()
                ;
            }
            if (null === $this->containerType) {
                $context->buildViolation('Le type de contenant est obligatoire pour un transporteur affrété.')
                    ->atPath('containerType')
                    ->addViolation()
                ;
            }
            if (null === $this->volumeM3) {
                $context->buildViolation('Le volume est obligatoire pour un transporteur affrété.')
                    ->atPath('volumeM3')
                    ->addViolation()
                ;
            }
        }
    }

    public function getId(): ?int
    {
        return $this->id;
    }

    public function getName(): ?string
    {
        return $this->name;
    }

    public function setName(string $name): static
    {
        $this->name = $name;

        return $this;
    }

    public function getQualimatCarrier(): ?QualimatCarrier
    {
        return $this->qualimatCarrier;
    }

    public function setQualimatCarrier(?QualimatCarrier $qualimatCarrier): static
    {
        $this->qualimatCarrier = $qualimatCarrier;

        return $this;
    }

    public function getCertificationType(): ?string
    {
        return $this->certificationType;
    }

    public function setCertificationType(?string $certificationType): static
    {
        $this->certificationType = $certificationType;

        return $this;
    }

    // Boolean trap (RETEX M1 bug #3) : #[Groups] + #[SerializedName] sur le getter,
    // sinon Symfony strip le prefixe "is" et drope la cle du JSON.
    #[Groups(['carrier:read'])]
    #[SerializedName('isChartered')]
    public function isChartered(): bool
    {
        return $this->isChartered;
    }

    public function setIsChartered(bool $isChartered): static
    {
        $this->isChartered = $isChartered;

        return $this;
    }

    public function getIndexationRate(): ?string
    {
        return $this->indexationRate;
    }

    public function setIndexationRate(?string $indexationRate): static
    {
        $this->indexationRate = $indexationRate;

        return $this;
    }

    public function getContainerType(): ?string
    {
        return $this->containerType;
    }

    public function setContainerType(?string $containerType): static
    {
        $this->containerType = $containerType;

        return $this;
    }

    public function getVolumeM3(): ?string
    {
        return $this->volumeM3;
    }

    public function setVolumeM3(?string $volumeM3): static
    {
        $this->volumeM3 = $volumeM3;

        return $this;
    }

    public function getDischargeDocument(): ?UploadedDocument
    {
        return $this->dischargeDocument;
    }

    public function setDischargeDocument(?UploadedDocument $dischargeDocument): static
    {
        $this->dischargeDocument = $dischargeDocument;

        return $this;
    }

    public function getLiotPlates(): ?string
    {
        return $this->liotPlates;
    }

    public function setLiotPlates(?string $liotPlates): static
    {
        $this->liotPlates = $liotPlates;

        return $this;
    }

    /** @return Collection<int, CarrierAddress> */
    #[Groups(['carrier:item:read'])]
    public function getAddresses(): Collection
    {
        return $this->addresses;
    }

    public function addAddress(CarrierAddress $address): static
    {
        if (!$this->addresses->contains($address)) {
            $this->addresses->add($address);
            $address->setCarrier($this);
        }

        return $this;
    }

    public function removeAddress(CarrierAddress $address): static
    {
        if ($this->addresses->removeElement($address) && $address->getCarrier() === $this) {
            $address->setCarrier(null);
        }

        return $this;
    }

    /** @return Collection<int, CarrierContact> */
    #[Groups(['carrier:item:read'])]
    public function getContacts(): Collection
    {
        return $this->contacts;
    }

    public function addContact(CarrierContact $contact): static
    {
        if (!$this->contacts->contains($contact)) {
            $this->contacts->add($contact);
            $contact->setCarrier($this);
        }

        return $this;
    }

    public function removeContact(CarrierContact $contact): static
    {
        if ($this->contacts->removeElement($contact) && $contact->getCarrier() === $this) {
            $contact->setCarrier(null);
        }

        return $this;
    }

    /** @return Collection<int, CarrierPrice> */
    #[Groups(['carrier:item:read'])]
    public function getPrices(): Collection
    {
        return $this->prices;
    }

    public function addPrice(CarrierPrice $price): static
    {
        if (!$this->prices->contains($price)) {
            $this->prices->add($price);
            $price->setCarrier($this);
        }

        return $this;
    }

    public function removePrice(CarrierPrice $price): static
    {
        if ($this->prices->removeElement($price) && $price->getCarrier() === $this) {
            $price->setCarrier(null);
        }

        return $this;
    }

    // Boolean trap (cf. isChartered) : groupe de lecture + SerializedName sur le getter.
    #[Groups(['carrier:read'])]
    #[SerializedName('isArchived')]
    public function isArchived(): bool
    {
        return $this->isArchived;
    }

    public function setIsArchived(bool $isArchived): static
    {
        $this->isArchived = $isArchived;

        return $this;
    }

    public function getArchivedAt(): ?DateTimeImmutable
    {
        return $this->archivedAt;
    }

    public function setArchivedAt(?DateTimeImmutable $archivedAt): static
    {
        $this->archivedAt = $archivedAt;

        return $this;
    }

    public function getDeletedAt(): ?DateTimeImmutable
    {
        return $this->deletedAt;
    }

    public function setDeletedAt(?DateTimeImmutable $deletedAt): static
    {
        $this->deletedAt = $deletedAt;

        return $this;
    }
}
