<?php

declare(strict_types=1);

namespace App\Module\Commercial\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\Commercial\Infrastructure\ApiPlatform\State\Processor\ClientProcessor;
use App\Module\Commercial\Infrastructure\ApiPlatform\State\Provider\ClientProvider;
use App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientRepository;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\CategoryInterface;
use App\Shared\Domain\Contract\ClientInterface;
use App\Shared\Domain\Contract\SiteInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
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\Context;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Attribute\SerializedName;
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;

/**
 * Client (M1 Commercial) — entite racine du repertoire clients. Porte le
 * formulaire principal, l'onglet Information, l'onglet Comptabilite, le
 * mecanisme d'archivage (is_archived / archived_at) et le soft-delete technique
 * prepare mais non expose au M1 (deleted_at, HP-M2-1).
 *
 * Decisions structurantes :
 *  - Audit complet (#[Auditable]) sur tous les champs (M2M categories audite
 *    automatiquement). Timestampable/Blamable via le trait Shared.
 *  - PAS de #[ORM\UniqueConstraint] : l'unicite du nom de societe (RG-1.16) est
 *    portee par l'index partiel fonctionnel uq_client_company_name_active
 *    (LOWER(company_name) WHERE is_archived = FALSE AND deleted_at IS NULL),
 *    inexprimable en attribut ORM, donc possede par la seule migration. Le SIREN
 *    et l'email NE SONT PAS uniques (RG-1.15/1.17 supprimees, decision Q4).
 *  - distributor / broker : 2 FK auto-referentes mutuellement exclusives
 *    (RG-1.03, CHECK chk_client_distrib_or_broker en base).
 *  - categories : M2M vers Category (module Catalog) via le contrat
 *    CategoryInterface + resolve_target_entities (regle n°1, pas d'import direct).
 *
 * Operations API (Provider + Processor) branchees en ERP-55 :
 *  - GetCollection / Get : security commercial.clients.view. La liste expose le
 *    groupe client:read ; le detail embarque en plus contacts/adresses/ribs
 *    (groupe client:item:read). Les champs comptables (client:read:accounting)
 *    sont ajoutes DYNAMIQUEMENT par ClientReadGroupContextBuilder si l'user a
 *    la permission accounting.view (§ 2.7 / § 4.1 / § 4.2).
 *  - Post / Patch : security commercial.clients.manage ; le ClientProcessor
 *    applique normalisation, gating accounting/archive et regles metier.
 *  - Pas de Delete au M1 (HP-M2-1) : l'archivage passe par PATCH isArchived.
 */
