<?php

declare(strict_types=1);

namespace App\Module\Technique\Infrastructure\Doctrine;

use App\Module\Technique\Domain\Entity\Provider;
use App\Module\Technique\Domain\Repository\ProviderRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;

/**
 * @extends ServiceEntityRepository<Provider>
 */
class DoctrineProviderRepository extends ServiceEntityRepository implements ProviderRepositoryInterface
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, Provider::class);
    }

    public function findById(int $id): ?Provider
    {
        return $this->find($id);
    }

    public function save(Provider $provider): void
    {
        $this->getEntityManager()->persist($provider);
        $this->getEntityManager()->flush();
    }

    public function createListQueryBuilder(
        bool $includeArchived = false,
        ?string $search = null,
        array $categoryCodes = [],
        array $siteIds = [],
        bool $archivedOnly = false,
    ): QueryBuilder {
        // SELECTION uniquement (filtres + tri) : pas de fetch-join to-many ici.
        // L'hydratation des collections affichees (Catégories / Site(s)) est
        // deleguee a hydrateListCollections() une fois le jeu borne, pour ne pas
        // imposer un produit cartesien aux chemins non pagines (export,
        // ?pagination=false) — § 2.12 (cf. M1/ERP-100, M2).
        $qb = $this->createQueryBuilder('p')
            ->andWhere('p.deletedAt IS NULL')
            ->orderBy('p.companyName', 'ASC')
        ;

        // Perimetre d'archivage : archivedOnly prioritaire sur includeArchived.
        if ($archivedOnly) {
            $qb->andWhere('p.isArchived = true');
        } elseif (!$includeArchived) {
            $qb->andWhere('p.isArchived = false');
        }

        $this->applySearch($qb, $search);
        $this->applyCategoryCodes($qb, $categoryCodes);
        $this->applySiteIds($qb, $siteIds);

        return $qb;
    }

    public function hydrateListCollections(array $providers): void
    {
        $ids = $this->collectIds($providers);
        if ([] === $ids) {
            return;
        }

        // 1re passe : categories (colonne « Catégories »). Produit p x cat seul.
        $this->createQueryBuilder('p')
            ->leftJoin('p.categories', 'cat')->addSelect('cat')
            ->where('p.id IN (:ids)')->setParameter('ids', $ids)
            ->getQuery()
            ->getResult()
        ;

        // 2e passe : sites (colonne « Site(s) »). SPECIFICITE M3 : les sites sont
        // portes DIRECTEMENT par le prestataire (provider.sites, RG-3.03), pas via
        // les adresses comme au M2 — d'ou un simple join p -> site (pas d'imbrication
        // addr -> site). Separer des categories casse le cartesien cat x site.
        $this->createQueryBuilder('p')
            ->leftJoin('p.sites', 'site')->addSelect('site')
            ->where('p.id IN (:ids)')->setParameter('ids', $ids)
            ->getQuery()
            ->getResult()
        ;
    }

    public function applySiteScope(QueryBuilder $qb, int $siteId): void
    {
        // Cloisonnement par site (RG-3.17, § 2.13) : ne garder que les prestataires
        // dont provider.sites contient le site donne. Sous-requete IN (alias p5
        // distinct des filtres p2/p3/p4) pour ne pas perturber le tri/pagination du
        // QueryBuilder principal — meme parti pris que applyCategoryCodes / applySiteIds.
        // Parametre :scopeSiteId distinct de :siteIds (filtre ?siteId du client) pour
        // que les deux clauses puissent coexister (intersection) sans collision.
        $sub = $this->getEntityManager()->createQueryBuilder()
            ->select('p5.id')
            ->from(Provider::class, 'p5')
            ->join('p5.sites', 'site5')
            ->where('site5.id = :scopeSiteId')
        ;

        $qb->andWhere($qb->expr()->in('p.id', $sub->getDQL()))
            ->setParameter('scopeSiteId', $siteId)
        ;
    }

    public function hydrateContacts(array $providers): void
    {
        $ids = $this->collectIds($providers);
        if ([] === $ids) {
            return;
        }

        // Une seule requete IN bornee : remplit la collection `contacts` des MEMES
        // instances Provider (identity map). Tri par position pour que le « contact
        // principal » (plus petit position) soit deterministe a l'export.
        $this->createQueryBuilder('p')
            ->leftJoin('p.contacts', 'pc')->addSelect('pc')
            ->where('p.id IN (:ids)')->setParameter('ids', $ids)
            ->orderBy('pc.position', 'ASC')
            ->getQuery()
            ->getResult()
        ;
    }

    /**
     * Recherche fuzzy insensible a la casse sur companyName ET sur les contacts
     * lies (firstName / lastName / email) — miroir M2. Les deux criteres sont unis
     * par OR : un prestataire matche si son nom de societe OU l'un de ses contacts
     * matche. Le critere contact passe par une sous-requete IN (plutot qu'un JOIN
     * sur la collection) pour ne pas perturber le DISTINCT / ORDER BY / pagination
     * principal. Les metacaracteres LIKE (%, _, \) saisis sont echappes pour rester
     * litteraux.
     */
    private function applySearch(QueryBuilder $qb, ?string $search): void
    {
        if (null === $search || '' === trim($search)) {
            return;
        }

        $escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], trim($search));
        $pattern = '%'.mb_strtolower($escaped, 'UTF-8').'%';

        $contactSub = $this->getEntityManager()->createQueryBuilder()
            ->select('p2.id')
            ->from(Provider::class, 'p2')
            ->join('p2.contacts', 'pc2')
            ->where('LOWER(pc2.firstName) LIKE :search')
            ->orWhere('LOWER(pc2.lastName) LIKE :search')
            ->orWhere('LOWER(pc2.email) LIKE :search')
        ;

        $qb->andWhere(
            $qb->expr()->orX(
                'LOWER(p.companyName) LIKE :search',
                $qb->expr()->in('p.id', $contactSub->getDQL()),
            ),
        )->setParameter('search', $pattern);
    }

    /**
     * Restreint aux prestataires possedant au moins une categorie dont le code
     * figure dans la liste (OR). Alimente le filtre « Catégories » du drawer.
     * Sous-requete IN (plutot qu'un JOIN sur la collection M2M) pour ne pas
     * perturber le DISTINCT / ORDER BY principal.
     *
     * @param list<string> $categoryCodes
     */
    private function applyCategoryCodes(QueryBuilder $qb, array $categoryCodes): void
    {
        $codes = $this->normalizeStringList($categoryCodes);
        if ([] === $codes) {
            return;
        }

        $sub = $this->getEntityManager()->createQueryBuilder()
            ->select('p3.id')
            ->from(Provider::class, 'p3')
            ->join('p3.categories', 'cat3')
            ->where('cat3.code IN (:categoryCodes)')
        ;

        $qb->andWhere($qb->expr()->in('p.id', $sub->getDQL()))
            ->setParameter('categoryCodes', $codes)
        ;
    }

    /**
     * Restreint aux prestataires rattaches a l'un des sites donnes (OR). SPECIFICITE
     * M3 : les sites sont portes DIRECTEMENT par le prestataire (provider.sites,
     * RG-3.03), d'ou une sous-requete sur p.sites (et non sur les adresses comme au
     * M2). Sous-requete IN pour ne pas perturber le tri/pagination principal.
     *
     * @param list<int> $siteIds
     */
    private function applySiteIds(QueryBuilder $qb, array $siteIds): void
    {
        $ids = $this->normalizeIntList($siteIds);
        if ([] === $ids) {
            return;
        }

        $sub = $this->getEntityManager()->createQueryBuilder()
            ->select('p4.id')
            ->from(Provider::class, 'p4')
            ->join('p4.sites', 'site4')
            ->where('site4.id IN (:siteIds)')
        ;

        $qb->andWhere($qb->expr()->in('p.id', $sub->getDQL()))
            ->setParameter('siteIds', $ids)
        ;
    }

    /**
     * Extrait les identifiants non nuls d'un jeu de prestataires (entites managees).
     * Les requetes d'hydratation renvoient les MEMES instances Provider (identity
     * map), dont les collections sont alors remplies — anti N+1 a la serialisation.
     *
     * @param list<Provider> $providers
     *
     * @return list<int>
     */
    private function collectIds(array $providers): array
    {
        $ids = [];
        foreach ($providers as $provider) {
            $id = $provider->getId();
            if (null !== $id) {
                $ids[] = $id;
            }
        }

        return $ids;
    }

    /**
     * Nettoie une liste de chaines : trim, retrait des vides, reindexation.
     * Defensive : tolere des elements scalaires non-string (cast) et ignore le
     * reste sans lever de TypeError, le contrat etant de normaliser une entree
     * potentiellement brute (query params).
     *
     * @param array<mixed> $values
     *
     * @return list<string>
     */
    private function normalizeStringList(array $values): array
    {
        $out = [];
        foreach ($values as $value) {
            if (is_string($value) || is_int($value) || is_float($value)) {
                $trimmed = trim((string) $value);
                if ('' !== $trimmed) {
                    $out[] = $trimmed;
                }
            }
        }

        return $out;
    }

    /**
     * Nettoie une liste d'identifiants : cast int, retrait des <= 0, reindexation.
     * Defensive (cf. normalizeStringList) : accepte des entiers ou des chaines
     * numeriques ('1', '2') sans TypeError, ignore le reste.
     *
     * @param array<mixed> $values
     *
     * @return list<int>
     */
    private function normalizeIntList(array $values): array
    {
        $out = [];
        foreach ($values as $value) {
            if (is_numeric($value) && (int) $value > 0) {
                $out[] = (int) $value;
            }
        }

        return $out;
    }
}
