<?php

declare(strict_types=1);

namespace App\Tests\Module\Commercial\Api;

use App\Module\Commercial\Domain\Entity\TvaMode;
use PHPUnit\Framework\Attributes\DataProvider;

/**
 * Tests fonctionnels des 4 referentiels comptables lecture seule (ERP-56) :
 * tva_mode, payment_delay, payment_type, bank. Cf. spec-back M1 § 4.7.
 *
 * Couvre les criteres d'acceptation du ticket :
 *  - les 4 GetCollection repondent 200 avec le seed (CommercialReferentialFixtures) ;
 *  - tri par defaut position ASC puis label ASC ;
 *  - POST / PATCH / DELETE -> 405 (aucune operation d'ecriture declaree) ;
 *  - user authentifie sans commercial.clients.view -> 403 ;
 *  - anonyme -> 401 ;
 *  - pagination serveur active (ERP-72) + echappatoire ?pagination=false.
 *
 * @internal
 */
final class ReferentialApiTest extends AbstractCommercialApiTestCase
{
    private const string LD = 'application/ld+json';

    /**
     * Endpoint => codes attendus dans le seed (sous-ensemble verifie present).
     *
     * @var array<string, list<string>>
     */
    private const SEED = [
        '/api/tva_modes'      => ['FRANCE_VENTES', 'EXPORT_VENTES', 'INTRACOM_VENTES'],
        '/api/payment_delays' => ['J15', 'J30', 'A_RECEPTION'],
        '/api/payment_types'  => ['VIREMENT', 'LCR', 'NON_SOUMISE', 'CHEQUE'],
        '/api/banks'          => ['SG', 'CIC', 'CA'],
    ];

    /**
     * Purge les eventuelles lignes de test inserees dans tva_mode (tri label).
     * Les codes du seed ne commencent jamais par TEST_, donc cette purge ne
     * touche pas les referentiels metier.
     */
    protected function tearDown(): void
    {
        $this->getEm()
            ->createQuery('DELETE FROM '.TvaMode::class.' t WHERE t.code LIKE :prefix')
            ->setParameter('prefix', 'TEST\_%')
            ->execute()
        ;

        parent::tearDown();
    }

    /**
     * Critere : chaque endpoint repond 200 et expose le seed (id, code, label,
     * position) sous le groupe de lecture du referentiel.
     *
     * @param list<string> $expectedCodes
     */
    #[DataProvider('endpointProvider')]
    public function testCollectionReturns200WithSeed(string $endpoint, array $expectedCodes): void
    {
        $client   = $this->createAdminClient();
        $response = $client->request('GET', $endpoint.'?pagination=false', ['headers' => ['Accept' => self::LD]]);

        self::assertResponseStatusCodeSame(200);

        $members = $response->toArray()['member'];
        $codes   = array_map(static fn (array $m): string => $m['code'], $members);

        foreach ($expectedCodes as $expected) {
            self::assertContains($expected, $codes, $endpoint.' doit exposer le code seede '.$expected);
        }

        // Le DTO de lecture expose bien id / code / label / position.
        $first = $members[0];
        self::assertArrayHasKey('id', $first);
        self::assertArrayHasKey('label', $first);
        self::assertArrayHasKey('position', $first);
    }

    /**
     * Critere : GET item repond 200 (recupere via un id reel de la collection).
     */
    public function testGetItemReturns200(): void
    {
        $client = $this->createAdminClient();

        $first = $client->request('GET', '/api/tva_modes?pagination=false', ['headers' => ['Accept' => self::LD]])
            ->toArray()['member'][0]
        ;

        $client->request('GET', '/api/tva_modes/'.$first['id'], ['headers' => ['Accept' => self::LD]]);
        self::assertResponseStatusCodeSame(200);
    }