#[ApiResource(
    operations: [
        new GetCollection(
            security: "is_granted('commercial.clients.view')",
            // La liste embarque les categories (avec leur code, groupe
            // category:read) et les sites agreges des adresses (groupe
            // site:read) pour alimenter les colonnes « Catégories » et
            // « Site(s) » du Repertoire (ERP-62). Cf. getSites() plus bas.
            normalizationContext: ['groups' => ['client:read', 'default:read', 'category:read', 'site:read']],
            provider: ClientProvider::class,
        ),
        new Get(
            security: "is_granted('commercial.clients.view')",
            // Detail : client + sous-collections embarquees.
            //  - client:read:accounting est ajoute par le context builder selon la
            //    permission (gate les scalaires comptables ET les RIB embarques),
            //    donc absent ici volontairement.
            //  - client_rib:read N'EST PLUS dans le contexte : le contenu des RIB
            //    embarques est desormais porte par client:read:accounting (gate),
            //    ce qui retire la fuite IBAN/BIC vers les users sans accounting.view.
            //  - category:read et site:read sont indispensables pour embarquer le
            //    code/libelle des categories et des sites (sinon stub IRI nu) :
            //    Category.code/name vivent sous category:read, Site.name sous site:read.
            normalizationContext: ['groups' => [
                'client:read',
                'client:item:read',
                'client_contact:read',
                'client_address:read',
                'category:read',
                'site:read',
                'default:read',
            ]],
            provider: ClientProvider::class,
        ),
        new Post(
            security: "is_granted('commercial.clients.manage')",
            normalizationContext: ['groups' => ['client:read', 'default:read', 'category:read', 'site:read']],
            denormalizationContext: ['groups' => ['client:write:main']],
            // Une valeur de mauvais type (ex. date non parsable sur foundedAt)
            // doit produire un 422 porte sur le champ (violations[].propertyPath,
            // mappable inline par useFormErrors) plutot qu'un 400 generique non
            // exploitable. Le front (MalioDate, MUI-44) forwarde la saisie brute
            // invalide : le back reste la couche autoritaire du format (ERP-101).
            collectDenormalizationErrors: true,
            processor: ClientProcessor::class,
        ),
        new Patch(
            // Security elargie (ERP-74) : `manage` OU `accounting.manage`. Le
            // role Compta n'a pas `manage` mais doit pouvoir editer l'onglet
            // Comptabilite d'un client existant (§ 2.7). Le ClientProcessor
            // re-gate ensuite onglet par onglet :
            //  - champs accounting -> accounting.manage (guardAccounting, RG-1.28) ;
            //  - champs main/information -> manage (guardManage : empeche Compta
            //    d'editer les autres onglets) ;
            //  - isArchived -> archive (guardArchive, RG-1.22).
            security: "is_granted('commercial.clients.manage') or is_granted('commercial.clients.accounting.manage')",
            // Le ClientProcessor inspecte les champs reellement envoyes pour
            // autoriser/refuser onglet par onglet (RG-1.22 / RG-1.28) : les
            // champs accounting exigent accounting.manage, isArchived exige
            // archive, le reste (main/information) exige manage.
            normalizationContext: ['groups' => ['client:read', 'default:read', 'category:read', 'site:read']],
            denormalizationContext: ['groups' => [
                'client:write:main',
                'client:write:information',
                'client:write:accounting',
                'client:write:archive',
            ]],
            // Cf. Post : date non parsable (foundedAt) -> 422 porte sur le champ
            // au lieu d'un 400 generique. Indispensable au mapping inline du
            // front (MalioDate MUI-44 forwarde la saisie brute invalide).
            collectDenormalizationErrors: true,
            provider: ClientProvider::class,
            processor: ClientProcessor::class,
        ),
    ],
)]
#[ORM\Entity(repositoryClass: DoctrineClientRepository::class)]
#[ORM\Table(name: 'client')]
// Index nommes pour matcher la migration (Version20260601000000). L'index
// unique partiel uq_client_company_name_active reste possede par la migration :
// Doctrine ORM ne sait pas exprimer un index fonctionnel (LOWER) + partiel
// (WHERE) via attribut. Pas de #[ORM\UniqueConstraint] (decision Q4).
#[ORM\Index(name: 'idx_client_is_archived', columns: ['is_archived'])]
#[ORM\Index(name: 'idx_client_deleted_at', columns: ['deleted_at'])]
#[ORM\Index(name: 'idx_client_distributor_id', columns: ['distributor_id'])]
#[ORM\Index(name: 'idx_client_broker_id', columns: ['broker_id'])]
#[ORM\Index(name: 'idx_client_created_by', columns: ['created_by'])]
#[ORM\Index(name: 'idx_client_updated_by', columns: ['updated_by'])]
#[Auditable]
class Client implements TimestampableInterface, BlamableInterface, ClientInterface
{
    use TimestampableBlamableTrait;

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

    // === Formulaire principal ===
    #[ORM\Column(length: 180)]
    #[Assert\NotBlank(message: 'Le nom de l\'entreprise est obligatoire.', normalizer: 'trim')]
    #[Assert\Length(min: 2, max: 180, minMessage: 'Le nom de l\'entreprise doit comporter au moins {{ limit }} caractères.', maxMessage: 'Le nom de l\'entreprise ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
    #[Groups(['client:read', 'client:write:main'])]
    private ?string $companyName = null;

    // Le contact principal n'est plus porte inline par le Client : les contacts
    // vivent uniquement dans ClientContact (onglet Contact). RG-1.01 / RG-1.02
    // supprimees du Client (equivalent RG-1.05 / RG-1.14 sur ClientContact).

    // RG-1.03 : distributor / broker auto-references mutuellement exclusives
    // (CHECK chk_client_distrib_or_broker en base).
    #[ORM\ManyToOne(targetEntity: self::class)]
    #[ORM\JoinColumn(name: 'distributor_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
    #[Groups(['client:read', 'client:write:main'])]
    private ?Client $distributor = null;

    #[ORM\ManyToOne(targetEntity: self::class)]
    #[ORM\JoinColumn(name: 'broker_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
    #[Groups(['client:read', 'client:write:main'])]
    private ?Client $broker = null;

    #[ORM\Column(name: 'triage_service', options: ['default' => false])]
    #[Groups(['client:read', 'client:write:main'])]
    private bool $triageService = false;

