<?php

declare(strict_types=1);

namespace App\Module\Technique\Infrastructure\Controller;

use App\Module\Sites\Application\Service\CurrentSiteProviderInterface;
use App\Module\Technique\Domain\Entity\Provider;
use App\Module\Technique\Domain\Entity\ProviderContact;
use App\Module\Technique\Domain\Repository\ProviderRepositoryInterface;
use App\Module\Technique\Infrastructure\ApiPlatform\State\Provider\ProviderProvider;
use App\Shared\Domain\Contract\CategoryInterface;
use App\Shared\Domain\Contract\SiteInterface;
use App\Shared\Domain\Contract\SpreadsheetExporterInterface;
use DateTimeImmutable;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;

/**
 * Export XLSX du repertoire prestataires (M3, spec-back § 4.6). Jumeau du
 * `SupplierExportController` (M2, module Commercial), augmente du cloisonnement
 * par site pilote par l'utilisateur (§ 2.13).
 *
 * Controller Symfony custom (et non operation API Platform) car il produit un
 * binaire de fichier, pas une representation Hydra. `priority: 1` est
 * OBLIGATOIRE sur la route : sans cela API Platform capterait
 * `/api/providers/export.xlsx` comme l'item `GET /api/providers/{id}.{_format}`
 * (id="export", _format="xlsx") — cf. CLAUDE.md « controller custom sous /api ».
 *
 * Separation des responsabilites :
 *  - le COMMENT (generation du fichier) est delegue au service Shared
 *    {@see SpreadsheetExporterInterface} — generique, reutilisable, sans metier ;
 *  - le QUOI vit ICI : selection des prestataires (memes filtres que
 *    `GET /api/providers`, via {@see ProviderRepositoryInterface::createListQueryBuilder()}),
 *    cloisonnement par site, et mapping metier des colonnes.
 *
 * Cloisonnement par site (RG-3.17, § 2.13) : replique la logique du
 * {@see ProviderProvider}
 * — un user sans `sites.bypass_scope` et possedant un currentSite n'exporte que
 * les prestataires rattaches a ce site (relation DIRECTE provider.sites). Le
 * QueryBuilder ne connait pas l'user : la decision est prise ICI, le DQL dans le
 * repository (applySiteScope).
 *
 * Colonnes de contact : alimentees par le CONTACT PRINCIPAL du prestataire — le
 * ProviderContact de plus petit `position` (decision D2, spec § 4.6).
 *
 * La colonne SIREN n'est ajoutee que si l'utilisateur a la permission
 * `technique.providers.accounting.view` (gating identique a la lecture, § 2.9).
 */
#[AsController]
final class ProviderExportController
{
    public function __construct(
        #[Autowire(service: 'App\Module\Technique\Infrastructure\Doctrine\DoctrineProviderRepository')]
        private readonly ProviderRepositoryInterface $repository,
        private readonly SpreadsheetExporterInterface $exporter,
        private readonly Security $security,
        // Outillage site-aware (cf. ProviderProvider) : resout le site courant pour
        // appliquer le cloisonnement RG-3.17 a l'export comme a la liste.
        private readonly CurrentSiteProviderInterface $currentSiteProvider,
    ) {}

    #[Route('/api/providers/export.xlsx', name: 'technique_providers_export_xlsx', methods: ['GET'], priority: 1)]
    #[IsGranted('technique.providers.view')]
    public function __invoke(Request $request): Response
    {
        // Memes filtres d'archivage que la vue liste (ProviderProvider) pour que
        // l'export reflete exactement ce que l'utilisateur voit a l'ecran :
        //  - includeArchived : inclut les archives en plus des actifs ;
        //  - archivedOnly    : restreint aux seules archives (prioritaire, cf.
        //    createListQueryBuilder).
        $includeArchived = $this->readBool($request->query->get('includeArchived'));
        $archivedOnly    = $this->readBool($request->query->get('archivedOnly'));
        $search          = $request->query->getString('search') ?: null;

        // Memes filtres que la vue liste : categoryCode/siteId tolerent une valeur
        // unique ou une liste (?categoryCode[]=A&siteId[]=1). On lit via all() pour
        // ne pas lever d'exception sur une valeur scalaire.
        $query         = $request->query->all();
        $categoryCodes = $this->readStringList($query['categoryCode'] ?? []);
        $siteIds       = $this->readIntList($query['siteId'] ?? []);

        $qb = $this->repository
            ->createListQueryBuilder($includeArchived, $search, $categoryCodes, $siteIds, $archivedOnly)
        ;

        // Cloisonnement par site (RG-3.17, § 2.13) AVANT materialisation : restreint
        // au currentSite pour un user non-bypass (s'intersecte avec un eventuel
        // ?siteId du client). No-op pour bypass_scope ou currentSite null.
        $scopeSite = $this->siteScopeOrNull();
        if (null !== $scopeSite) {
            $this->repository->applySiteScope($qb, (int) $scopeSite->getId());
        }

        /** @var list<Provider> $providers */
        $providers = $qb->getQuery()->getResult();

        // Hydratation batchee des collections affichees (§ 2.12) : le QB de
        // selection ne fetch-join pas les to-many. On remplit categories + sites en
        // lot (colonnes « Catégories » / « Sites »), puis les contacts (colonnes du
        // contact principal) — chacune en requetes IN bornees, anti N+1.
        $this->repository->hydrateListCollections($providers);
        $this->repository->hydrateContacts($providers);

        $withSiren = $this->security->isGranted('technique.providers.accounting.view');

        $binary = $this->exporter->export(
            'Répertoire prestataires',
            $this->buildHeaders($withSiren),
            $this->buildRows($providers, $withSiren),
        );

        return $this->buildResponse($binary);
    }

