"""Résolution des marqueurs de citation du corpus.

Le rapport consolidé relie chacun de ses faits à sa source par un marqueur de
la forme ``[^dim07-24^]``. La chaîne de résolution est ::

    sec07.md : « … chlordécone [^dim07-24^] »
        → rapport de dimension dim07, référence locale n° 24
        → bloc sourcé ou note de bas de page portant ce numéro
        → Source vérifiable (URL, éditeur, date, extrait verbatim)

Encore faut-il savoir ce que « référence locale n° 24 » désigne : le corpus
emploie **six conventions de numérotation** selon la dimension, appliquées ici
par ordre de priorité décroissante.

=============  =========================================  =========================
Convention     Exemple                                    Dimensions concernées
=============  =========================================  =========================
marqueur       ``Claim: … [^24^]``                        dim01, dim02, dim04,
de fin                                                    dim05, dim07, dim08
en-tête        ``**Claim 1.2** — …``                      dim10, phase5
de claim
section        ``### 1.4. LPM actualisée …``              dim10, dim11, dim12
rang           1ᵉʳ claim du fichier = n° 1                dim03
note           ``[^1614^] assemblee-nationale.fr — …``    dim06, dim08, dim09
section.rang   section 2, 1ᵉʳ bloc → « 2.1 »              dim11
=============  =========================================  =========================

Les indexer toutes fait passer la résolution de 55 % à 100 % des 332 marqueurs
du rapport, dont 93 % avec un lien vérifiable. Les 7 % restants désignent une
référence réelle mais dépourvue d'URL dans le corpus — presque toutes dans
dim09, qui ne compte que cinq lignes contenant une adresse pour soixante-cinq
notes ; `_rapprocher_notes_sans_lien` en récupère une partie via la
bibliographie consolidée.

`insight` est le seul préfixe volontairement non résolu : la table de
correspondance de la bibliographie indique qu'il ne porte « aucune source
primaire propre ».
"""

from __future__ import annotations

import re
from dataclasses import dataclass, field

from pipeline import chemins
from pipeline.journal import logger
from pipeline.modeles import Confiance, Source
from pipeline.parseurs import bibliographie, blocs_claim, notes_bas_page
from pipeline.parseurs.blocs_claim import BlocClaim
from pipeline.parseurs.dates_fr import sans_accents
from pipeline.parseurs.markdown import Marqueur, extraire_marqueurs
from pipeline.parseurs.notes_bas_page import Note

log = logger("parseur.citations")

DIMENSIONS_SANS_SOURCE = frozenset({"insight"})

# « ### 1.4. LPM actualisée … », « ## 3.1 Parcours … », « ## POINT 5 — … »
_ENTETE_NUMEROTEE = re.compile(r"^#{2,4}\s+(?:POINT\s+)?([\d]+(?:\.[\d]+)*)\.?\s", re.IGNORECASE)
_ENTETE_CLAIM = re.compile(r"^\s*(?:[-*]\s*)?\*{0,2}\s*Claim\s+([\d]+(?:\.[\d]+)*)", re.IGNORECASE)

# Mots trop courants pour distinguer deux références l'une de l'autre.
_MOTS_VIDES = frozenset(
    """a au aux avec ce ces dans de des du elle en et eux il ils je la le les leur
    lui ma mais me meme mes moi mon ne nos notre nous on ou par pas pour qu que qui
    sa se ses son sur ta te tes toi ton tu un une vos votre vous sont est etre plus
    loi projet proposition texte article articles n no ndeg juillet juin mai avril
    aout septembre octobre 2026 2025 fr com www http https org gouv""".split()
)


@dataclass(slots=True)
class Resolution:
    """Ce qu'un marqueur désigne, avec ou sans lien exploitable."""

    marqueur: str
    source: Source | None = None
    libelle: str | None = None      # texte de la référence, même sans URL
    origine: str = ""               # « bloc », « note », « section », « rang »