    // Champ transitoire (NON persiste : aucune colonne ORM) portant l'intention UI
    // « ce client depend d'un distributeur / courtier ». Write-only (groupe
    // d'ecriture main uniquement, pas de groupe de lecture -> jamais serialise en
    // sortie). Sert exclusivement a la validation croisee validateRelationName :
    // si une relation est choisie, la FK correspondante (distributor / broker)
    // devient obligatoire. Non mappe ORM -> non audite, et toujours null une fois
    // l'entite rechargee depuis la base (ne sert qu'au cycle d'une ecriture).
    #[Assert\Choice(choices: ['distributeur', 'courtier'], message: 'Le type de relation est invalide.')]
    #[Groups(['client:write:main'])]
    private ?string $relationType = null;

    // RG : au moins une categorie (Count min 1). M2M vers Category via le contrat
    // CategoryInterface (resolve_target_entities -> Category).
    /** @var Collection<int, CategoryInterface> */
    #[ORM\ManyToMany(targetEntity: CategoryInterface::class)]
    #[ORM\JoinTable(name: 'client_category')]
    #[ORM\JoinColumn(name: 'client_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:read', 'client:write:main'])]
    private Collection $categories;

    // === Onglet Information ===
    #[ORM\Column(type: 'text', nullable: true)]
    #[Groups(['client:read', 'client:write:information'])]
    private ?string $description = null;

    #[ORM\Column(length: 255, nullable: true)]
    #[Assert\Length(max: 255, maxMessage: 'La liste des concurrents ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
    #[Groups(['client:read', 'client:write:information'])]
    private ?string $competitors = null;

    #[ORM\Column(type: 'date_immutable', nullable: true)]
    #[Groups(['client:read', 'client:write:information'])]
    // Format d'ENTREE strict ISO `Y-m-d` (le `!` remet l'heure a 00:00:00). Sans
    // ce format, PHP DateTime accepte des formes ambigues : « 12/25/2026 » (que
    // le front MalioDate juge invalide en JJ/MM/AAAA) serait sinon interprete en
    // M/J/AAAA -> 25 decembre 2026, et accepte a tort. Avec le format, toute
    // saisie brute non-ISO (forwardee par MalioDate sur date invalide) echoue la
    // denormalisation -> 422 sur foundedAt (cf. collectDenormalizationErrors).
    #[Context(denormalizationContext: [DateTimeNormalizer::FORMAT_KEY => '!Y-m-d'])]
    private ?DateTimeImmutable $foundedAt = null;

    #[ORM\Column(nullable: true)]
    #[Assert\PositiveOrZero(message: 'L\'effectif doit être un nombre positif ou nul.')]
    #[Groups(['client:read', 'client:write:information'])]
    private ?int $employeesCount = null;

    #[ORM\Column(type: 'decimal', precision: 15, scale: 2, nullable: true)]
    #[Groups(['client:read', 'client:write:information'])]
    private ?string $revenueAmount = null;

    #[ORM\Column(length: 120, nullable: true)]
    #[Assert\Length(max: 120, maxMessage: 'Le nom du dirigeant ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
    #[Groups(['client:read', 'client:write:information'])]
    private ?string $directorName = null;

    #[ORM\Column(type: 'decimal', precision: 15, scale: 2, nullable: true)]
    #[Groups(['client:read', 'client:write:information'])]
    private ?string $profitAmount = null;

    // === Onglet Comptabilite ===
    // Lecture conditionnee via le groupe `client:read:accounting` (ajoute par le
    // futur Provider si l'user a la permission accounting.view). Ecriture via
    // `client:write:accounting` (le futur Processor exige accounting.manage).
    #[ORM\Column(length: 20, nullable: true)]
    #[Assert\Length(max: 20, maxMessage: 'Le SIREN ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
    #[Groups(['client:read:accounting', 'client:write:accounting'])]
    private ?string $siren = null;

    #[ORM\Column(length: 40, nullable: true)]
    #[Assert\Length(max: 40, maxMessage: 'Le numéro de compte ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
    #[Groups(['client:read:accounting', 'client:write:accounting'])]
    private ?string $accountNumber = null;

    #[ORM\ManyToOne(targetEntity: TvaMode::class)]
    #[ORM\JoinColumn(name: 'tva_mode_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')]
    #[Groups(['client:read:accounting', 'client:write:accounting'])]
    private ?TvaMode $tvaMode = null;

    #[ORM\Column(length: 40, nullable: true)]
    #[Assert\Length(max: 40, maxMessage: 'Le numéro de TVA ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
    #[Groups(['client:read:accounting', 'client:write:accounting'])]
    private ?string $nTva = null;

    #[ORM\ManyToOne(targetEntity: PaymentDelay::class)]
    #[ORM\JoinColumn(name: 'payment_delay_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')]
    #[Groups(['client:read:accounting', 'client:write:accounting'])]
    private ?PaymentDelay $paymentDelay = null;

