<?php

declare(strict_types=1);

namespace App\Tests\Module\Technique\Api;

use ApiPlatform\Symfony\Bundle\Test\Client;
use App\Module\Catalog\Domain\Entity\Category;
use App\Module\Catalog\Domain\Entity\CategoryType;
use App\Module\Commercial\Domain\Entity\Bank;
use App\Module\Commercial\Domain\Entity\PaymentDelay;
use App\Module\Commercial\Domain\Entity\PaymentType;
use App\Module\Commercial\Domain\Entity\TvaMode;
use App\Module\Core\Domain\Entity\Permission;
use App\Module\Core\Domain\Entity\Role;
use App\Module\Core\Domain\Entity\User;
use App\Module\Sites\Domain\Entity\Site;
use App\Module\Technique\Domain\Entity\Provider;
use App\Module\Technique\Domain\Entity\ProviderAddress;
use App\Module\Technique\Domain\Entity\ProviderContact;
use App\Module\Technique\Domain\Entity\ProviderRib;
use App\Tests\Module\Core\Api\AbstractApiTestCase;
use DateTimeImmutable;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;

/**
 * Base des tests fonctionnels du repertoire prestataires (M3 — module Technique).
 * Jumelle de la base fournisseurs (M2), recentree sur le perimetre ERP-134
 * (Provider + Processor + cloisonnement site).
 *
 * Donnees (RETEX M1/M2 — pas de fixtures globales pour les tests) : chaque test
 * seede ses prestataires en base via les helpers ci-dessous, puis le tearDown les
 * purge. Les 3 sites (Chatellerault 86 / Saint-Jean 17 / Pommevic 82) sont seedes
 * par SitesFixtures (make test-db-setup) ; on les recupere par code postal.
 *
 * Categories : `providerCategory('NETTOYAGE')` fetch-or-create une categorie de
 * type PRESTATAIRE (requis par RG-3.09). Pour fabriquer une categorie d'un AUTRE
 * type (test de rejet RG-3.09), utiliser `foreignCategory()`.
 *
 * Cleanup : tearDown purge prestataires AVANT categories/users (provider_category
 * et provider_site sont ON DELETE CASCADE cote provider — le DELETE DQL sur
 * Provider libere categories et sites pour les purges suivantes).
 *
 * @internal
 */
abstract class AbstractProviderApiTestCase extends AbstractApiTestCase
{
    protected const string LD    = 'application/ld+json';
    protected const string MERGE = 'application/merge-patch+json';

    protected const string TEST_CATEGORY_PREFIX = 'test_prov_cat_';

    /** Codes postaux des 3 sites fixtures (cf. SitesFixtures). */
    protected const string SITE_86 = '86100'; // Chatellerault
    protected const string SITE_17 = '17400'; // Saint-Jean
    protected const string SITE_82 = '82400'; // Pommevic

    /** IBAN / BIC valides (memes valeurs que les tests M2) pour les RIB. */
    protected const string VALID_IBAN = 'FR1420041010050500013M02606';
    protected const string VALID_BIC  = 'BNPAFRPPXXX';
    /** BIC d'un autre pays (DE) : controle croise pays BIC/IBAN. */
    protected const string FOREIGN_BIC = 'DEUTDEFFXXX';

    protected function tearDown(): void
    {
        $em = $this->getEm();

        $em->createQuery('DELETE FROM '.Provider::class)->execute();
        $em->createQuery('DELETE FROM '.Category::class.' c WHERE c.name LIKE :prefix')
            ->setParameter('prefix', self::TEST_CATEGORY_PREFIX.'%')->execute()
        ;
        $em->createQuery('DELETE FROM '.User::class.' u WHERE u.username LIKE :prefix')
            ->setParameter('prefix', 'test_%')->execute()
        ;
        $em->createQuery('DELETE FROM '.Role::class.' r WHERE r.code LIKE :prefix')
            ->setParameter('prefix', 'test_%')->execute()
        ;

        parent::tearDown();
    }

    protected function createAdminClient(): Client
    {
        return $this->authenticatedClient('admin', 'admin');
    }

    /**
     * Recupere (ou cree) le type PRESTATAIRE. Idempotent (unicite category_type.code).
     */
    protected function providerCategoryType(): CategoryType
    {
        $em       = $this->getEm();
        $existing = $em->getRepository(CategoryType::class)->findOneBy(['code' => 'PRESTATAIRE']);
        if (null !== $existing) {
            return $existing;
        }

        $type = new CategoryType();
        $type->setCode('PRESTATAIRE');
        $type->setLabel('Prestataire');
        $em->persist($type);
        $em->flush();

        return $type;
    }

