<?php

declare(strict_types=1);

namespace App\Tests\Module\Commercial\Api;

use App\Module\Commercial\Domain\Entity\Client as ClientEntity;
use App\Module\Commercial\Domain\Entity\ClientAddress;
use App\Module\Commercial\Domain\Entity\ClientContact;
use App\Module\Commercial\Domain\Entity\ClientRib;
use App\Module\Sites\Domain\Entity\Site;

/**
 * Tests anti-regression du CONTRAT DE SERIALISATION du repertoire clients (M1).
 *
 * Captures reelles du 02/06/2026 (cf. docs/specs/M2-suppliers/spec-back.md
 * § 4.0.ter) ayant revele 4 bugs silencieux du contrat (aucune erreur levee) :
 *  - #81 : booleens d'adresse (isProspect/isDelivery/isBilling) absents du JSON
 *          (Groups sur la propriete `isX`, getter `isX()` derivant l'attribut `x`).
 *  - #80 : fuite RIB (IBAN/BIC) vers un user sans accounting.view.
 *  - #82 : code/libelle de Category et Site non embarques (stub IRI nu).
 *  - enveloppe AP4 : member/totalItems/view sans prefixe `hydra:`, archives exclus.
 *
 * REGLE D'OR : ces tests assertent sur le CORPS JSON reel, jamais sur les
 * annotations. Toute regression de groupe de serialisation casse ici.
 *
 * Limite connue (dependance module Sites) : l'entite Site ne porte PAS de champ
 * `code` (ni SiteInterface) — son libelle est `name`. Les « codes 86/17/82 » de
 * la spec M2 correspondent en realite au prefixe du code postal des 3 sites
 * fixtures (86100/17400/82400). On asserte donc le libelle `name` du site
 * embarque ; l'ajout d'un `Site.code` reste un ticket cote module Sites.
 *
 * @internal
 */
final class ClientSerializationContractTest extends AbstractCommercialApiTestCase
{
    private const string LD         = 'application/ld+json';
    private const string VALID_IBAN = 'FR1420041010050500013M02606';
    private const string VALID_BIC  = 'BNPAFRPPXXX';

    // === #81 — Booleens d'adresse presents dans le JSON ===

