<?php

declare(strict_types=1);

namespace App\Tests\Module\Technique\Api;

use PhpOffice\PhpSpreadsheet\IOFactory;

/**
 * Tests fonctionnels de l'export XLSX du repertoire prestataires (M3, § 4.6).
 * Jumeau du {@see \App\Tests\Module\Commercial\Api\SupplierExportControllerTest}
 * (M2), augmente du cloisonnement par site (§ 2.13, propre au M3).
 *
 * Couvre : reponse 200 (Content-Type + Content-Disposition), exclusion des
 * archives par defaut, respect du filtre ?search, peuplement des colonnes contact
 * principal / categories / sites (relation directe provider.sites), gating de la
 * colonne SIREN selon technique.providers.accounting.view (admin ET user minimal a
 * permission explicite), dedup (prestataire multi-categories rendu sur une seule
 * ligne), cloisonnement par site (un user cloisonne n'exporte que son site), 403
 * sans technique.providers.view, 401 anonyme.
 *
 * @internal
 */
final class ProviderExportControllerTest extends AbstractProviderApiTestCase
{
    private const string XLSX_MIME  = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
    private const string EXPORT_URL = '/api/providers/export.xlsx';

    public function testExportReturnsXlsxResponseWithAttachmentFilename(): void
    {
        $client = $this->createAdminClient();
        $this->seedProvider('Export Alpha');

        $response = $client->request('GET', self::EXPORT_URL);

        self::assertResponseIsSuccessful();
        $headers = $response->getHeaders(false);
        self::assertStringContainsString(self::XLSX_MIME, $headers['content-type'][0] ?? '');

        $disposition = $headers['content-disposition'][0] ?? '';
        self::assertStringContainsString('attachment; filename="repertoire-prestataires-', $disposition);
        self::assertMatchesRegularExpression(
            '/filename="repertoire-prestataires-\d{8}\.xlsx"/',
            $disposition,
        );

        // Le binaire est un XLSX relisible dont la 1re ligne porte les en-tetes.
        $grid    = $this->gridFromResponse($response->getContent());
        $headers = $grid[0];
        self::assertSame('Nom prestataire', $headers[0]);
        self::assertContains('Contact principal', $headers);
        self::assertContains('Téléphone principal', $headers);
        self::assertContains('Téléphone secondaire', $headers);
        self::assertContains('Email', $headers);
        self::assertContains('Catégories', $headers);
        self::assertContains('Sites', $headers);
        self::assertContains('Date de création', $headers);
    }

    public function testExportExcludesArchivedByDefault(): void
    {
        $client = $this->createAdminClient();
        $this->seedProvider('Active One');
        $this->seedProvider('Archived One', [self::SITE_86], true);

        $names = $this->companyNames($client->request('GET', self::EXPORT_URL)->getContent());

        self::assertContains('ACTIVE ONE', $names);
        self::assertNotContains('ARCHIVED ONE', $names);
    }

    public function testExportRespectsSearchFilter(): void
    {
        $client = $this->createAdminClient();
        $this->seedProvider('Searchable Alpha');
        $this->seedProvider('Other Beta');

        $names = $this->companyNames(
            $client->request('GET', self::EXPORT_URL.'?search=alpha')->getContent(),
        );

        self::assertContains('SEARCHABLE ALPHA', $names);
        self::assertNotContains('OTHER BETA', $names);
    }

    /**
     * Les colonnes contact sont alimentees par le CONTACT PRINCIPAL : le contact
     * de plus petit `position` (decision D2, § 4.6). On seede deux contacts en
     * ordre de position inverse pour garantir que c'est bien le principal (et non
     * le premier insere) qui alimente la ligne.
     */
    public function testExportUsesPrincipalContactColumns(): void
    {
        $client   = $this->createAdminClient();
        $provider = $this->seedProvider('Contact Co');

        // position 1 (secondaire) insere en premier...
        $this->addContact($provider, 'Bob', 'Secondaire', '0600000001', 'bob@contact.co', 1);
        // ...position 0 (principal) insere ensuite : c'est lui qui doit gagner.
        $principal = $this->addContact($provider, 'Alice', 'Principal', '0612345678', 'alice@contact.co', 0);
        // Le telephone secondaire n'est pas porte par le helper de base : on le pose
        // directement sur le contact principal pour alimenter la colonne dediee.
        $principal->setPhoneSecondary('0698765432');
        $this->getEm()->flush();

        $row = $this->rowFor($client->request('GET', self::EXPORT_URL)->getContent(), 'CONTACT CO');

        self::assertNotNull($row, 'Ligne « CONTACT CO » introuvable dans l\'export.');
        self::assertSame('Principal Alice', $row[1]);
        self::assertSame('0612345678', $row[2]);
        self::assertSame('0698765432', $row[3]);
        self::assertSame('alice@contact.co', $row[4]);
    }

