<?php

declare(strict_types=1);

namespace App\Module\Commercial\Domain\Entity;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\Commercial\Infrastructure\ApiPlatform\State\Processor\ClientAddressProcessor;
use App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientAddressRepository;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\CategoryInterface;
use App\Shared\Domain\Contract\ClientAddressInterface;
use App\Shared\Domain\Contract\SiteInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
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;

/**
 * Adresse d'un client (1:n) — onglet Adresse. Une adresse de prospection
 * (isProspect) est exclusive d'une adresse de livraison/facturation
 * (RG-1.06/07/08). Un email de facturation est obligatoire ssi isBilling
 * (RG-1.11). Au moins un site doit etre rattache (RG-1.10, Assert\Count). Ces
 * regles sont portees par des Assert\Callback (cf. validateProspectExclusivity /
 * validateBillingEmailPresence, ERP-76) qui remontent une 422 avant la base ;
 * les CHECK Postgres (chk_client_address_prospect_exclusive /
 * chk_client_address_billing_email) restent en filet de securite.
 *
 * Relations M2M :
 *  - sites : SiteInterface (module Sites) via resolve_target_entities
 *  - contacts : ClientContact (meme module)
 *  - categories : CategoryInterface (module Catalog) via resolve_target_entities
 *    — codes DISTRIBUTEUR/COURTIER interdits (RG-1.29, validateCategoryCodes, ERP-78)
 *
 * Audite (#[Auditable]) + Timestampable/Blamable.
 *
 * Sous-ressource API (ERP-57, spec § 4.5) :
 *  - POST /api/clients/{clientId}/addresses : creation rattachee au client parent
 *    (Link toProperty 'client'), security commercial.clients.manage.
 *  - PATCH / DELETE /api/client_addresses/{id} : security commercial.clients.manage.
 *  - GET /api/client_addresses/{id} : lecture unitaire (security view) — la
 *    lecture courante reste via le parent. Pas de GET collection autonome.
 * Tout passe par le ClientAddressProcessor (normalisation RG-1.21 billingEmail).
 */
#[ApiResource(
    operations: [
        new Get(
            security: "is_granted('commercial.clients.view')",
            normalizationContext: ['groups' => ['client_address:read']],
        ),
        new Post(
            uriTemplate: '/clients/{clientId}/addresses',
            uriVariables: [
                'clientId' => new Link(fromClass: Client::class, toProperty: 'client'),
            ],
            // read:false : pas de stade lecture du parent. Le Link toProperty
            // resoudrait l'enfant (SELECT ClientAddress ... WHERE client = :id) et
            // casse en NonUniqueResult des >= 2 enfants. Le parent est rattache
            // manuellement par ClientAddressProcessor::linkParent (404 si absent).
            read: false,
            security: "is_granted('commercial.clients.manage')",
            normalizationContext: ['groups' => ['client_address:read']],
            denormalizationContext: ['groups' => ['client_address:write']],
            processor: ClientAddressProcessor::class,
        ),
        new Patch(
            security: "is_granted('commercial.clients.manage')",
            normalizationContext: ['groups' => ['client_address:read']],
            denormalizationContext: ['groups' => ['client_address:write']],
            processor: ClientAddressProcessor::class,
        ),
        new Delete(
            security: "is_granted('commercial.clients.manage')",
            processor: ClientAddressProcessor::class,
        ),
    ],
)]
#[ORM\Entity(repositoryClass: DoctrineClientAddressRepository::class)]
#[ORM\Table(name: 'client_address')]
#[ORM\Index(name: 'idx_client_address_client', columns: ['client_id'])]
#[Auditable]
class ClientAddress implements TimestampableInterface, BlamableInterface, ClientAddressInterface
{
    use TimestampableBlamableTrait;

    /**
     * RG-1.29 (ERP-78) : ces codes de categorie decrivent une relation entre
     * clients (distributeur / courtier) et n'ont pas de sens sur une adresse.
     * Toute autre categorie du type CLIENT est autorisee.
     */
    private const array FORBIDDEN_CATEGORY_CODES = ['DISTRIBUTEUR', 'COURTIER'];

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