@dataclass(slots=True)
class Repertoire:
    """Index de résolution construit une fois pour tout le seed."""

    entrees: dict[str, dict[str, Resolution]] = field(default_factory=dict)
    references: list[bibliographie.Reference] = field(default_factory=list)
    par_numero_loi: dict[str, list[bibliographie.Reference]] = field(default_factory=dict)
    par_affaire_cc: dict[str, list[bibliographie.Reference]] = field(default_factory=dict)
    par_document: dict[str, list[bibliographie.Reference]] = field(default_factory=dict)
    blocs_par_dimension: dict[str, list[BlocClaim]] = field(default_factory=dict)
    correspondances: dict[str, set[int]] = field(default_factory=dict)
    rapprochements: int = 0

    # Diagnostic d'import
    resolus_avec_lien: list[str] = field(default_factory=list)
    resolus_sans_lien: list[str] = field(default_factory=list)
    non_resolus: list[str] = field(default_factory=list)

    # ── Résolution ───────────────────────────────────────────────────────────
    def _entree(self, marqueur: Marqueur) -> Resolution | None:
        if not marqueur.dimension or marqueur.dimension in DIMENSIONS_SANS_SOURCE:
            return None
        return self.entrees.get(marqueur.dimension, {}).get(marqueur.numero)

    def resoudre(self, marqueur: Marqueur | str) -> Source | None:
        """Rend la source d'un marqueur, ou `None` s'il n'en désigne aucune."""
        marqueur = _normaliser(marqueur)
        if marqueur is None:
            return None

        entree = self._entree(marqueur)
        if entree is None:
            if marqueur.dimension not in DIMENSIONS_SANS_SOURCE:
                self.non_resolus.append(marqueur.brut)
            return None

        if entree.source is not None:
            self.resolus_avec_lien.append(marqueur.brut)
            return entree.source

        self.resolus_sans_lien.append(marqueur.brut)
        return None

    def libelle(self, marqueur: Marqueur | str) -> str | None:
        """Texte de la référence, y compris lorsqu'elle n'a pas d'URL."""
        marqueur = _normaliser(marqueur)
        entree = self._entree(marqueur) if marqueur else None
        return entree.libelle if entree else None

    def resoudre_plusieurs(self, marqueurs: list[Marqueur] | list[str]) -> list[Source]:
        """Résout une liste de marqueurs, en écartant les doublons d'URL."""
        sources: list[Source] = []
        vues: set[str] = set()
        for marqueur in marqueurs:
            source = self.resoudre(marqueur)
            if source and source.url not in vues:
                vues.add(source.url)
                sources.append(source)
        return sources

    def references_du_texte(
        self,
        *,
        numero_loi: str | None = None,
        affaires: list[str] | None = None,
        documents: list[str] | None = None,
    ) -> list[Source]:
        """Sources bibliographiques pour un numéro de loi, une affaire ou une cote.

        La recherche par cote de document parlementaire est le seul recours
        pour les textes qui n'ont pas de numéro de loi : une proposition
        déposée et non examinée n'est identifiée que par sa cote, « AN n° 3025 ».
        """
        sources: list[Source] = []
        vues: set[str] = set()

        candidates: list[bibliographie.Reference] = []
        if numero_loi:
            candidates.extend(self.par_numero_loi.get(numero_loi, []))
        for affaire in affaires or []:
            candidates.extend(self.par_affaire_cc.get(affaire, []))
        for document in documents or []:
            candidates.extend(self.par_document.get(document, []))

        for reference in candidates:
            source = reference.en_source()
            if source and source.url not in vues:
                vues.add(source.url)
                sources.append(source)

        return sources

    @property
    def taux_de_resolution(self) -> float:
        """Part des marqueurs qui aboutissent à une source ou à une référence."""
        total = len(self.resolus_avec_lien) + len(self.resolus_sans_lien) + len(self.non_resolus)
        if not total:
            return 0.0
        return (len(self.resolus_avec_lien) + len(self.resolus_sans_lien)) / total

    def remettre_a_zero_diagnostic(self) -> None:
        self.resolus_avec_lien.clear()
        self.resolus_sans_lien.clear()
        self.non_resolus.clear()


def _normaliser(marqueur: Marqueur | str) -> Marqueur | None:
    if isinstance(marqueur, Marqueur):
        return marqueur
    trouves = extraire_marqueurs(f"[^{marqueur}^]")
    return trouves[0] if trouves else None


# ─────────────────────────────────────────────────────────────────────────────
# Construction de l'index
# ─────────────────────────────────────────────────────────────────────────────
def construire() -> Repertoire:
    """Charge et indexe l'ensemble des sources du corpus."""
    repertoire = Repertoire()

    for numero in range(1, 13):
        dimension = f"dim{numero:02d}"
        markdown = chemins.dimension(numero).read_text(encoding="utf-8")
        blocs = blocs_claim.parser(markdown, dimension)
        notes = notes_bas_page.parser(markdown, dimension)
        repertoire.blocs_par_dimension[dimension] = blocs
        repertoire.entrees[dimension] = _indexer(markdown, blocs, notes)

    phase5 = chemins.PHASE5_VALIDATION.read_text(encoding="utf-8")
    blocs_phase5 = blocs_claim.parser(phase5, "phase5")
    repertoire.blocs_par_dimension["phase5"] = blocs_phase5
    repertoire.entrees["phase5"] = _indexer(phase5, blocs_phase5, [])

    markdown_biblio = chemins.BIBLIOGRAPHIE.read_text(encoding="utf-8")
    repertoire.references = bibliographie.parser(markdown_biblio)
    repertoire.par_numero_loi = bibliographie.indexer_par_numero(repertoire.references)
    repertoire.par_affaire_cc = bibliographie.indexer_par_affaire(repertoire.references)
    repertoire.par_document = bibliographie.indexer_par_document(repertoire.references)
    repertoire.correspondances = bibliographie.lire_table_de_correspondance(markdown_biblio)

    repertoire.rapprochements = _rapprocher_notes_sans_lien(repertoire)

    avec_lien = sum(
        1 for entrees in repertoire.entrees.values() for e in entrees.values() if e.source
    )
    log.info(
        "répertoire de citations construit",
        dimensions=len(repertoire.entrees),
        entrees=sum(len(e) for e in repertoire.entrees.values()),
        avec_lien=avec_lien,
        references=len(repertoire.references),
    )
    return repertoire