    /**
     * Colonnes « Catégories » et « Sites » : un oubli d'hydratation les rendrait
     * vides sans erreur (cf. ERP-100 cote client). Le site est porte EN DIRECT par
     * le prestataire (RG-3.03), contrairement au fournisseur M2 (via l'adresse).
     */
    public function testExportPopulatesCategoryAndSiteColumns(): void
    {
        $client = $this->createAdminClient();
        $this->seedProvider('Hydrate Co', [self::SITE_86], false, 'NETTOYAGE');

        $flat = $this->flatten($this->gridFromResponse($client->request('GET', self::EXPORT_URL)->getContent()));

        // Colonne « Catégories » : libelle de la categorie PRESTATAIRE (getName()).
        // Derive du helper de base (idempotent) plutot que de hardcoder le prefixe.
        self::assertStringContainsString((string) $this->providerCategory('NETTOYAGE')->getName(), $flat);
        // Colonne « Sites » : site rattache en direct au prestataire (RG-3.03).
        self::assertStringContainsString((string) $this->site(self::SITE_86)->getName(), $flat);
    }

    public function testSirenColumnPresentWithAccountingView(): void
    {
        // L'admin bypass le RBAC : il a donc accounting.view -> colonne SIREN.
        $client = $this->createAdminClient();
        $this->seedProvider('Siren Co', [self::SITE_86], false, 'NETTOYAGE', '123456789');

        $grid = $this->gridFromResponse($client->request('GET', self::EXPORT_URL)->getContent());

        self::assertContains('SIREN', $grid[0]);
        self::assertStringContainsString('123456789', $this->flatten($grid));
    }

    public function testSirenColumnAbsentWithoutAccountingView(): void
    {
        // Seed via admin, puis relecture par un user qui n'a QUE providers.view.
        $this->createAdminClient();
        $this->seedProvider('No Siren Co', [self::SITE_86], false, 'NETTOYAGE', '987654321');

        $creds  = $this->createUserWithPermission('technique.providers.view');
        $viewer = $this->authenticatedClient($creds['username'], $creds['password']);

        $grid = $this->gridFromResponse($viewer->request('GET', self::EXPORT_URL)->getContent());

        self::assertNotContains('SIREN', $grid[0]);
        self::assertStringNotContainsString('987654321', $this->flatten($grid));
    }

    /**
     * Gating SIREN prouve via une permission EXPLICITE (et non le bypass admin) :
     * un user minimal portant uniquement technique.providers.view +
     * technique.providers.accounting.view voit bien la colonne SIREN et sa valeur.
     * Complement de testSirenColumnPresentWithAccountingView (admin), qui ne prouve
     * pas que accounting.view SEULE suffit (l'admin bypasse le RBAC). Le pendant
     * negatif est couvert par testSirenColumnAbsentWithoutAccountingView.
     */
    public function testSirenColumnPresentForMinimalUserWithAccountingView(): void
    {
        // Seed via admin, puis relecture par un user non-admin a 2 permissions.
        $this->createAdminClient();
        $this->seedProvider('Gated Siren Co', [self::SITE_86], false, 'NETTOYAGE', '456789123');

        $creds = $this->createUserWithPermissions([
            'technique.providers.view',
            'technique.providers.accounting.view',
        ]);
        $viewer = $this->authenticatedClient($creds['username'], $creds['password']);

        $grid = $this->gridFromResponse($viewer->request('GET', self::EXPORT_URL)->getContent());

        self::assertContains('SIREN', $grid[0]);
        self::assertStringContainsString('456789123', $this->flatten($grid));
    }

