<?php

declare(strict_types=1);

namespace App\Tests\Module\Commercial\Api;

/**
 * Tests anti-regression du CONTRAT DE SERIALISATION du repertoire fournisseurs
 * (M2, spec-back § 4.0 / § 4.0.bis / § 4.0.ter). Jumeau du
 * {@see ClientSerializationContractTest} (M1), il reverifie sur le JSON reel les
 * 4 pieges silencieux constates en prod sur le M1 :
 *  - #4 : fuite RIB (IBAN/BIC) vers un user sans accounting.view -> clé `ribs`
 *         ABSENTE pour la Commerciale.
 *  - #3 : booleens droppes (Groups sur la propriete `isX`, getter derivant `x`)
 *         -> triageProvider (adresse) et isArchived (fournisseur) presents.
 *  - #1 : categories embarquees sans code/name -> code + name presents en LISTE
 *         ET DETAIL.
 *  - #2 : sites embarques en IRI nu -> name + postalCode presents en LISTE
 *         (via getSites()) ET DETAIL (addresses[].sites[]).
 * Plus l'enveloppe AP4 (member/totalItems/view sans prefixe hydra:, archives
 * exclus) et la suppression du contact inline (refonte-contact V0.2).
 *
 * REGLE D'OR : ces tests assertent sur le CORPS JSON reel, jamais sur les
 * annotations. Toute regression de groupe de serialisation casse ici.
 *
 * @internal
 */
final class SupplierSerializationContractTest extends AbstractSupplierApiTestCase
{
    // === #4 — Gating des RIB par accounting.view ===

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

        $supplier = $this->seedCompleteSupplier('Rib Admin Co');

        $http = $this->createAdminClient();
        $data = $http->request('GET', '/api/suppliers/'.$supplier->getId(), ['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 testRibsAbsentForCommercialeWithoutAccountingView(): void
    {
        $this->skipIfSitesModuleDisabled();

        $supplier = $this->seedCompleteSupplier('Rib Commerciale Co');

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

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

        // La clé `ribs` est ABSENTE (pas null) : le groupe supplier:read:accounting
        // n'est pas ajoute au contexte -> getRibs() jamais serialise. Fin de la
        // fuite IBAN/BIC (piege n°4 du M1).
        self::assertArrayNotHasKey('ribs', $data);
    }

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

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

        $supplier = $this->seedCompleteSupplier('Compta Gating Co');
        $id       = $supplier->getId();

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

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

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

    // === Refs comptables embarquees {id,label} et non IRI nu (ERP-92) ===

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

        // Reglement Virement -> banque renseignee : on couvre les 4 referentiels.
        $supplier = $this->seedCompleteSupplier('Refs Embed Co', 'VIREMENT');

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

        // Avant fix ERP-92 : ces refs sortaient en IRI nu ("/api/tva_modes/30")
        // car les entites partagees ne portaient que `client:read:accounting` (M1),
        // pas `supplier:read:accounting`. Apres fix : objet {id, label} embarque
        // (le front consultation/edition affiche le libelle sans fetch — § 4.0).
        foreach (['tvaMode', 'paymentDelay', 'paymentType', 'bank'] as $ref) {
            self::assertArrayHasKey($ref, $data, sprintf('Le ref comptable "%s" doit etre present.', $ref));
            self::assertIsArray($data[$ref], sprintf('Le ref "%s" doit etre un objet embarque, pas un IRI nu.', $ref));
            self::assertArrayHasKey('id', $data[$ref]);
            self::assertArrayHasKey('label', $data[$ref]);
            self::assertNotSame('', (string) $data[$ref]['label']);
        }

        // paymentType embarque aussi son code (logique front VIREMENT/LCR).
        self::assertArrayHasKey('code', $data['paymentType']);
        self::assertSame('VIREMENT', $data['paymentType']['code']);
    }

    // === #3 — Booleens presents dans le JSON (triageProvider + isArchived) ===

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

        $supplier = $this->seedCompleteSupplier('Bool Addr Co');

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

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

        // Le bug M1 droppait TOTALEMENT la cle (Groups sur la propriete `triageProvider`,
        // getter derivant `triage`). Apres parade (Groups + SerializedName sur le
        // getter isTriageProvider), la cle est presente ET typee bool `true`.
        self::assertArrayHasKey('triageProvider', $address);
        self::assertTrue($address['triageProvider']);
    }

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