    #[ORM\ManyToOne(targetEntity: Client::class, inversedBy: 'addresses')]
    #[ORM\JoinColumn(name: 'client_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
    private ?Client $client = null;

    // Groupe d'ECRITURE uniquement sur la propriete (denormalisation PATCH/POST).
    // Le groupe de LECTURE est porte par le getter isProspect()/isDelivery()/
    // isBilling() avec SerializedName : sans cela, Symfony strip le prefixe "is"
    // des getters booleens et exposerait les cles "prospect"/"delivery"/"billing"
    // — en pratique le #[Groups] etant sur la propriete `isX` et le getter
    // derivant l'attribut `x`, la cle etait totalement DROPPEE du JSON (meme bug
    // que Client::isArchived). Pattern corrige : Groups + SerializedName sur le getter.
    #[ORM\Column(name: 'is_prospect', options: ['default' => false])]
    #[Groups(['client_address:write'])]
    private bool $isProspect = false;

    #[ORM\Column(name: 'is_delivery', options: ['default' => false])]
    #[Groups(['client_address:write'])]
    private bool $isDelivery = false;

    #[ORM\Column(name: 'is_billing', options: ['default' => false])]
    #[Groups(['client_address:write'])]
    private bool $isBilling = false;

    // Adresse Courtier / Distributeur : types autonomes (comme Prospection),
    // exclusifs de tout autre usage (validateExclusiveAddressTypes + CHECK BDD
    // chk_client_address_broker_exclusive / chk_client_address_distributor_exclusive).
    // Lecture portee par le getter + SerializedName (meme pattern que isProspect).
    #[ORM\Column(name: 'is_broker', options: ['default' => false])]
    #[Groups(['client_address:write'])]
    private bool $isBroker = false;

    #[ORM\Column(name: 'is_distributor', options: ['default' => false])]
    #[Groups(['client_address:write'])]
    private bool $isDistributor = false;

    #[ORM\Column(length: 80, options: ['default' => 'France'])]
    #[Assert\Length(max: 80, maxMessage: 'Le pays ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
    #[Groups(['client_address:read', 'client_address:write'])]
    private string $country = 'France';

    // RG-1.09 : code postal a 4 ou 5 chiffres (pas de controle CP/ville serveur).
    // Le Regex borne deja la longueur (≤ 5) : pas de Length redondant.
    #[ORM\Column(length: 20)]
    #[Assert\NotBlank(message: 'Le code postal est obligatoire.', normalizer: 'trim')]
    #[Assert\Regex(pattern: '/^[0-9]{4,5}$/', message: 'Le code postal doit comporter 4 ou 5 chiffres.')]
    #[Groups(['client_address:read', 'client_address:write'])]
    private ?string $postalCode = null;

    #[ORM\Column(length: 120)]
    #[Assert\NotBlank(message: 'La ville est obligatoire.', normalizer: 'trim')]
    #[Assert\Length(max: 120, maxMessage: 'La ville ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
    #[Groups(['client_address:read', 'client_address:write'])]
    private ?string $city = null;

    #[ORM\Column(length: 255)]
    #[Assert\NotBlank(message: 'La rue est obligatoire.', normalizer: 'trim')]
    #[Assert\Length(max: 255, maxMessage: 'La rue ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
    #[Groups(['client_address:read', 'client_address:write'])]
    private ?string $street = null;

    #[ORM\Column(length: 255, nullable: true)]
    #[Assert\Length(max: 255, maxMessage: 'Le complément d\'adresse ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
    #[Groups(['client_address:read', 'client_address:write'])]
    private ?string $streetComplement = null;

    // RG-1.11 : obligatoire ssi isBilling (validateBillingEmailPresence + CHECK BDD).
    #[ORM\Column(length: 180, nullable: true)]
    #[Assert\Email(message: 'L\'email de facturation n\'est pas valide.')]
    #[Assert\Length(max: 180, maxMessage: 'L\'email de facturation ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
    #[Groups(['client_address:read', 'client_address:write'])]
    private ?string $billingEmail = null;