    /**
     * Site de cloisonnement a appliquer en LECTURE, ou null si aucun cloisonnement
     * (user `sites.bypass_scope`, ou pas de site courant resolu — module Sites off
     * / user sans currentSite). Miroir de ProviderProvider::siteScopeOrNull().
     */
    private function siteScopeOrNull(): ?SiteInterface
    {
        if ($this->security->isGranted('sites.bypass_scope')) {
            return null;
        }

        return $this->currentSiteProvider->get();
    }

    /**
     * Colonnes de l'export (spec § 4.6). SIREN inseree avant la date de creation,
     * uniquement si l'utilisateur a accounting.view.
     *
     * @return list<string>
     */
    private function buildHeaders(bool $withSiren): array
    {
        $headers = [
            'Nom prestataire',
            'Contact principal',
            'Téléphone principal',
            'Téléphone secondaire',
            'Email',
            'Catégories',
            'Sites',
        ];

        if ($withSiren) {
            $headers[] = 'SIREN';
        }

        $headers[] = 'Date de création';

        return $headers;
    }

    /**
     * @param list<Provider> $providers
     *
     * @return iterable<list<null|scalar>>
     */
    private function buildRows(array $providers, bool $withSiren): iterable
    {
        foreach ($providers as $provider) {
            $contact = $this->principalContact($provider);

            $row = [
                $provider->getCompanyName(),
                null !== $contact ? $this->formatContactName($contact) : '',
                $contact?->getPhonePrimary() ?? '',
                $contact?->getPhoneSecondary() ?? '',
                $contact?->getEmail() ?? '',
                $this->formatCategories($provider),
                $this->formatSites($provider),
            ];

            if ($withSiren) {
                $row[] = $provider->getSiren();
            }

            $row[] = $provider->getCreatedAt()?->format('d/m/Y');

            yield $row;
        }
    }

    /**
     * Contact principal du prestataire : le ProviderContact de plus petit
     * `position` (decision D2, spec § 4.6). Null si le prestataire n'a aucun
     * contact (les colonnes contact restent vides).
     */
    private function principalContact(Provider $provider): ?ProviderContact
    {
        $contacts = $provider->getContacts()->toArray();
        if ([] === $contacts) {
            return null;
        }

        usort(
            $contacts,
            static fn (ProviderContact $a, ProviderContact $b): int => $a->getPosition() <=> $b->getPosition(),
        );

        return $contacts[0];
    }

    /**
     * Libelle du contact principal « Nom Prénom » (spec § 4.6). Les deux parties
     * sont optionnelles (RG-3.04 : au moins l'une des deux), d'ou le trim final.
     */
    private function formatContactName(ProviderContact $contact): string
    {
        return trim(sprintf('%s %s', $contact->getLastName() ?? '', $contact->getFirstName() ?? ''));
    }

    /**
     * Libelles des categories du prestataire, dedupliques, tries, joints par
     * virgule.
     */
    private function formatCategories(Provider $provider): string
    {
        $names = [];
        foreach ($provider->getCategories() as $category) {
            // @var CategoryInterface $category
            $name = $category->getName();
            if (null !== $name && '' !== $name) {
                $names[$name] = true;
            }
        }

        return $this->joinSorted($names);
    }

    /**
     * Sites du prestataire (relation DIRECTE provider.sites, RG-3.03 — contrairement
     * au fournisseur M2 dont les sites sont portes par les adresses). La colonne
     * « Sites » agrege l'union distincte des sites rattaches.
     */
    private function formatSites(Provider $provider): string
    {
        $names = [];
        foreach ($provider->getSites() as $site) {
            // @var SiteInterface $site
            $name = $site->getName();
            if (null !== $name && '' !== $name) {
                $names[$name] = true;
            }
        }

        return $this->joinSorted($names);
    }

    /**
     * @param array<string, true> $names ensemble de libelles (cles)
     */
    private function joinSorted(array $names): string
    {
        $list = array_keys($names);
        sort($list);

        return implode(', ', $list);
    }

    private function buildResponse(string $binary): Response
    {
        $filename = sprintf('repertoire-prestataires-%s.xlsx', new DateTimeImmutable()->format('Ymd'));

        $response = new Response($binary);
        $response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
        $response->headers->set('Content-Disposition', sprintf('attachment; filename="%s"', $filename));

        return $response;
    }

    /**
     * Lit un flag booleen issu des query params. Accepte true / "true" / "1".
     * Aligne sur ProviderProvider pour un comportement identique a la liste.
     */
    private function readBool(mixed $raw): bool
    {
        return is_string($raw) && in_array(strtolower($raw), ['true', '1'], true);
    }

    /**
     * Normalise un filtre en liste de chaines (valeur unique ou liste).
     * Aligne sur ProviderProvider pour un comportement identique a la liste.
     *
     * @return list<string>
     */
    private function readStringList(mixed $raw): array
    {
        $values = is_array($raw) ? $raw : [$raw];

        $out = [];
        foreach ($values as $value) {
            if (is_string($value) && '' !== trim($value)) {
                $out[] = trim($value);
            }
        }

        return $out;
    }

    /**
     * Normalise un filtre en liste d'identifiants entiers positifs (valeur unique
     * ou liste). Aligne sur ProviderProvider.
     *
     * @return list<int>
     */
    private function readIntList(mixed $raw): array
    {
        $values = is_array($raw) ? $raw : [$raw];

        $out = [];
        foreach ($values as $value) {
            if ((is_int($value) || (is_string($value) && ctype_digit($value))) && (int) $value > 0) {
                $out[] = (int) $value;
            }
        }

        return $out;
    }
}