    /**
     * Critere : tri par defaut position ASC. Le seed tva_mode est ordonne
     * FRANCE_VENTES (10) < EXPORT_VENTES (20) < INTRACOM_VENTES (30).
     */
    public function testDefaultSortByPositionAsc(): void
    {
        $client = $this->createAdminClient();

        $codes = array_map(
            static fn (array $m): string => $m['code'],
            $client->request('GET', '/api/tva_modes?pagination=false', ['headers' => ['Accept' => self::LD]])->toArray()['member'],
        );

        $expectedOrder = ['FRANCE_VENTES', 'EXPORT_VENTES', 'INTRACOM_VENTES'];
        $filtered      = array_values(array_intersect($codes, $expectedOrder));

        self::assertSame(
            $expectedOrder,
            $filtered,
            'Les modes de TVA doivent etre tries position ASC (§ 4.7).',
        );
    }

    /**
     * Critere : a position egale, tri label ASC (departage). On insere deux
     * lignes de test partageant la meme position, labels volontairement dans le
     * desordre alphabetique ; le tearDown les purge ensuite.
     */
    public function testTieBreakSortByLabelAsc(): void
    {
        $em = $this->getEm();
        foreach ([['TEST_TIE_Z', 'ZZZ Tie'], ['TEST_TIE_A', 'AAA Tie']] as [$code, $label]) {
            $mode = new TvaMode();
            $mode->setCode($code);
            $mode->setLabel($label);
            $mode->setPosition(9000);
            $em->persist($mode);
        }
        $em->flush();

        $client = $this->createAdminClient();
        $codes  = array_map(
            static fn (array $m): string => $m['code'],
            $client->request('GET', '/api/tva_modes?pagination=false', ['headers' => ['Accept' => self::LD]])->toArray()['member'],
        );

        $tie = array_values(array_intersect($codes, ['TEST_TIE_A', 'TEST_TIE_Z']));
        self::assertSame(
            ['TEST_TIE_A', 'TEST_TIE_Z'],
            $tie,
            'A position egale, le tri secondaire doit etre label ASC (§ 4.7).',
        );
    }

    /**
     * Critere ERP-72 : la collection est paginee par defaut. Preuve : une page
     * au-dela des donnees est vide (un provider non pagine ignorerait `page`).
     * Avec ?pagination=false, le parametre `page` est ignore -> tout revient.
     */
    public function testPaginationActiveAndClientToggle(): void
    {
        $client = $this->createAdminClient();

        // Page 2 d'un referentiel tenant sur une page : vide -> pagination active.
        $page2 = $client->request('GET', '/api/tva_modes?page=2', ['headers' => ['Accept' => self::LD]])->toArray();
        self::assertArrayHasKey('totalItems', $page2);
        self::assertSame([], $page2['member'], 'La page 2 doit etre vide : pagination serveur active.');

        // ?pagination=false : `page` ignore, le seed complet est renvoye.
        $all = $client->request('GET', '/api/tva_modes?pagination=false&page=2', ['headers' => ['Accept' => self::LD]])->toArray();
        self::assertNotEmpty($all['member'], '?pagination=false doit desactiver la pagination (page ignoree).');
    }

    /**
     * Critere : aucune operation d'ecriture n'est declaree -> POST sur la
     * collection renvoie 405 Method Not Allowed sur les 4 referentiels.
     *
     * @param list<string> $expectedCodes
     */
    #[DataProvider('endpointProvider')]
    public function testPostReturns405(string $endpoint, array $expectedCodes): void
    {
        $client = $this->createAdminClient();
        $client->request('POST', $endpoint, [
            'headers' => ['Content-Type' => self::LD],
            'json'    => ['code' => 'X', 'label' => 'X', 'position' => 1],
        ]);

        self::assertResponseStatusCodeSame(405);
    }

    /**
     * Critere : PATCH et DELETE sur un item renvoient 405 (lecture seule).
     */
    public function testPatchAndDeleteReturn405(): void
    {
        $client = $this->createAdminClient();
        $first  = $client->request('GET', '/api/tva_modes?pagination=false', ['headers' => ['Accept' => self::LD]])
            ->toArray()['member'][0]
        ;
        $iri = '/api/tva_modes/'.$first['id'];

        $client->request('PATCH', $iri, [
            'headers' => ['Content-Type' => 'application/merge-patch+json'],
            'json'    => ['label' => 'Renamed'],
        ]);
        self::assertResponseStatusCodeSame(405);

        $client->request('DELETE', $iri);
        self::assertResponseStatusCodeSame(405);
    }