    // 2e email de facturation, optionnel (max 2 — pendant du telephone secondaire).
    // Comme le principal : interdit hors facturation (validateBillingEmailPresence),
    // mais jamais obligatoire. Normalise en lowercase par le ClientAddressProcessor.
    #[ORM\Column(length: 180, nullable: true)]
    #[Assert\Email(message: 'L\'email de facturation secondaire n\'est pas valide.')]
    #[Assert\Length(max: 180, maxMessage: 'L\'email de facturation secondaire ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
    #[Groups(['client_address:read', 'client_address:write'])]
    private ?string $billingEmailSecondary = null;

    #[ORM\Column(options: ['default' => 0])]
    #[Groups(['client_address:read', 'client_address:write'])]
    private int $position = 0;

    // RG-1.10 : au moins un site rattache a chaque adresse.
    /** @var Collection<int, SiteInterface> */
    #[ORM\ManyToMany(targetEntity: SiteInterface::class)]
    #[ORM\JoinTable(name: 'client_address_site')]
    #[ORM\JoinColumn(name: 'client_address_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
    #[ORM\InverseJoinColumn(name: 'site_id', referencedColumnName: 'id', onDelete: 'RESTRICT')]
    #[Assert\Count(min: 1, minMessage: 'Au moins un site est obligatoire.')]
    #[Groups(['client_address:read', 'client_address:write'])]
    private Collection $sites;

    /** @var Collection<int, ClientContact> */
    #[ORM\ManyToMany(targetEntity: ClientContact::class)]
    #[ORM\JoinTable(name: 'client_address_contact')]
    #[ORM\JoinColumn(name: 'client_address_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
    #[ORM\InverseJoinColumn(name: 'client_contact_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
    #[Groups(['client_address:read', 'client_address:write'])]
    private Collection $contacts;

    // Au moins une categorie est obligatoire sur une adresse (spec-front § Adresse).
    // RG-1.29 : categories de code DISTRIBUTEUR/COURTIER interdites (validateCategoryCodes).
    /** @var Collection<int, CategoryInterface> */
    #[ORM\ManyToMany(targetEntity: CategoryInterface::class)]
    #[ORM\JoinTable(name: 'client_address_category')]
    #[ORM\JoinColumn(name: 'client_address_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
    #[ORM\InverseJoinColumn(name: 'category_id', referencedColumnName: 'id', onDelete: 'RESTRICT')]
    #[Assert\Count(min: 1, minMessage: 'Au moins une catégorie est obligatoire.')]
    #[Groups(['client_address:read', 'client_address:write'])]
    private Collection $categories;

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

    /**
     * RG-1.06 / RG-1.07 / RG-1.08 : une adresse de prospection est exclusive
     * d'une adresse de livraison ou de facturation. Mirror applicatif (422) du
     * CHECK chk_client_address_prospect_exclusive, joue avant la base afin de
     * remonter une violation Hydra plutot qu'une 500 DBAL.
     */
    #[Assert\Callback]
    public function validateProspectExclusivity(ExecutionContextInterface $context): void
    {
        if ($this->isProspect && ($this->isDelivery || $this->isBilling)) {
            $context->buildViolation('Une adresse de prospection ne peut pas être une adresse de livraison ni de facturation.')
                ->atPath('isProspect')
                ->addViolation()
            ;
        }
    }

    /**
     * Au moins un type d'adresse est obligatoire (Prospection, Livraison ou
     * Facturation) : une adresse sans aucun drapeau pose n'a pas de sens metier.
     * La violation est portee sur `isProspect` (meme champ que l'exclusivite) pour
     * un mapping inline sous le select « Type d'adresse » cote front (ERP-119).
     */
    #[Assert\Callback]
    public function validateAddressTypeRequired(ExecutionContextInterface $context): void
    {
        if (!$this->isProspect && !$this->isDelivery && !$this->isBilling && !$this->isBroker && !$this->isDistributor) {
            $context->buildViolation('Le type d\'adresse est obligatoire.')
                ->atPath('isProspect')
                ->addViolation()
            ;
        }
    }