    /**
     * Fetch-or-create une categorie de type PRESTATAIRE par code (defaut NETTOYAGE).
     * Idempotent (lookup par code, aligne sur l'index unique partiel uq_category_code)
     * et auto-suffisant. Nom prefixe -> purge par tearDown.
     */
    protected function providerCategory(string $code = 'NETTOYAGE'): Category
    {
        $em       = $this->getEm();
        $existing = $em->getRepository(Category::class)->findOneBy(['code' => $code, 'deletedAt' => null]);
        if (null !== $existing) {
            return $existing;
        }

        $category = new Category();
        $category->setName(self::TEST_CATEGORY_PREFIX.strtolower($code));
        $category->setCode($code);
        $category->addCategoryType($this->providerCategoryType());
        $em->persist($category);
        $em->flush();

        return $category;
    }

    /**
     * Cree une categorie d'un type DIFFERENT de PRESTATAIRE (pour tester le rejet
     * RG-3.09). Code unique pour ne pas collisionner avec une categorie existante.
     */
    protected function foreignCategory(): Category
    {
        $em     = $this->getEm();
        $suffix = substr(bin2hex(random_bytes(4)), 0, 8);

        $type = $em->getRepository(CategoryType::class)->findOneBy(['code' => 'CLIENT']);
        if (null === $type) {
            $type = new CategoryType();
            $type->setCode('CLIENT');
            $type->setLabel('Client');
            $em->persist($type);
        }

        $category = new Category();
        $category->setName(self::TEST_CATEGORY_PREFIX.'foreign_'.$suffix);
        $category->setCode('FOREIGN_'.strtoupper($suffix));
        $category->addCategoryType($type);
        $em->persist($category);
        $em->flush();

        return $category;
    }

    /**
     * Recupere un site fixture par code postal (cf. SitesFixtures). Echoue
     * explicitement si absent (fixtures non chargees / module Sites off).
     */
    protected function site(string $postalCode): Site
    {
        $site = $this->getEm()->getRepository(Site::class)->findOneBy(['postalCode' => $postalCode]);

        self::assertNotNull(
            $site,
            sprintf('Site fixture "%s" introuvable : SitesFixtures charge (make test-db-setup) ?', $postalCode),
        );

        return $site;
    }

    /**
     * Seede directement un Provider minimal (sans passer par l'API), pour les tests
     * de liste / archivage / cloisonnement. Nom stocke en MAJUSCULES pour refleter
     * l'etat normalise (RG-3.11) qu'aurait produit le ProviderProcessor. Porte une
     * categorie PRESTATAIRE + les sites donnes (par code postal).
     *
     * @param list<string> $sitePostalCodes codes postaux des sites a rattacher
     */
    protected function seedProvider(
        string $companyName,
        array $sitePostalCodes = [self::SITE_86],
        bool $isArchived = false,
        string $categoryCode = 'NETTOYAGE',
        ?string $siren = null,
    ): Provider {
        $em       = $this->getEm();
        $provider = new Provider();
        $provider->setCompanyName(mb_strtoupper($companyName, 'UTF-8'));
        $provider->addCategory($this->providerCategory($categoryCode));
        foreach ($sitePostalCodes as $postalCode) {
            $provider->addSite($this->site($postalCode));
        }
        if (null !== $siren) {
            $provider->setSiren($siren);
        }
        $provider->setIsArchived($isArchived);
        if ($isArchived) {
            $provider->setArchivedAt(new DateTimeImmutable());
        }
        $em->persist($provider);
        $em->flush();

        return $provider;
    }

    /**
     * Payload minimal valide du formulaire principal (companyName + 1 categorie
     * PRESTATAIRE + sites donnes). Categorie NETTOYAGE par defaut.
     *
     * @param list<string> $sitePostalCodes
     *
     * @return array<string, mixed>
     */
    protected function validMainPayload(string $companyName, array $sitePostalCodes = [self::SITE_86]): array
    {
        $siteIris = array_map(fn (string $pc): string => '/api/sites/'.$this->site($pc)->getId(), $sitePostalCodes);

        return [
            'companyName' => $companyName,
            'categories'  => ['/api/categories/'.$this->providerCategory()->getId()],
            'sites'       => $siteIris,
        ];
    }