def _indexer(
    markdown: str, blocs: list[BlocClaim], notes: list[Note]
) -> dict[str, Resolution]:
    """Construit l'index d'une dimension selon les cinq conventions du corpus.

    L'ordre d'insertion vaut priorité : une convention n'écrase jamais une
    entrée déjà posée par une convention plus explicite.
    """
    index: dict[str, Resolution] = {}

    def poser(cle: str, resolution: Resolution) -> None:
        if cle and cle not in index:
            index[cle] = resolution

    # 1. Marqueur de fin de claim — la convention la plus explicite.
    for bloc in blocs:
        for marqueur in bloc.marqueurs:
            poser(marqueur, _depuis_bloc(bloc, "bloc"))

    # 2. Numéro porté par l'en-tête du claim (« **Claim 1.2** — … »).
    for bloc, numero in _numeros_d_entete(markdown, blocs):
        poser(numero, _depuis_bloc(bloc, "en-tête"))

    # 3. Numéro de section : le premier bloc sourcé de la section en tient lieu.
    for numero, bloc in _blocs_par_section(markdown, blocs).items():
        poser(numero, _depuis_bloc(bloc, "section"))

    # 4. Rang du claim dans le fichier (dim03 ne numérote rien d'autre).
    for rang, bloc in enumerate(blocs, start=1):
        poser(str(rang), _depuis_bloc(bloc, "rang"))

    # 5. Notes de bas de page — souvent sans URL, mais toujours un libellé.
    for note in notes:
        source = note.en_source()
        if note.numero in index and index[note.numero].source is not None:
            continue
        index[note.numero] = Resolution(
            marqueur=note.numero,
            source=source,
            libelle=note.texte or None,
            origine="note",
        )

    # 6. Composition « section.rang » : dim11 numérote ses sections mais pas ses
    #    affirmations, et le rapport la cite en `[^dim11-2.1^]` — soit le
    #    premier bloc de la section 2. Convention de dernier recours.
    for numero_compose, bloc in _blocs_par_section_et_rang(markdown, blocs).items():
        poser(numero_compose, _depuis_bloc(bloc, "section.rang"))

    return index


def _blocs_par_section_et_rang(
    markdown: str, blocs: list[BlocClaim]
) -> dict[str, BlocClaim]:
    """Numérote les blocs d'une section : section 2, 1ᵉʳ bloc → clé « 2.1 »."""
    lignes = markdown.splitlines()
    frontieres = [
        (numero, entete.group(1))
        for numero, ligne in enumerate(lignes, start=1)
        if (entete := _ENTETE_NUMEROTEE.match(ligne))
    ]
    if not frontieres:
        return {}

    composes: dict[str, BlocClaim] = {}
    for index, (debut, section) in enumerate(frontieres):
        if "." in section:  # déjà une sous-section, elle est indexée telle quelle
            continue
        fin = frontieres[index + 1][0] if index + 1 < len(frontieres) else len(lignes) + 1
        for rang, bloc in enumerate(
            (b for b in blocs if debut < b.ligne < fin), start=1
        ):
            composes.setdefault(f"{section}.{rang}", bloc)

    return composes