        $supplier = $this->seedCompleteSupplier('Bool Archived Co');

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

        // isArchived expose via Groups + SerializedName('isArchived') sur le getter :
        // sans cela Symfony exposerait la cle "archived" et la droppait (piege n°3 M1).
        self::assertArrayHasKey('isArchived', $data);
        self::assertFalse($data['isArchived']);
    }

    // === #1 — Embed code/name des Category (liste ET detail) ===

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

        $supplier = $this->seedCompleteSupplier('Embed Cat Detail Co');

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

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

        // Categories d'adresse aussi (category:read dans le contexte du detail).
        self::assertArrayHasKey('categories', $data['addresses'][0]);
        self::assertNotEmpty($data['addresses'][0]['categories']);
        self::assertArrayHasKey('code', $data['addresses'][0]['categories'][0]);
    }

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

        $token    = 'CatList'.substr(bin2hex(random_bytes(3)), 0, 6);
        $supplier = $this->seedCompleteSupplier($token);

        $http = $this->createAdminClient();
        $list = $http->request('GET', '/api/suppliers?search='.$token, ['headers' => ['Accept' => self::LD]])->toArray();

        $row = $this->memberById($list, (int) $supplier->getId());
        self::assertNotNull($row, 'Le fournisseur seede doit apparaitre dans la liste filtree.');
        self::assertNotEmpty($row['categories']);
        self::assertArrayHasKey('code', $row['categories'][0]);
        self::assertArrayHasKey('name', $row['categories'][0]);
        self::assertSame('NEGOCIANT', $row['categories'][0]['code']);
    }

    // === #2 — Embed name/postalCode des Site (liste via getSites + detail) ===

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

        $token    = 'SiteList'.substr(bin2hex(random_bytes(3)), 0, 6);
        $supplier = $this->seedCompleteSupplier($token);

        $http = $this->createAdminClient();
        $list = $http->request('GET', '/api/suppliers?search='.$token, ['headers' => ['Accept' => self::LD]])->toArray();

        $row = $this->memberById($list, (int) $supplier->getId());
        self::assertNotNull($row);
        // sites agreges depuis les adresses via getSites() : objet Site entier
        // (name + postalCode), pas un IRI nu (piege n°2 M1). Multi-sites (>= 2).
        self::assertArrayHasKey('sites', $row);
        self::assertGreaterThanOrEqual(2, count($row['sites']));
        self::assertArrayHasKey('name', $row['sites'][0]);
        self::assertArrayHasKey('postalCode', $row['sites'][0]);
        self::assertNotSame('', (string) $row['sites'][0]['name']);
    }

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

        $supplier = $this->seedCompleteSupplier('Site Detail Co');

        $http    = $this->createAdminClient();
        $data    = $http->request('GET', '/api/suppliers/'.$supplier->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
        $address = $data['addresses'][0];

        self::assertArrayHasKey('sites', $address);
        self::assertGreaterThanOrEqual(2, count($address['sites']), 'L\'adresse seedee est multi-sites.');
        self::assertArrayHasKey('name', $address['sites'][0]);
        self::assertArrayHasKey('postalCode', $address['sites'][0]);
        self::assertNotSame('', (string) $address['sites'][0]['name']);
    }

    // === Detail : sous-collections embarquees ===

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

        $supplier = $this->seedCompleteSupplier('Embed Subres Co');

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

        self::assertNotEmpty($data['contacts']);
        self::assertSame('Marie', $data['contacts'][0]['firstName']);
        self::assertSame('Martin', $data['contacts'][0]['lastName']);
        self::assertArrayHasKey('email', $data['contacts'][0]);

        self::assertNotEmpty($data['addresses']);
        self::assertSame('DEPART', $data['addresses'][0]['addressType']);

        self::assertNotEmpty($data['ribs']);
    }

    // === refonte-contact V0.2 : plus de contact inline sur le fournisseur ===

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

        $supplier = $this->seedCompleteSupplier('No Inline Contact Co');

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

        // Les champs de contact vivent UNIQUEMENT sous contacts[] (refonte-contact).
        foreach (['firstName', 'lastName', 'phonePrimary', 'phoneSecondary', 'email'] as $key) {
            self::assertArrayNotHasKey($key, $data, sprintf('Le champ inline "%s" ne doit plus exister au niveau du fournisseur.', $key));
        }
    }

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

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

        $http  = $this->createAdminClient();
        $token = 'EnvCheck'.substr(bin2hex(random_bytes(3)), 0, 6);

        $this->seedSupplier($token.' Active');
        $this->seedSupplier($token.' Archived', true);

        // Liste par defaut filtree sur le token : enveloppe member/totalItems sans
        // prefixe hydra:, archive EXCLU du totalItems (RG-2.17).
        $default = $http->request('GET', '/api/suppliers?search='.$token, ['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/suppliers?search='.$token.'&includeArchived=true', ['headers' => ['Accept' => self::LD]])->toArray();
        self::assertSame(2, $all['totalItems']);

        // `view` (PartialCollectionView) sans prefixe hydra:.
        $paged = $http->request('GET', '/api/suppliers?search='.$token.'&includeArchived=true&itemsPerPage=1', ['headers' => ['Accept' => self::LD]])->toArray();
        self::assertArrayHasKey('view', $paged);
        self::assertArrayNotHasKey('hydra:view', $paged);
    }

    /**
     * DoD (§ 4.0.bis) : capture des reponses JSON REELLES (liste + detail admin +
     * detail commerciale) pour les coller dans la spec avant de lancer les tickets
     * front. Le test asserte la forme ; si la variable d'env SUPPLIER_DOD_DUMP est
     * positionnee, il ecrit aussi les 3 corps formates sous /tmp pour copie.
     */
    public function testDodReferenceJsonShape(): void
    {
        $this->skipIfSitesModuleDisabled();

        $token    = 'DoD'.substr(bin2hex(random_bytes(3)), 0, 6);
        $supplier = $this->seedCompleteSupplier($token);
        $id       = (int) $supplier->getId();

        $admin = $this->createAdminClient();
        $list  = $admin->request('GET', '/api/suppliers?search='.$token, ['headers' => ['Accept' => self::LD]])->toArray();
        $detailAdmin = $admin->request('GET', '/api/suppliers/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();

        $creds            = $this->createUserWithPermission('commercial.suppliers.view');
        $commerciale      = $this->authenticatedClient($creds['username'], $creds['password']);
        $detailCommerciale = $commerciale->request('GET', '/api/suppliers/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();

        // Forme minimale attendue (la DoD valide que tout champ front est present).
        self::assertArrayHasKey('member', $list);
        self::assertArrayHasKey('siren', $detailAdmin);
        self::assertArrayHasKey('ribs', $detailAdmin);
        self::assertArrayNotHasKey('siren', $detailCommerciale);
        self::assertArrayNotHasKey('ribs', $detailCommerciale);

        if (false !== getenv('SUPPLIER_DOD_DUMP')) {
            $flags = JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES;
            file_put_contents('/tmp/supplier-dod-list.json', json_encode($list, $flags));
            file_put_contents('/tmp/supplier-dod-detail-admin.json', json_encode($detailAdmin, $flags));
            file_put_contents('/tmp/supplier-dod-detail-commerciale.json', json_encode($detailCommerciale, $flags));
        }
    }

    /**
     * Retrouve un membre de la collection par son id (liste filtree).
     *
     * @param array<string, mixed> $collection
     *
     * @return array<string, mixed>|null
     */
    private function memberById(array $collection, int $id): ?array
    {
        foreach ($collection['member'] ?? [] as $member) {
            if (($member['id'] ?? null) === $id) {
                return $member;
            }
        }

        return null;
    }
}