    /**
     * Cree un utilisateur non-admin CLOISONNE : porte les permissions donnees via
     * un role jetable, rattache aux seuls sites donnes (par code postal), avec un
     * currentSite positionne. N'a PAS `sites.bypass_scope` (sauf si fourni dans
     * $permissionCodes) -> sujet ideal des tests de cloisonnement (RG-3.17).
     *
     * Contrairement a createUserWithPermissions() (parent, qui attache TOUS les
     * sites et ne pose pas de currentSite), ce helper controle finement le
     * perimetre site de l'user.
     *
     * @param list<string> $permissionCodes
     * @param list<string> $sitePostalCodes sites a rattacher (user_site)
     *
     * @return array{username: string, password: string}
     */
    protected function createScopedUser(
        array $permissionCodes,
        array $sitePostalCodes,
        ?string $currentSitePostalCode = null,
    ): array {
        $em = $this->getEm();

        $suffix   = substr(bin2hex(random_bytes(4)), 0, 8);
        $username = 'test_scoped_'.$suffix;
        $password = 'testpass';

        /** @var UserPasswordHasherInterface $hasher */
        $hasher = self::getContainer()->get(UserPasswordHasherInterface::class);

        $role = new Role('test_'.$suffix, 'Test Role '.$suffix, false);
        foreach ($permissionCodes as $code) {
            $permission = $em->getRepository(Permission::class)->findOneBy(['code' => $code]);
            self::assertNotNull($permission, sprintf('Permission "%s" introuvable (app:sync-permissions ?).', $code));
            $role->addPermission($permission);
        }
        $em->persist($role);

        $user = new User();
        $user->setUsername($username);
        $user->setIsAdmin(false);
        $user->setPassword($hasher->hashPassword($user, $password));
        $user->addRbacRole($role);

        foreach ($sitePostalCodes as $postalCode) {
            $user->addSite($this->site($postalCode));
        }
        if (null !== $currentSitePostalCode) {
            $user->setCurrentSite($this->site($currentSitePostalCode));
        }

        $em->persist($user);
        $em->flush();
        $em->clear();

        return ['username' => $username, 'password' => $password];
    }

    /**
     * Ajoute un contact a un prestataire deja persiste (seed direct).
     */
    protected function addContact(
        Provider $provider,
        ?string $firstName = 'Marie',
        ?string $lastName = 'Martin',
        ?string $phonePrimary = null,
        ?string $email = null,
        int $position = 0,
    ): ProviderContact {
        $contact = new ProviderContact();
        $contact->setProvider($provider);
        $contact->setFirstName($firstName);
        $contact->setLastName($lastName);
        $contact->setPhonePrimary($phonePrimary);
        $contact->setEmail($email);
        $contact->setPosition($position);
        $provider->addContact($contact);
        $this->getEm()->persist($contact);
        $this->getEm()->flush();

        return $contact;
    }

    /**
     * Ajoute un RIB a un prestataire deja persiste (seed direct).
     */
    protected function addRib(Provider $provider, string $label = 'Compte principal'): ProviderRib
    {
        $rib = new ProviderRib();
        $rib->setProvider($provider);
        $rib->setLabel($label);
        $rib->setBic(self::VALID_BIC);
        $rib->setIban(self::VALID_IBAN);
        $provider->addRib($rib);
        $this->getEm()->persist($rib);
        $this->getEm()->flush();

        return $rib;
    }