    /**
     * Courtier et Distributeur sont des types d'adresse AUTONOMES (comme la
     * Prospection) : exclusifs de tout autre usage (Livraison / Facturation /
     * Prospection / l'autre type autonome). Mirror applicatif (422) des CHECK
     * chk_client_address_broker_exclusive / chk_client_address_distributor_exclusive.
     * Violation portee sur `isProspect` (mappee sous le select « Type d'adresse »).
     */
    #[Assert\Callback]
    public function validateExclusiveAddressTypes(ExecutionContextInterface $context): void
    {
        if ($this->isBroker && ($this->isProspect || $this->isDelivery || $this->isBilling || $this->isDistributor)) {
            $context->buildViolation('Une adresse Courtier ne peut pas avoir d\'autre type.')
                ->atPath('isProspect')
                ->addViolation()
            ;
        }

        if ($this->isDistributor && ($this->isProspect || $this->isDelivery || $this->isBilling || $this->isBroker)) {
            $context->buildViolation('Une adresse Distributeur ne peut pas avoir d\'autre type.')
                ->atPath('isProspect')
                ->addViolation()
            ;
        }
    }

    /**
     * RG-1.11 : l'email de facturation est obligatoire si l'adresse est de
     * facturation, et interdit sinon. Mirror applicatif (422) du CHECK
     * chk_client_address_billing_email.
     *
     * On raisonne sur la PRESENCE effective de l'email : null ET chaine vide
     * sont traites comme « absent », car le ClientAddressProcessor normalise une
     * chaine vide en null APRES la validation (RG-1.21). Sans ce traitement,
     * billingEmail="" passerait les callbacks (null === "" est faux) puis serait
     * persiste en null avec is_billing=true -> violation du CHECK -> 500 au lieu
     * du 422 attendu (et symetriquement, "" sur une adresse non facturable
     * serait rejete a tort).
     */
    #[Assert\Callback]
    public function validateBillingEmailPresence(ExecutionContextInterface $context): void
    {
        $hasBillingEmail = null !== $this->billingEmail && '' !== trim($this->billingEmail);

        if ($this->isBilling && !$hasBillingEmail) {
            $context->buildViolation('L\'email de facturation est obligatoire pour une adresse de facturation.')
                ->atPath('billingEmail')
                ->addViolation()
            ;
        }

        if (!$this->isBilling && $hasBillingEmail) {
            $context->buildViolation('L\'email de facturation n\'est autorisé que sur une adresse de facturation.')
                ->atPath('billingEmail')
                ->addViolation()
            ;
        }

        // Le 2e email est OPTIONNEL (jamais requis), mais comme le principal il
        // n'a de sens que sur une adresse de facturation.
        $hasSecondaryEmail = null !== $this->billingEmailSecondary && '' !== trim($this->billingEmailSecondary);
        if (!$this->isBilling && $hasSecondaryEmail) {
            $context->buildViolation('L\'email de facturation n\'est autorisé que sur une adresse de facturation.')
                ->atPath('billingEmailSecondary')
                ->addViolation()
            ;
        }
    }

    /**
     * RG-1.29 (ERP-78) : une adresse interdit les categories de code
     * DISTRIBUTEUR / COURTIER — elles decrivent une relation entre clients
     * (RG-1.03) et n'ont pas de sens sur une adresse physique -> 422 avec
     * violation sur le champ `categories`. Toute autre categorie (type unique
     * CLIENT) est acceptee. S'appuie sur CategoryInterface::getCode() (pas
     * d'import du module Catalog — regle ABSOLUE n°1).
     */
    #[Assert\Callback]
    public function validateCategoryCodes(ExecutionContextInterface $context): void
    {
        foreach ($this->categories as $category) {
            if ($category instanceof CategoryInterface
                && in_array($category->getCode(), self::FORBIDDEN_CATEGORY_CODES, true)) {
                $context->buildViolation('Type de catégorie non autorisé sur une adresse.')
                    ->atPath('categories')
                    ->addViolation()
                ;

                return;
            }
        }
    }

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

    public function getClient(): ?Client
    {
        return $this->client;
    }

    public function setClient(?Client $client): static
    {
        $this->client = $client;

        return $this;
    }