    /**
     * Critere : un utilisateur authentifie sans la permission
     * commercial.clients.view obtient 403 sur les 4 endpoints.
     *
     * @param list<string> $expectedCodes
     */
    #[DataProvider('endpointProvider')]
    public function testForbiddenWithoutPermission(string $endpoint, array $expectedCodes): void
    {
        // User jetable portant une permission SANS rapport (existe en base mais
        // ne donne pas commercial.clients.view).
        $creds  = $this->createUserWithPermission('core.users.view');
        $client = $this->authenticatedClient($creds['username'], $creds['password']);

        $client->request('GET', $endpoint, ['headers' => ['Accept' => self::LD]]);
        self::assertResponseStatusCodeSame(403);
    }

    /**
     * Critere : un appel anonyme (non authentifie) obtient 401 sur les 4
     * endpoints.
     *
     * @param list<string> $expectedCodes
     */
    #[DataProvider('endpointProvider')]
    public function testUnauthorizedWhenAnonymous(string $endpoint, array $expectedCodes): void
    {
        $client = self::createClient();
        $client->request('GET', $endpoint, ['headers' => ['Accept' => self::LD]]);

        self::assertResponseStatusCodeSame(401);
    }

    /**
     * Referentiel pays (ERP-116) — teste a part des 4 referentiels comptables
     * car il expose `name` (et non `label`). Memes garanties : 200 + seed des 7
     * pays, France en tete (position ASC), lecture seule (405), gating (403/401).
     */
    public function testCountriesCollectionReturns200WithSeed(): void
    {
        $client   = $this->createAdminClient();
        $response = $client->request('GET', '/api/countries?pagination=false', ['headers' => ['Accept' => self::LD]]);

        self::assertResponseStatusCodeSame(200);

        $members = $response->toArray()['member'];
        $codes   = array_map(static fn (array $m): string => $m['code'], $members);

        foreach (['FR', 'DE', 'BE', 'ES', 'IT', 'GB', 'CH'] as $expected) {
            self::assertContains($expected, $codes, '/api/countries doit exposer le pays seede '.$expected);
        }

        // Le DTO de lecture expose id / code / name / position.
        $first = $members[0];
        self::assertArrayHasKey('id', $first);
        self::assertArrayHasKey('name', $first);
        self::assertArrayHasKey('position', $first);
        // Tri par defaut position ASC : France (position 10) en tete.
        self::assertSame('FR', $first['code'], 'France (FR) doit etre en tete (position 10, tri position ASC).');
    }

    public function testCountriesGetItemReturns200(): void
    {
        $client = $this->createAdminClient();

        $first = $client->request('GET', '/api/countries?pagination=false', ['headers' => ['Accept' => self::LD]])
            ->toArray()['member'][0]
        ;

        $client->request('GET', '/api/countries/'.$first['id'], ['headers' => ['Accept' => self::LD]]);
        self::assertResponseStatusCodeSame(200);
    }

    public function testCountriesPostReturns405(): void
    {
        $client = $this->createAdminClient();
        $client->request('POST', '/api/countries', [
            'headers' => ['Content-Type' => self::LD],
            'json'    => ['code' => 'XX', 'name' => 'Pays X', 'position' => 1],
        ]);
        self::assertResponseStatusCodeSame(405);
    }

    public function testCountriesForbiddenWithoutPermission(): void
    {
        $creds  = $this->createUserWithPermission('core.users.view');
        $client = $this->authenticatedClient($creds['username'], $creds['password']);

        $client->request('GET', '/api/countries', ['headers' => ['Accept' => self::LD]]);
        self::assertResponseStatusCodeSame(403);
    }

    public function testCountriesUnauthorizedWhenAnonymous(): void
    {
        $client = self::createClient();
        $client->request('GET', '/api/countries', ['headers' => ['Accept' => self::LD]]);
        self::assertResponseStatusCodeSame(401);
    }

    /**
     * @return iterable<string, array{string, list<string>}>
     */
    public static function endpointProvider(): iterable
    {
        foreach (self::SEED as $endpoint => $codes) {
            yield $endpoint => [$endpoint, $codes];
        }
    }
}