    public function testAddressBooleansArePresentInDetail(): void
    {
        $this->skipIfSitesModuleDisabled();

        $seed = $this->seedCompleteClient('Bool Addr Co');
        $id   = $seed->getId();

        $http = $this->createAdminClient();
        $data = $http->request('GET', '/api/clients/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();

        self::assertArrayHasKey('addresses', $data);
        self::assertNotEmpty($data['addresses']);
        $address = $data['addresses'][0];

        // Le bug droppait TOTALEMENT ces cles. Apres correctif (Groups +
        // SerializedName sur le getter), elles sont presentes ET typees bool.
        self::assertArrayHasKey('isProspect', $address);
        self::assertArrayHasKey('isDelivery', $address);
        self::assertArrayHasKey('isBilling', $address);
        // Memes garanties pour les types Courtier / Distributeur (ERP-119, meme
        // pattern getter + SerializedName).
        self::assertArrayHasKey('isBroker', $address);
        self::assertArrayHasKey('isDistributor', $address);

        // L'adresse seedee est livraison + facturation (prospect exclusif, RG-1.06).
        // Prouve qu'un booleen `true` est bien serialise (le bug masquait meme les true).
        self::assertFalse($address['isProspect']);
        self::assertTrue($address['isDelivery']);
        self::assertTrue($address['isBilling']);
        self::assertFalse($address['isBroker']);
        self::assertFalse($address['isDistributor']);
    }

    // === #80 — Gating des RIB par accounting.view ===

    public function testRibsPresentForAdminWithAccountingView(): void
    {
        $this->skipIfSitesModuleDisabled();

        $seed = $this->seedCompleteClient('Rib Admin Co');
        $id   = $seed->getId();

        $http = $this->createAdminClient();
        $data = $http->request('GET', '/api/clients/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();

        // Admin bypass RBAC -> accounting.view -> RIB embarques (label/bic/iban).
        self::assertArrayHasKey('ribs', $data);
        self::assertNotEmpty($data['ribs']);
        self::assertSame('Compte principal', $data['ribs'][0]['label']);
        self::assertSame(self::VALID_IBAN, $data['ribs'][0]['iban']);
        self::assertSame(self::VALID_BIC, $data['ribs'][0]['bic']);
    }

    public function testRibsAbsentForUserWithoutAccountingView(): void
    {
        $this->skipIfSitesModuleDisabled();

        $seed = $this->seedCompleteClient('Rib Commerciale Co');
        $id   = $seed->getId();

        // Commerciale : commercial.clients.view SANS accounting.view.
        $creds = $this->createUserWithPermission('commercial.clients.view');
        $http  = $this->authenticatedClient($creds['username'], $creds['password']);

        $data = $http->request('GET', '/api/clients/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();

        // La cle `ribs` est ABSENTE (pas null) : le groupe client:read:accounting
        // n'est pas ajoute au contexte -> getRibs() jamais serialise. Fin de la
        // fuite IBAN/BIC.
        self::assertArrayNotHasKey('ribs', $data);
    }

    // === #80.bis — Gating par OMISSION des scalaires comptables ===

    public function testAccountingScalarsGatedByOmission(): void
    {
        $this->skipIfSitesModuleDisabled();

        $seed = $this->seedCompleteClient('Compta Gating Co');
        $id   = $seed->getId();

        // Admin : scalaires comptables presents.
        $admin = $this->createAdminClient();
        $adminData = $admin->request('GET', '/api/clients/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();
        self::assertArrayHasKey('siren', $adminData);
        self::assertSame('123456789', $adminData['siren']);
        self::assertArrayHasKey('accountNumber', $adminData);

        // Commerciale : scalaires comptables ABSENTS (omission, pas null).
        $creds = $this->createUserWithPermission('commercial.clients.view');
        $http  = $this->authenticatedClient($creds['username'], $creds['password']);
        $data  = $http->request('GET', '/api/clients/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();

        self::assertArrayNotHasKey('siren', $data);
        self::assertArrayNotHasKey('accountNumber', $data);
        self::assertArrayNotHasKey('nTva', $data);
        self::assertArrayNotHasKey('ribs', $data);
    }

    // === #82 — Embed code/libelle des Category et Site ===

    public function testCategoriesEmbedCodeAndLabel(): void
    {
        $this->skipIfSitesModuleDisabled();

        $seed = $this->seedCompleteClient('Embed Cat Co');
        $id   = $seed->getId();

        $http = $this->createAdminClient();
        $data = $http->request('GET', '/api/clients/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();

        self::assertNotEmpty($data['categories']);
        $category = $data['categories'][0];
        // Avant correctif : seuls @id/@type/createdAt/updatedAt (category:read
        // absent du contexte). Apres : code + name (libelle) embarques.
        self::assertArrayHasKey('code', $category);
        self::assertArrayHasKey('name', $category);
        self::assertNotSame('', $category['code']);
    }

    public function testAddressSitesEmbedLabel(): void
    {
        $this->skipIfSitesModuleDisabled();

        $seed = $this->seedCompleteClient('Embed Site Co');
        $id   = $seed->getId();

        $http = $this->createAdminClient();
        $data = $http->request('GET', '/api/clients/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();

        $address = $data['addresses'][0];
        self::assertArrayHasKey('sites', $address);
        self::assertNotEmpty($address['sites']);
        // Site embarque : libelle `name` present (avant : stub @id/@type nu).
        // NB : Site n'a pas de champ `code` (cf. note de classe) -> on asserte name.
        self::assertArrayHasKey('name', $address['sites'][0]);
        self::assertNotSame('', $address['sites'][0]['name']);

        // L'adresse seedee est multi-sites : preuve que l'embed parcourt la collection.
        self::assertGreaterThanOrEqual(2, count($address['sites']));

        // Categories d'adresse : code embarque (category:read dans le contexte).
        self::assertArrayHasKey('categories', $address);
        self::assertNotEmpty($address['categories']);
        self::assertArrayHasKey('code', $address['categories'][0]);
    }

    // === Enveloppe AP4 (sans prefixe hydra:) + exclusion des archives ===

    public function testCollectionEnvelopeShapeAndArchivedExcluded(): void
    {
        $http   = $this->createAdminClient();
        $prefix = 'EnvCheck'.substr(bin2hex(random_bytes(3)), 0, 6);

        $this->seedClient($prefix.' Active');
        $this->seedClient($prefix.' Archived', true);

        // Liste par defaut filtree sur le prefixe : enveloppe member/totalItems
        // sans prefixe hydra:, archive EXCLU du totalItems (RG-1.24).
        $default = $http->request('GET', '/api/clients?search='.$prefix, ['headers' => ['Accept' => self::LD]])->toArray();

        self::assertArrayHasKey('member', $default);
        self::assertArrayHasKey('totalItems', $default);
        self::assertArrayNotHasKey('hydra:member', $default);
        self::assertArrayNotHasKey('hydra:totalItems', $default);
        self::assertSame(1, $default['totalItems'], 'Archive exclu du totalItems par defaut.');

        // includeArchived : l'archive reintegre le total.
        $all = $http->request('GET', '/api/clients?search='.$prefix.'&includeArchived=true', ['headers' => ['Accept' => self::LD]])->toArray();
        self::assertSame(2, $all['totalItems']);

        // `view` (PartialCollectionView) sans prefixe hydra: : force le multi-page
        // via itemsPerPage=1 sur les 2 resultats archives inclus.
        $paged = $http->request('GET', '/api/clients?search='.$prefix.'&includeArchived=true&itemsPerPage=1', ['headers' => ['Accept' => self::LD]])->toArray();
        self::assertArrayHasKey('view', $paged);
        self::assertArrayNotHasKey('hydra:view', $paged);
    }

    // === Helper ===

    /**
     * Seede un client COMPLET (sans passer par l'API, validations applicatives
     * non rejouees mais CHECK BDD respectes) : bloc comptable non nul, >= 1 RIB,
     * >= 1 adresse multi-sites avec categories, >= 1 contact, >= 1 categorie.
     *
     * L'adresse est livraison + facturation (prospect exclusif, RG-1.06 ; email
     * de facturation present, RG-1.11) afin de poser des booleens `true`
     * serialisables tout en respectant les CHECK Postgres.
     */
    private function seedCompleteClient(string $companyName): ClientEntity
    {
        $em = $this->getEm();

        // Nom unique parmi les actifs (index partiel uq_client_company_name_active).
        $suffix = substr(bin2hex(random_bytes(3)), 0, 6);

        $client = new ClientEntity();
        $client->setCompanyName(mb_strtoupper($companyName.' '.$suffix, 'UTF-8'));
        $client->addCategory($this->createCategory('SECTEUR'));
        // Bloc comptable non nul (gating par omission cote Commerciale).
        $client->setSiren('123456789');
        $client->setAccountNumber('C0001');
        $client->setNTva('FR00123456789');
        $em->persist($client);

        // >= 2 sites fixtures pour une adresse multi-sites (RG-1.10).
        $sites = $em->getRepository(Site::class)->findBy([], null, 2);
        self::assertGreaterThanOrEqual(2, count($sites), 'Au moins 2 sites fixtures requis (SitesFixtures).');

        $address = new ClientAddress();
        $address->setClient($client);
        $address->setIsProspect(false);
        $address->setIsDelivery(true);
        $address->setIsBilling(true);
        $address->setBillingEmail('billing'.$suffix.'@seed.test');
        $address->setPostalCode('86000');
        $address->setCity('Poitiers');
        $address->setStreet('12 rue des Acacias');
        foreach ($sites as $site) {
            $address->addSite($site);
        }
        $address->addCategory($this->createCategory('SECTEUR'));
        $em->persist($address);

        $rib = new ClientRib();
        $rib->setClient($client);
        $rib->setLabel('Compte principal');
        $rib->setBic(self::VALID_BIC);
        $rib->setIban(self::VALID_IBAN);
        $em->persist($rib);

        $contact = new ClientContact();
        $contact->setClient($client);
        $contact->setFirstName('Marie');
        $contact->setLastName('Martin');
        $em->persist($contact);

        $em->flush();

        return $client;
    }
}