    /**
     * Seede un prestataire COMPLET (sans passer par l'API — validations applicatives
     * non rejouees mais CHECK BDD respectes) : bloc comptable non nul (SIREN + refs),
     * >= 1 RIB, >= 2 sites en relation DIRECTE (formulaire principal, RG-3.03), >= 1
     * adresse multi-sites (>= 2 sites) avec >= 1 categorie PRESTATAIRE et >= 1 contact,
     * >= 1 contact et >= 1 categorie sur le prestataire. Socle du contrat de
     * serialisation et de la DoD (§ 4.0.bis), jumeau de seedCompleteSupplier (M2)
     * mais SANS onglet Information (absent au M3) et AVEC sites directs sur le
     * prestataire (NOUVEAU M3 — la liste affiche provider.sites, pas un agregat
     * d'adresses).
     *
     * @param string $paymentTypeCode code du type de reglement a poser (defaut LCR,
     *                                coherent avec le RIB seede ; RG-3.08)
     */
    protected function seedCompleteProvider(string $companyName, string $paymentTypeCode = 'LCR'): Provider
    {
        $em = $this->getEm();

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

        $provider = new Provider();
        $provider->setCompanyName(mb_strtoupper($companyName.' '.$suffix, 'UTF-8'));
        $provider->addCategory($this->providerCategory('NETTOYAGE'));

        // Bloc comptable non nul (gating par omission cote sans accounting.view).
        $provider->setSiren('987654321');
        $provider->setAccountNumber('P0001');
        $provider->setNTva('FR00987654321');
        $provider->setTvaMode($this->tvaMode('FRANCE_VENTES'));
        $provider->setPaymentDelay($this->paymentDelay('J30'));
        $provider->setPaymentType($this->paymentType($paymentTypeCode));
        if ('VIREMENT' === $paymentTypeCode) {
            $provider->setBank($this->bank('SG'));
        }

        // >= 2 sites fixtures : relation DIRECTE provider.sites (RG-3.03) pour la
        // LISTE + reutilises sur l'adresse multi-sites pour le DETAIL.
        $sites = $em->getRepository(Site::class)->findBy([], null, 2);
        self::assertGreaterThanOrEqual(2, count($sites), 'Au moins 2 sites fixtures requis (SitesFixtures).');
        foreach ($sites as $site) {
            $provider->addSite($site);
        }
        $em->persist($provider);

        $contact = new ProviderContact();
        $contact->setProvider($provider);
        $contact->setFirstName('Marie');
        $contact->setLastName('Martin');
        $contact->setJobTitle('Responsable');
        $contact->setPhonePrimary('0612345678');
        $contact->setEmail('marie.martin@seed.test');
        $provider->addContact($contact);
        $em->persist($contact);

        // Adresse simplifiee M3 (PAS de addressType / bennes / triageProvider).
        $address = new ProviderAddress();
        $address->setProvider($provider);
        $address->setCountry('France');
        $address->setPostalCode('86000');
        $address->setCity('Poitiers');
        $address->setStreet('12 rue des Acacias');
        foreach ($sites as $site) {
            $address->addSite($site);
        }
        $address->addCategory($this->providerCategory('NETTOYAGE'));
        $address->addContact($contact);
        $provider->addAddress($address);
        $em->persist($address);

        $rib = new ProviderRib();
        $rib->setProvider($provider);
        $rib->setLabel('Compte principal');
        $rib->setBic(self::VALID_BIC);
        $rib->setIban(self::VALID_IBAN);
        $provider->addRib($rib);
        $em->persist($rib);

        $em->flush();

        return $provider;
    }

    /**
     * Recupere un mode de TVA seede (CommercialReferentialFixtures) par code (ex.
     * FRANCE_VENTES). Echoue explicitement si absent (fixtures non chargees).
     */
    protected function tvaMode(string $code): TvaMode
    {
        $tvaMode = $this->getEm()->getRepository(TvaMode::class)->findOneBy(['code' => $code]);

        self::assertNotNull(
            $tvaMode,
            sprintf('Mode de TVA "%s" introuvable : fixtures comptables chargees (make test-db-setup) ?', $code),
        );

        return $tvaMode;
    }

    /**
     * Recupere un delai de reglement seede (CommercialReferentialFixtures) par code
     * (ex. J30). Echoue explicitement si absent (fixtures non chargees).
     */
    protected function paymentDelay(string $code): PaymentDelay
    {
        $paymentDelay = $this->getEm()->getRepository(PaymentDelay::class)->findOneBy(['code' => $code]);

        self::assertNotNull(
            $paymentDelay,
            sprintf('Delai de reglement "%s" introuvable : fixtures comptables chargees (make test-db-setup) ?', $code),
        );

        return $paymentDelay;
    }

    /**
     * Recupere un type de reglement seede (CommercialReferentialFixtures) par code
     * (ex. LCR, VIREMENT). Echoue explicitement si absent (fixtures non chargees).
     */
    protected function paymentType(string $code): PaymentType
    {
        $paymentType = $this->getEm()->getRepository(PaymentType::class)->findOneBy(['code' => $code]);

        self::assertNotNull(
            $paymentType,
            sprintf('Type de reglement "%s" introuvable : fixtures comptables chargees (make test-db-setup) ?', $code),
        );

        return $paymentType;
    }

    /**
     * Recupere une banque seedee (CommercialReferentialFixtures) par code (ex. SG).
     * Echoue explicitement si absente (fixtures non chargees).
     */
    protected function bank(string $code): Bank
    {
        $bank = $this->getEm()->getRepository(Bank::class)->findOneBy(['code' => $code]);

        self::assertNotNull(
            $bank,
            sprintf('Banque "%s" introuvable : fixtures comptables chargees (make test-db-setup) ?', $code),
        );

        return $bank;
    }

    /**
     * Indexe les violations d'un corps 422 par propertyPath (assert ciblee).
     *
     * @param array<string, mixed> $body corps decode (toArray(false))
     *
     * @return array<string, string> propertyPath => message
     */
    protected function violationsByPath(array $body): array
    {
        $byPath = [];
        foreach ($body['violations'] ?? [] as $v) {
            $byPath[$v['propertyPath']] = $v['message'];
        }

        return $byPath;
    }
}