    /**
     * Dedup : un prestataire portant >= 2 categories PRESTATAIRE est multiplie par
     * la jointure (selection/hydratation des collections) ; l'export doit le rendre
     * sur UNE SEULE ligne. On seede un prestataire a 2 categories et on assert qu'il
     * n'apparait qu'une fois dans la colonne « Nom prestataire ».
     */
    public function testExportDeduplicatesProviderWithMultipleCategories(): void
    {
        $client   = $this->createAdminClient();
        $provider = $this->seedProvider('Multi Cat Co', [self::SITE_86], false, 'NETTOYAGE');
        // 2e categorie PRESTATAIRE sur le meme prestataire.
        $provider->addCategory($this->providerCategory('SECURITE'));
        $this->getEm()->flush();

        $names = $this->companyNames($client->request('GET', self::EXPORT_URL)->getContent());

        $occurrences = count(array_filter($names, static fn (string $name): bool => 'MULTI CAT CO' === $name));
        self::assertSame(
            1,
            $occurrences,
            'Un prestataire multi-categories doit apparaitre sur une seule ligne (dedup).',
        );
    }

    /**
     * Cloisonnement par site (RG-3.17, § 2.13) : un user non-bypass cloisonne sur
     * le site 86 n'exporte QUE les prestataires rattaches au site 86 — les
     * prestataires des sites 17 / 82 sont exclus, comme dans la liste. Pendant
     * export de ProviderSiteScopeTest::testListIsScopedToCurrentSiteForNonBypassUser.
     */
    public function testExportIsScopedToCurrentSiteForNonBypassUser(): void
    {
        // Pre-requis : module Sites actif (sinon currentSite = null, cloisonnement
        // no-op et ce test perd son sens).
        $this->skipIfSitesModuleDisabled();

        $this->createAdminClient();
        $this->seedProvider('Presta Site 86', [self::SITE_86]);
        $this->seedProvider('Presta Site 17', [self::SITE_17]);
        $this->seedProvider('Presta Site 82', [self::SITE_82]);

        $creds = $this->createScopedUser(
            ['technique.providers.view'],
            sitePostalCodes: [self::SITE_86],
            currentSitePostalCode: self::SITE_86,
        );
        $client = $this->authenticatedClient($creds['username'], $creds['password']);

        $names = $this->companyNames($client->request('GET', self::EXPORT_URL)->getContent());

        self::assertContains('PRESTA SITE 86', $names);
        self::assertNotContains('PRESTA SITE 17', $names);
        self::assertNotContains('PRESTA SITE 82', $names);
    }

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

        $client->request('GET', self::EXPORT_URL);

        self::assertResponseStatusCodeSame(403);
    }

    public function testUnauthorizedWhenAnonymous(): void
    {
        $client = self::createClient();
        $client->request('GET', self::EXPORT_URL);

        self::assertResponseStatusCodeSame(401);
    }

    /**
     * Relit le binaire XLSX d'une reponse et renvoie la grille de cellules.
     *
     * @return array<int, array<int, mixed>>
     */
    private function gridFromResponse(string $binary): array
    {
        $tmp = tempnam(sys_get_temp_dir(), 'xlsx_export_test_');
        self::assertIsString($tmp);
        file_put_contents($tmp, $binary);

        try {
            return IOFactory::load($tmp)->getActiveSheet()->toArray();
        } finally {
            @unlink($tmp);
        }
    }

    /**
     * Extrait la colonne « Nom prestataire » (1re colonne) des lignes de donnees.
     *
     * @return list<string>
     */
    private function companyNames(string $binary): array
    {
        $grid = $this->gridFromResponse($binary);
        $rows = array_slice($grid, 1); // saute l'en-tete

        return array_values(array_map(static fn (array $row): string => (string) ($row[0] ?? ''), $rows));
    }

    /**
     * Renvoie la ligne de donnees dont la 1re colonne (nom) vaut $companyName.
     *
     * @return null|array<int, mixed>
     */
    private function rowFor(string $binary, string $companyName): ?array
    {
        foreach (array_slice($this->gridFromResponse($binary), 1) as $row) {
            if ((string) ($row[0] ?? '') === $companyName) {
                return $row;
            }
        }

        return null;
    }

    /**
     * Aplatit toute la grille en une chaine, pour les assertions de presence.
     *
     * @param array<int, array<int, mixed>> $grid
     */
    private function flatten(array $grid): string
    {
        return implode('|', array_map(
            static fn (array $row): string => implode('|', array_map(static fn ($cell): string => (string) $cell, $row)),
            $grid,
        ));
    }
}