def _rapprocher_notes_sans_lien(repertoire: Repertoire) -> int:
    """Donne une URL aux références qui n'en portent pas dans leur dimension.

    dim09 — la dimension « associations », centrale pour la mission — ne compte
    que 5 lignes contenant une URL pour 65 notes. Ses sources existent pourtant :
    la bibliographie consolidée les liste toutes, et la table de correspondance
    dit lesquelles lui appartiennent.

    Le rapprochement est délibérément prudent : il ne cherche que parmi les
    références attribuées à la dimension, exige au moins trois mots significatifs
    communs et un recouvrement d'au moins deux tiers du libellé de la note, et
    n'accepte le résultat que s'il est **strictement meilleur** que le suivant.
    Une correspondance ambiguë est refusée plutôt que devinée.
    """
    par_numero = {reference.numero: reference for reference in repertoire.references}
    rapproches = 0

    for dimension, entrees in repertoire.entrees.items():
        numeros_autorises = repertoire.correspondances.get(dimension)
        if not numeros_autorises:
            continue

        candidates = [
            (reference, _mots_significatifs(f"{reference.editeur or ''} {reference.titre}"))
            for numero in sorted(numeros_autorises)
            if (reference := par_numero.get(numero)) and reference.url
        ]
        if not candidates:
            continue

        for resolution in entrees.values():
            if resolution.source is not None or not resolution.libelle:
                continue

            reference = _meilleure_correspondance(resolution.libelle, candidates)
            if reference is None:
                continue

            source = reference.en_source()
            if source is None:
                continue

            resolution.source = Source(
                url=source.url,
                titre=resolution.libelle[:280],
                editeur=reference.editeur,
                date_publication=source.date_publication,
                confiance=Confiance.MEDIUM,
                marqueur=f"{dimension}-{resolution.marqueur or '?'}",
                fichier_origine=f"research/lois-2026_{dimension}.md "
                f"(lien repris de la bibliographie, réf. {reference.numero})",
            )
            resolution.origine = "note→bibliographie"
            rapproches += 1

    log.info("notes sans lien rapprochées de la bibliographie", rapprochements=rapproches)
    return rapproches


def _mots_significatifs(texte: str) -> set[str]:
    plat = sans_accents(texte).lower()
    return {
        mot
        for mot in re.split(r"[^a-z0-9]+", plat)
        if len(mot) > 2 and mot not in _MOTS_VIDES
    }


def _meilleure_correspondance(
    libelle: str, candidates: list[tuple[bibliographie.Reference, set[str]]]
) -> bibliographie.Reference | None:
    mots_note = _mots_significatifs(libelle)
    if len(mots_note) < 3:
        return None

    scores: list[tuple[float, int, bibliographie.Reference]] = []
    for reference, mots_reference in candidates:
        communs = mots_note & mots_reference
        if len(communs) < 3:
            continue
        scores.append((len(communs) / len(mots_note), len(communs), reference))

    if not scores:
        return None

    scores.sort(key=lambda triplet: (-triplet[0], -triplet[1]))
    meilleur = scores[0]
    if meilleur[0] < 0.66:
        return None
    # Ambiguïté : deux références également plausibles, on s'abstient.
    if len(scores) > 1 and abs(scores[1][0] - meilleur[0]) < 1e-9:
        return None

    return meilleur[2]


def _depuis_bloc(bloc: BlocClaim, origine: str) -> Resolution:
    sources = bloc.en_sources()
    return Resolution(
        marqueur=bloc.marqueurs[0] if bloc.marqueurs else "",
        source=sources[0] if sources else None,
        libelle=bloc.affirmation or None,
        origine=origine,
    )


def _numeros_d_entete(markdown: str, blocs: list[BlocClaim]) -> list[tuple[BlocClaim, str]]:
    """Associe chaque bloc au numéro figurant dans sa ligne d'ouverture."""
    par_ligne = {bloc.ligne: bloc for bloc in blocs}
    associations: list[tuple[BlocClaim, str]] = []

    for numero_ligne, ligne in enumerate(markdown.splitlines(), start=1):
        entete = _ENTETE_CLAIM.match(ligne)
        if entete and numero_ligne in par_ligne:
            associations.append((par_ligne[numero_ligne], entete.group(1)))

    return associations


def _blocs_par_section(markdown: str, blocs: list[BlocClaim]) -> dict[str, BlocClaim]:
    """Associe un numéro de section au premier bloc sourcé qu'elle contient.

    C'est la seule façon de résoudre ``[^dim11-1.4^]`` : dim11 ne numérote pas
    ses affirmations, elle numérote ses sections (« ### 1.4. LPM actualisée »).
    """
    lignes = markdown.splitlines()
    frontieres: list[tuple[int, str]] = []

    for numero_ligne, ligne in enumerate(lignes, start=1):
        if entete := _ENTETE_NUMEROTEE.match(ligne):
            frontieres.append((numero_ligne, entete.group(1)))

    if not frontieres:
        return {}

    par_section: dict[str, BlocClaim] = {}
    for index, (debut, numero) in enumerate(frontieres):
        fin = frontieres[index + 1][0] if index + 1 < len(frontieres) else len(lignes) + 1
        candidats = [b for b in blocs if debut < b.ligne < fin]
        # Un bloc avec URL vaut mieux qu'un bloc sans, à section égale.
        avec_lien = next((b for b in candidats if b.urls), None)
        retenu = avec_lien or (candidats[0] if candidats else None)
        if retenu is not None and numero not in par_section:
            par_section[numero] = retenu

    return par_section