    // Groupe de lecture + nom serialise explicite (cf. note sur la propriete) :
    // sans SerializedName, Symfony exposerait la cle "prospect" (strip du prefixe
    // "is" sur les getters) et, le groupe etant declare sur la propriete `isProspect`,
    // droppait silencieusement la cle du JSON.
    #[Groups(['client_address:read'])]
    #[SerializedName('isProspect')]
    public function isProspect(): bool
    {
        return $this->isProspect;
    }

    public function setIsProspect(bool $isProspect): static
    {
        $this->isProspect = $isProspect;

        return $this;
    }

    #[Groups(['client_address:read'])]
    #[SerializedName('isDelivery')]
    public function isDelivery(): bool
    {
        return $this->isDelivery;
    }

    public function setIsDelivery(bool $isDelivery): static
    {
        $this->isDelivery = $isDelivery;

        return $this;
    }

    #[Groups(['client_address:read'])]
    #[SerializedName('isBilling')]
    public function isBilling(): bool
    {
        return $this->isBilling;
    }

    public function setIsBilling(bool $isBilling): static
    {
        $this->isBilling = $isBilling;

        return $this;
    }

    #[Groups(['client_address:read'])]
    #[SerializedName('isBroker')]
    public function isBroker(): bool
    {
        return $this->isBroker;
    }

    public function setIsBroker(bool $isBroker): static
    {
        $this->isBroker = $isBroker;

        return $this;
    }

    #[Groups(['client_address:read'])]
    #[SerializedName('isDistributor')]
    public function isDistributor(): bool
    {
        return $this->isDistributor;
    }

    public function setIsDistributor(bool $isDistributor): static
    {
        $this->isDistributor = $isDistributor;

        return $this;
    }

    public function getCountry(): string
    {
        return $this->country;
    }

    public function setCountry(string $country): static
    {
        $this->country = $country;

        return $this;
    }

    public function getPostalCode(): ?string
    {
        return $this->postalCode;
    }

    public function setPostalCode(?string $postalCode): static
    {
        $this->postalCode = $postalCode;

        return $this;
    }

    public function getCity(): ?string
    {
        return $this->city;
    }

    public function setCity(?string $city): static
    {
        $this->city = $city;

        return $this;
    }

    public function getStreet(): ?string
    {
        return $this->street;
    }

    public function setStreet(?string $street): static
    {
        $this->street = $street;

        return $this;
    }

    public function getStreetComplement(): ?string
    {
        return $this->streetComplement;
    }

    public function setStreetComplement(?string $streetComplement): static
    {
        $this->streetComplement = $streetComplement;

        return $this;
    }

    public function getBillingEmail(): ?string
    {
        return $this->billingEmail;
    }

    public function setBillingEmail(?string $billingEmail): static
    {
        $this->billingEmail = $billingEmail;

        return $this;
    }

    public function getBillingEmailSecondary(): ?string
    {
        return $this->billingEmailSecondary;
    }

    public function setBillingEmailSecondary(?string $billingEmailSecondary): static
    {
        $this->billingEmailSecondary = $billingEmailSecondary;

        return $this;
    }

    public function getPosition(): int
    {
        return $this->position;
    }

    public function setPosition(int $position): static
    {
        $this->position = $position;

        return $this;
    }

    /** @return Collection<int, SiteInterface> */
    public function getSites(): Collection
    {
        return $this->sites;
    }

    public function addSite(SiteInterface $site): static
    {
        if (!$this->sites->contains($site)) {
            $this->sites->add($site);
        }

        return $this;
    }

    public function removeSite(SiteInterface $site): static
    {
        $this->sites->removeElement($site);

        return $this;
    }

    /** @return Collection<int, ClientContact> */
    public function getContacts(): Collection
    {
        return $this->contacts;
    }

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

        return $this;
    }

    public function removeContact(ClientContact $contact): static
    {
        $this->contacts->removeElement($contact);

        return $this;
    }

    /** @return Collection<int, CategoryInterface> */
    public function getCategories(): Collection
    {
        return $this->categories;
    }

    public function addCategory(CategoryInterface $category): static
    {
        if (!$this->categories->contains($category)) {
            $this->categories->add($category);
        }

        return $this;
    }

    public function removeCategory(CategoryInterface $category): static
    {
        $this->categories->removeElement($category);

        return $this;
    }
}