    #[ORM\ManyToOne(targetEntity: PaymentType::class)]
    #[ORM\JoinColumn(name: 'payment_type_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')]
    #[Groups(['client:read:accounting', 'client:write:accounting'])]
    private ?PaymentType $paymentType = null;

    #[ORM\ManyToOne(targetEntity: Bank::class)]
    #[ORM\JoinColumn(name: 'bank_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')]
    #[Groups(['client:read:accounting', 'client:write:accounting'])]
    private ?Bank $bank = null;

    // === Sous-collections (exposees via sous-ressources API dediees, ulterieur) ===
    /** @var Collection<int, ClientContact> */
    #[ORM\OneToMany(mappedBy: 'client', targetEntity: ClientContact::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
    private Collection $contacts;

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

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

    // === Archive / Soft delete ===
    // Groupe d'ECRITURE uniquement sur la propriete (denormalisation PATCH
    // archive). Le groupe de LECTURE est declare sur le getter isArchived()
    // avec SerializedName('isArchived') : sans cela, Symfony strip le prefixe
    // "is" et exposerait la cle JSON "archived" (meme pattern que User::isAdmin
    // et Role::isSystem).
    #[ORM\Column(name: 'is_archived', options: ['default' => false])]
    #[Groups(['client:write:archive'])]
    private bool $isArchived = false;

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

    // Soft delete technique (HP-M2-1) : non expose en lecture/ecriture au M1.
    #[ORM\Column(type: 'datetime_immutable', nullable: true)]
    private ?DateTimeImmutable $deletedAt = null;

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

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

    public function getCompanyName(): ?string
    {
        return $this->companyName;
    }

    public function setCompanyName(string $companyName): static
    {
        $this->companyName = $companyName;

        return $this;
    }

    public function getDistributor(): ?Client
    {
        return $this->distributor;
    }

    public function setDistributor(?Client $distributor): static
    {
        $this->distributor = $distributor;

        return $this;
    }

    public function getBroker(): ?Client
    {
        return $this->broker;
    }

    public function setBroker(?Client $broker): static
    {
        $this->broker = $broker;

        return $this;
    }

    public function getRelationType(): ?string
    {
        return $this->relationType;
    }

    public function setRelationType(?string $relationType): static
    {
        $this->relationType = $relationType;

        return $this;
    }

    /**
     * RG-1.03 bis : si l'utilisateur declare une relation (« depend d'un
     * distributeur / courtier » via le champ transitoire relationType), la FK
     * correspondante est obligatoire. Le back ne peut pas deviner cette intention
     * a partir des seules FK nullable (distributor=null ne distingue pas « pas de
     * relation » de « relation choisie sans nom »), d'ou relationType qui la porte.
     * Violation portee sur distributor / broker (champ fautif cote formulaire), de
     * sorte que useFormErrors la mappe inline sous le bon select (ERP-101).
     */
    #[Assert\Callback]
    public function validateRelationName(ExecutionContextInterface $context): void
    {
        if ('distributeur' === $this->relationType && null === $this->distributor) {
            $context->buildViolation('Le nom du distributeur est obligatoire.')
                ->atPath('distributor')
                ->addViolation()
            ;
        }

        if ('courtier' === $this->relationType && null === $this->broker) {
            $context->buildViolation('Le nom du courtier est obligatoire.')
                ->atPath('broker')
                ->addViolation()
            ;
        }
    }

    public function isTriageService(): bool
    {
        return $this->triageService;
    }

    public function setTriageService(bool $triageService): static
    {
        $this->triageService = $triageService;

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

    public function getDescription(): ?string
    {
        return $this->description;
    }

    public function setDescription(?string $description): static
    {
        $this->description = $description;

        return $this;
    }

    public function getCompetitors(): ?string
    {
        return $this->competitors;
    }

    public function setCompetitors(?string $competitors): static
    {
        $this->competitors = $competitors;

        return $this;
    }

    public function getFoundedAt(): ?DateTimeImmutable
    {
        return $this->foundedAt;
    }

    public function setFoundedAt(?DateTimeImmutable $foundedAt): static
    {
        $this->foundedAt = $foundedAt;

        return $this;
    }

    public function getEmployeesCount(): ?int
    {
        return $this->employeesCount;
    }

    public function setEmployeesCount(?int $employeesCount): static
    {
        $this->employeesCount = $employeesCount;

        return $this;
    }

    public function getRevenueAmount(): ?string
    {
        return $this->revenueAmount;
    }

    public function setRevenueAmount(?string $revenueAmount): static
    {
        $this->revenueAmount = $revenueAmount;

        return $this;
    }

    public function getDirectorName(): ?string
    {
        return $this->directorName;
    }

    public function setDirectorName(?string $directorName): static
    {
        $this->directorName = $directorName;

        return $this;
    }

    public function getProfitAmount(): ?string
    {
        return $this->profitAmount;
    }

    public function setProfitAmount(?string $profitAmount): static
    {
        $this->profitAmount = $profitAmount;

        return $this;
    }

    public function getSiren(): ?string
    {
        return $this->siren;
    }

    public function setSiren(?string $siren): static
    {
        $this->siren = $siren;

        return $this;
    }

    public function getAccountNumber(): ?string
    {
        return $this->accountNumber;
    }

    public function setAccountNumber(?string $accountNumber): static
    {
        $this->accountNumber = $accountNumber;

        return $this;
    }

    public function getTvaMode(): ?TvaMode
    {
        return $this->tvaMode;
    }

    public function setTvaMode(?TvaMode $tvaMode): static
    {
        $this->tvaMode = $tvaMode;

        return $this;
    }

    public function getNTva(): ?string
    {
        return $this->nTva;
    }

    public function setNTva(?string $nTva): static
    {
        $this->nTva = $nTva;

        return $this;
    }

    public function getPaymentDelay(): ?PaymentDelay
    {
        return $this->paymentDelay;
    }

    public function setPaymentDelay(?PaymentDelay $paymentDelay): static
    {
        $this->paymentDelay = $paymentDelay;

        return $this;
    }

    public function getPaymentType(): ?PaymentType
    {
        return $this->paymentType;
    }

    public function setPaymentType(?PaymentType $paymentType): static
    {
        $this->paymentType = $paymentType;

        return $this;
    }

    public function getBank(): ?Bank
    {
        return $this->bank;
    }

    public function setBank(?Bank $bank): static
    {
        $this->bank = $bank;

        return $this;
    }

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

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

        return $this;
    }

    public function removeContact(ClientContact $contact): static
    {
        if ($this->contacts->removeElement($contact) && $contact->getClient() === $this) {
            $contact->setClient(null);
        }

        return $this;
    }

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

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

        return $this;
    }

    public function removeAddress(ClientAddress $address): static
    {
        if ($this->addresses->removeElement($address) && $address->getClient() === $this) {
            $address->setClient(null);
        }

        return $this;
    }

    /**
     * Sites distincts rattaches a au moins une adresse du client (RG-1.10).
     * Le Client ne porte pas de sites en propre : ils vivent sur les adresses.
     * Agrege en lecture seule pour la colonne « Site(s) » du Repertoire (badges
     * colores) — expose en LISTE via le groupe client:read (les adresses
     * completes restent reservees au detail, client:item:read).
     *
     * @return list<SiteInterface>
     */
    #[Groups(['client:read'])]
    public function getSites(): array
    {
        $sites = [];
        foreach ($this->addresses as $address) {
            foreach ($address->getSites() as $site) {
                // Deduplication par identite d'objet : un meme site peut etre
                // rattache a plusieurs adresses du client.
                $sites[spl_object_id($site)] = $site;
            }
        }

        return array_values($sites);
    }

    // Embed gate sur le groupe COMPTABLE (et non client:item:read comme contacts/
    // adresses) : client:read:accounting n'est ajoute au contexte que si l'user a
    // accounting.view (ClientReadGroupContextBuilder). Resultat : la cle `ribs` est
    // TOTALEMENT ABSENTE du detail pour un user sans accounting.view (ex. Commerciale),
    // au meme titre que les scalaires comptables — corrige la fuite de RIB ou la
    // Commerciale recevait IBAN/BIC en clair.
    /** @return Collection<int, ClientRib> */
    #[Groups(['client:read:accounting'])]
    public function getRibs(): Collection
    {
        return $this->ribs;
    }

    public function addRib(ClientRib $rib): static
    {
        if (!$this->ribs->contains($rib)) {
            $this->ribs->add($rib);
            $rib->setClient($this);
        }

        return $this;
    }

    public function removeRib(ClientRib $rib): static
    {
        if ($this->ribs->removeElement($rib) && $rib->getClient() === $this) {
            $rib->setClient(null);
        }

        return $this;
    }

    // Groupe de lecture + nom serialise explicite : sans SerializedName, Symfony
    // exposerait la cle "archived" (strip du prefixe "is" sur les getters).
    #[Groups(['client: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;
    }
}
