"""Parseur de l'annexe récapitulative (`lois-france-2026-guadeloupe_sec10.md`).

C'est le chemin le plus fiable du seed : deux tableaux tenus à jour à la main
par la recherche, avec numéros de loi, dates de promulgation, statuts au
25 juillet 2026, cotation des libertés et pertinence Guadeloupe.

- Tableau A : 27 lois promulguées du 23 avril au 23 juillet 2026.
- Tableau B : 23 lignes de textes adoptés non promulgués, en navette, déposés
  ou annoncés. Deux de ces lignes portent chacune deux textes distincts, avec
  des statuts différents : elles sont éclatées (voir `LIGNES_A_ECLATER`).

Le parseur ne produit que ce que les cellules disent. Tout ce qui manque —
résumé, points clés, impacts par public — est rempli plus loin dans le seed, à
partir des chapitres et des rapports de dimension.
"""

from __future__ import annotations

import re
from dataclasses import dataclass, field
from datetime import date

from pipeline.journal import logger
from pipeline.modeles import (
    Confiance,
    DecisionCC,
    Evenement,
    Pertinence,
    RegimeLibertes,
    ResultatCC,
    Statut,
    Texte,
    Theme,
    TypeEtape,
    TypeTexte,
)
from pipeline.parseurs.dates_fr import lire_date, lire_echeance, sans_accents, toutes_les_dates
from pipeline.parseurs.markdown import Tableau, extraire_marqueurs, extraire_tableaux, nettoyer

log = logger("parseur.sec10")

ANNEE_CORPUS = 2026

MOTIF_AFFAIRE = re.compile(r"\b(\d{4}-\d{1,4})\s*DC\b")
MOTIF_NUMERO_LOI = re.compile(r"^(LO\s+)?(\d{4}-\d{1,4})$", re.IGNORECASE)


# ─────────────────────────────────────────────────────────────────────────────
# Correspondances déclaratives
#
# Ces tables ne sont pas des données : ce sont des identifiants et des
# rattachements. Chaque entrée est justifiée par la ligne du corpus qu'elle
# désigne, citée en commentaire.
# ─────────────────────────────────────────────────────────────────────────────

# Identifiants canoniques du tableau B, indexés par le début de l'intitulé
# (comparaison sans accent ni casse). Les trois premiers sont imposés par le §7
# du cahier des charges ; les autres suivent la même logique de lisibilité.
SLUGS_TABLEAU_B: dict[str, str] = {
    "loi « riposte »": "loi-riposte",
    "ppl relative au droit a l'aide a mourir": "ppl-aide-a-mourir",
    "pjl d'habilitation": "pjl-pacte-migration",
    "loi « philippine »": "loi-philippine",
    "ppl reseaux sociaux et mineurs": "ppl-reseaux-sociaux-mineurs",
    "loi d'urgence pour la protection et la souverainete agricoles": "loi-urgence-agricole",
    "loi actualisant la programmation militaire": "loi-programmation-militaire",
    "ppl « pour une montagne vivante": "ppl-montagne",
    "ppl relative au patrimoine immobilier": "ppl-patrimoine-immobilier-etat",
    "pjl relatif a la protection des enfants": "pjl-protection-enfants",
    "pjl cohesion republicaine": "pjl-cohesion-republicaine",
    "pjl logement": "pjl-logement",
    "accord globe": "accord-globe",
    "ppl « entrisme »": "ppl-entrisme",
    "pjl « separatisme »": "pjl-separatisme",
    "ppl « outrage a l'hymne": "ppl-outrage-hymne-drapeau",
    "ppl d'abrogation du « code noir »": "ppl-abrogation-code-noir",
    "ppl « adaptation du droit des outre-mer »": "ppl-adaptation-droit-outre-mer",
    "ppl « dispositif ultramarin d'emploi durable »": "ppl-emploi-ultramarin",
    "ppl « entites naturelles": "ppl-entites-naturelles",
    "ppl visant a mettre fin au devoir conjugal": "ppl-devoir-conjugal",
}


@dataclass(slots=True)
class Eclatement:
    """Découpe une ligne du tableau B qui décrit deux textes distincts."""

    slug: str
    titre: str
    type: TypeTexte
    statut: Statut
    # Fragment de la cellule « Statut » qui concerne ce texte-là, pour que la
    # datation ne mélange pas les deux parcours.
    fragment_statut: str
    note: str


# Deux lignes du tableau B portent deux textes chacune, avec des statuts
# différents. Les laisser fusionnées reviendrait à attribuer un statut unique à
# deux parcours parlementaires distincts.
LIGNES_A_ECLATER: dict[str, list[Eclatement]] = {
    # « Accords Kazakhstan (réadmission, AN n° 2416) et Colombie (extradition,
    #   AN n° 2509) | Adoptés par le Sénat (28 janv. et 18 févr. 2026) ; en
    #   attente à l'Assemblée »
    "accords kazakhstan": [
        Eclatement(
            slug="accord-kazakhstan-readmission",
            titre="Accord France-Kazakhstan de réadmission (AN n° 2416)",
            type=TypeTexte.ACCORD_INTERNATIONAL,
            statut=Statut.NAVETTE,
            fragment_statut="Adopté par le Sénat le 28 janv. 2026 ; en attente à l'Assemblée",
            note="Réadmission vers des « hubs » tiers (clause de non-refoulement)",
        ),
        Eclatement(
            slug="accord-colombie-extradition",
            titre="Accord France-Colombie d'extradition (AN n° 2509)",
            type=TypeTexte.ACCORD_INTERNATIONAL,
            statut=Statut.NAVETTE,
            fragment_statut="Adopté par le Sénat le 18 févr. 2026 ; en attente à l'Assemblée",
            note="Extradition",
        ),
    ],
    # « MACF : ratification de l'ordonnance n° 2026-326 et accord « plateforme
    #   centrale commune » | Ratification déposée au Sénat, non examinée
    #   (~10 juill.) ; accord plateforme en attente de dépôt »
    "macf : ratification": [
        Eclatement(
            slug="pjl-ratification-ordonnance-macf",
            titre="Ratification de l'ordonnance n° 2026-326 (mécanisme d'ajustement "
            "carbone aux frontières)",
            type=TypeTexte.PJL,
            statut=Statut.DEPOSEE_NON_EXAMINEE,
            fragment_statut="Déposée au Sénat, non examinée (~10 juill. 2026)",
            note="Obligations déclaratives des importateurs ; négoces guadeloupéens "
            "de matériaux tiers concernés",
        ),
        Eclatement(
            slug="accord-macf-plateforme-centrale",
            titre="Accord « plateforme centrale commune » (MACF)",
            type=TypeTexte.ACCORD_INTERNATIONAL,
            statut=Statut.ANNONCEE,
            fragment_statut="En attente de dépôt",
            note="Obligations déclaratives des importateurs",
        ),
    ],
}

# Rattachement des décisions du Conseil constitutionnel rendues sur les lois du
# tableau A. Les numéros figurent dans la colonne « Statut libertés », le
# résultat dans `cross_verification.md` §1.
RESULTATS_CC_CONNUS: dict[str, tuple[ResultatCC, str]] = {
    "2026-903 DC": (
        ResultatCC.NON_CONFORMITE_PARTIELLE,
        "25 articles censurés (ZFE art. 37, dérogations ZAN art. 35 §IV)",
    ),
    "2026-904 DC": (
        ResultatCC.NON_CONFORMITE_PARTIELLE,
        "Articles 10, 12 (numéro d'identification des associations) et 32 censurés "
        "comme cavaliers ; 6 réserves",
    ),
    "2026-905 DC": (ResultatCC.CONFORME, "Conforme"),
    "2026-906 DC": (
        ResultatCC.CONFORME_AVEC_RESERVES,
        "Conforme avec réserves (décision du 23 juillet 2026)",
    ),
    "2026-908 DC": (ResultatCC.CONFORME, "Conforme"),
    "2026-909 DC": (
        ResultatCC.NON_CONFORMITE_PARTIELLE,
        "Censure du portrait-robot génétique et des bases ADN étrangères",
    ),
}

# Mots-clés de thème. Volontairement conservateurs : mieux vaut un thème
# manquant qu'un thème inventé — les chapitres affinent en phase suivante.
MOTS_CLES_THEMES: dict[Theme, tuple[str, ...]] = {
    Theme.JUSTICE: (
        "justice", "penal", "criminel", "juge", "avocat", "detention", "victimes",
        "privation de liberte", "extradition", "defense", "juridiction",
        "consentement", "conjugal", "mariage", "outrage", "delit", "amende",
    ),
    Theme.SECURITE: (
        "securite", "ordre public", "police", "retention", "surveillance", "fouille",
        "attentat", "tranquillite", "militaire", "defense nationale", "rodeo",
    ),
    Theme.NUMERIQUE: (
        "numerique", "reseaux sociaux", "donnees de connexion", "en ligne",
        "plateforme", "geolocalisation", "facturation electronique",
    ),
    Theme.SOCIAL: (
        "social", "chomage", "travail", "emploi", "petite enfance", "conge",
        "professionnalisation", "logement", "formation", "salarie", "enfant",
    ),
    Theme.FISCAL: ("fiscal", "impot", "taxe", "fraude", "conventions fiscales", "octroi de mer"),
    Theme.ENVIRONNEMENT: (
        "environnement", "ecolog", "inondation", "hydroelectric", "eau", "littoral",
        "carbone", "pesticide", "neonicotinoide", "montagne", "zan", "textile",
    ),
    Theme.AGRICULTURE: ("agricole", "agriculture", "exploitation", "peche", "elevage"),
    Theme.SANTE: (
        "sante", "medecin", "soins", "palliatif", "malade", "handicap", "chlordecone",
        "aide a mourir", "psychiatrique", "protoxyde",
    ),
    Theme.MEMOIRE_PATRIMOINE: (
        "memoire", "patrimoine", "restitution", "biens culturels", "restes humains",
        "code noir", "reparation", "esclavage", "transplantes",
    ),
    Theme.OUTRE_MER: (
        "outre-mer", "outre mer", "ultramarin", "guadeloupe", "martinique", "guyane",
        "reunion", "mayotte", "drom", "caricom", "caraibe", "saint-martin",
        "nouvelle-caledonie", "calédonie", "kali'na", "arawak",
    ),
    Theme.ECONOMIE: (
        "economique", "entreprise", "commercial", "creances", "concentration",
        "marches publics", "simplification", "pme", "importateur", "concurrence",
    ),
    Theme.MIGRATION: ("migration", "asile", "readmission", "etranger", "frontiere", "expulsion"),
    Theme.INSTITUTIONS: (
        "organique", "corps electoral", "senatoriales", "constitution", "habilitation",
        "collectivite", "prefet", "prefectoral", "etat", "association", "republicain",
        "subvention", "dissolution", "hymne", "drapeau", "expression",
    ),
}


@dataclass(slots=True)
class ResultatSeed:
    """Ce qu'un parseur rend au seed : des textes et un compte-rendu."""

    textes: list[Texte] = field(default_factory=list)
    avertissements: list[str] = field(default_factory=list)
    # Marqueurs de citation portés par la ligne d'annexe de chaque texte.
    # Les lignes éclatées transmettent les leurs aux deux textes issus d'elles :
    # sans cela, l'accord « plateforme centrale » MACF n'hériterait d'aucune
    # source, alors que sa ligne d'origine en cite trois.
    marqueurs: dict[str, list[str]] = field(default_factory=dict)

    def __len__(self) -> int:
        return len(self.textes)


# ─────────────────────────────────────────────────────────────────────────────
# Point d'entrée
# ─────────────────────────────────────────────────────────────────────────────
def parser(markdown: str) -> ResultatSeed:
    """Extrait tous les textes de l'annexe récapitulative."""
    resultat = ResultatSeed()
    tableaux = extraire_tableaux(markdown)

    tableau_a = _trouver(tableaux, "N° de loi")
    tableau_b = _trouver(tableaux, "Texte")

    if tableau_a is None:
        resultat.avertissements.append("Tableau A introuvable dans sec10.md")
    else:
        for ligne in tableau_a.lignes:
            try:
                texte = _lire_loi_promulguee(ligne)
                resultat.textes.append(texte)
                resultat.marqueurs[texte.id] = _marqueurs_de_ligne(ligne)
            except Exception as erreur:  # noqa: BLE001 — on continue le lot
                resultat.avertissements.append(f"Tableau A, ligne {ligne!r} : {erreur}")

    if tableau_b is None:
        resultat.avertissements.append("Tableau B introuvable dans sec10.md")
    else:
        for ligne in tableau_b.lignes:
            try:
                issus = _lire_texte_en_cours(ligne)
                marqueurs = _marqueurs_de_ligne(ligne)
                resultat.textes.extend(issus)
                for texte in issus:
                    resultat.marqueurs[texte.id] = list(marqueurs)
            except Exception as erreur:  # noqa: BLE001
                resultat.avertissements.append(f"Tableau B, ligne {ligne!r} : {erreur}")

    log.info(
        "annexe analysée",
        promulguees=len(tableau_a.lignes) if tableau_a else 0,
        en_cours=len(tableau_b.lignes) if tableau_b else 0,
        textes=len(resultat.textes),
        avertissements=len(resultat.avertissements),
    )
    return resultat


def _marqueurs_de_ligne(ligne: dict[str, str]) -> list[str]:
    """Marqueurs de citation portés par une ligne, toutes colonnes confondues."""
    marqueurs: list[str] = []
    for cellule in ligne.values():
        for marqueur in extraire_marqueurs(cellule):
            if marqueur.brut not in marqueurs:
                marqueurs.append(marqueur.brut)
    return marqueurs


def _trouver(tableaux: list[Tableau], premiere_colonne: str) -> Tableau | None:
    for tableau in tableaux:
        if tableau.entetes and tableau.entetes[0] == premiere_colonne:
            return tableau
    return None


# ─────────────────────────────────────────────────────────────────────────────
# Tableau A — lois promulguées
# ─────────────────────────────────────────────────────────────────────────────
def _lire_loi_promulguee(ligne: dict[str, str]) -> Texte:
    brut_numero = nettoyer(ligne["N° de loi"])
    correspondance = MOTIF_NUMERO_LOI.match(brut_numero)
    if not correspondance:
        raise ValueError(f"numéro de loi illisible : {brut_numero!r}")

    organique = bool(correspondance.group(1))
    numero = correspondance.group(2)

    cellule_dates = ligne["Promulgation / JO"]
    dates = toutes_les_dates(cellule_dates, annee_defaut=ANNEE_CORPUS)
    if not dates:
        raise ValueError(f"date de promulgation illisible : {cellule_dates!r}")
    date_promulgation = dates[0]
    date_jo = dates[1] if len(dates) > 1 else None

    objet = nettoyer(ligne["Objet"])
    cellule_libertes = ligne["Statut libertés"]
    cellule_gpe = ligne["Pertinence Guadeloupe-OM"]

    regime, note_regime = _lire_regime(cellule_libertes)
    pertinence, note_gpe = _lire_pertinence(cellule_gpe)

    evenements = [
        Evenement(
            date_evenement=date_promulgation,
            type_etape=TypeEtape.PROMULGATION,
            description=f"Promulgation de la loi n° {numero}",
        )
    ]
    if date_jo:
        evenements.append(
            Evenement(
                date_evenement=date_jo,
                type_etape=TypeEtape.PUBLICATION_JO,
                description="Publication au Journal officiel",
            )
        )

    # Une date postérieure à la promulgation, citée dans la colonne Guadeloupe,
    # est une entrée en vigueur différée (« 1er sept. 2026 », « 6 janv. 2027 »).
    entree_vigueur = _entree_en_vigueur_differee(cellule_gpe, date_promulgation)
    if entree_vigueur:
        evenements.append(
            Evenement(
                date_evenement=entree_vigueur,
                type_etape=TypeEtape.ENTREE_VIGUEUR,
                description=f"Entrée en vigueur différée au {entree_vigueur.isoformat()}",
                previsionnel=entree_vigueur > date_promulgation,
            )
        )

    decisions = _lire_decisions_cc(f"{cellule_libertes} {cellule_gpe}")

    return Texte(
        id=f"loi-{numero}",
        numero_officiel=numero,
        type=TypeTexte.LOI_ORGANIQUE if organique else TypeTexte.LOI,
        titre_court=objet,
        statut=Statut.PROMULGUEE,
        statut_date=date_promulgation,
        date_promulgation=date_promulgation,
        date_entree_vigueur=entree_vigueur,
        themes=_deduire_themes(f"{objet} {cellule_libertes} {cellule_gpe}"),
        regime_libertes=regime,
        regime_libertes_note=note_regime,
        guadeloupe_pertinence=pertinence,
        guadeloupe_note=note_gpe,
        confiance=Confiance.HIGH,
        source_seed="lois-france-2026-guadeloupe_sec10.md#tableau-a",
        evenements=evenements,
        decisions_cc=decisions,
    )


def _entree_en_vigueur_differee(cellule: str, promulgation: date) -> date | None:
    """Repère une date d'application postérieure à la promulgation."""
    for candidate in toutes_les_dates(cellule, annee_defaut=ANNEE_CORPUS):
        if candidate > promulgation:
            return candidate
    return None


# ─────────────────────────────────────────────────────────────────────────────
# Tableau B — textes en cours
# ─────────────────────────────────────────────────────────────────────────────
def _lire_texte_en_cours(ligne: dict[str, str]) -> list[Texte]:
    intitule = nettoyer(ligne["Texte"])
    cle = sans_accents(intitule).lower()

    cellule_statut = ligne["Statut au 25 juillet 2026"]
    cellule_etape = nettoyer(ligne["Prochaine étape"])
    cellule_echeance = nettoyer(ligne["Échéance probable"])
    cellule_enjeu = nettoyer(ligne["Enjeu libertés principal"])

    echeance, libelle_echeance = lire_echeance(cellule_echeance)
    # La date de saisine se lit dans la cellule de statut uniquement : la
    # colonne « Échéance probable » ne contient que la décision attendue, et
    # la confondre avec la saisine daterait celle-ci du mois suivant.
    decisions = _lire_decisions_cc(cellule_statut, echeance_par_defaut=echeance)

    for prefixe, eclatements in LIGNES_A_ECLATER.items():
        if cle.startswith(prefixe):
            return [
                _construire_texte_en_cours(
                    slug=e.slug,
                    titre=e.titre,
                    type_texte=e.type,
                    statut=e.statut,
                    cellule_statut=e.fragment_statut,
                    etape=cellule_etape,
                    echeance=echeance,
                    libelle_echeance=libelle_echeance,
                    enjeu=e.note or cellule_enjeu,
                    decisions=[],
                )
                for e in eclatements
            ]

    slug = _slug_tableau_b(cle, intitule)
    statut = _lire_statut(cellule_statut)
    type_texte = _lire_type(intitule)

    return [
        _construire_texte_en_cours(
            slug=slug,
            titre=intitule,
            type_texte=type_texte,
            statut=statut,
            cellule_statut=cellule_statut,
            etape=cellule_etape,
            echeance=echeance,
            libelle_echeance=libelle_echeance,
            enjeu=cellule_enjeu,
            decisions=decisions,
        )
    ]


def _construire_texte_en_cours(
    *,
    slug: str,
    titre: str,
    type_texte: TypeTexte,
    statut: Statut,
    cellule_statut: str,
    etape: str,
    echeance: date | None,
    libelle_echeance: str,
    enjeu: str,
    decisions: list[DecisionCC],
) -> Texte:
    statut_propre = nettoyer(cellule_statut)
    evenements = _evenements_du_statut(cellule_statut, statut)

    label = etape
    if libelle_echeance and libelle_echeance.lower() not in etape.lower():
        label = f"{etape} — {libelle_echeance}" if etape else libelle_echeance

    return Texte(
        id=slug,
        type=type_texte,
        titre_court=titre,
        statut=statut,
        statut_date=_date_du_statut(cellule_statut),
        date_adoption=_date_adoption(cellule_statut, statut),
        prochaine_echeance=echeance,
        prochaine_echeance_label=label or None,
        themes=_deduire_themes(f"{titre} {statut_propre} {enjeu}"),
        regime_libertes=None,  # le tableau B ne cote pas les régimes
        guadeloupe_pertinence=None,  # renseigné en phase d'enrichissement
        points_cles=[enjeu] if enjeu else [],
        confiance=Confiance.HIGH,
        source_seed="lois-france-2026-guadeloupe_sec10.md#tableau-b",
        evenements=evenements,
        decisions_cc=decisions,
    )


def _slug_tableau_b(cle: str, intitule: str) -> str:
    for prefixe, slug in SLUGS_TABLEAU_B.items():
        if cle.startswith(prefixe):
            return slug
    # Repli : slug dérivé de l'intitulé, tronqué pour rester lisible en URL.
    mots = re.sub(r"[^a-z0-9]+", "-", sans_accents(intitule).lower()).strip("-")
    return "-".join(mots.split("-")[:6])


# ─────────────────────────────────────────────────────────────────────────────
# Lecture des cellules
# ─────────────────────────────────────────────────────────────────────────────
def _lire_regime(cellule: str) -> tuple[RegimeLibertes | None, str | None]:
    """Lit la colonne « Statut libertés » : droits+, contrôle+, mixte, neutre."""
    propre = nettoyer(cellule)
    plat = sans_accents(propre).lower()

    if "droits+" in plat and "neutre" not in plat.split(":")[0]:
        regime = RegimeLibertes.DROITS_PLUS
    elif "controle+" in plat:
        regime = RegimeLibertes.CONTROLE_PLUS
    elif plat.startswith("mixte"):
        regime = RegimeLibertes.MIXTE
    elif "droits+" in plat:
        # « Neutre à droits+ » : la source hésite, on retient le sens positif.
        regime = RegimeLibertes.DROITS_PLUS
    elif plat.startswith("neutre"):
        regime = RegimeLibertes.NEUTRE
    else:
        return None, propre or None

    return regime, propre or None


def _lire_pertinence(cellule: str) -> tuple[Pertinence | None, str | None]:
    """Lit « Forte — motif », « Moyenne — motif », « Faible — motif »."""
    propre = nettoyer(cellule)
    plat = sans_accents(propre).lower()

    pertinence: Pertinence | None = None
    if plat.startswith("forte"):
        pertinence = Pertinence.FORTE
    elif plat.startswith("moyenne"):
        pertinence = Pertinence.MOYENNE
    elif plat.startswith("faible"):
        pertinence = Pertinence.FAIBLE

    note = propre
    for separateur in ("—", "–", "-"):
        if separateur in propre:
            note = propre.split(separateur, 1)[1].strip()
            break

    return pertinence, note or None


# Formes du corpus, testées dans cet ordre : la première qui apparaît gagne.
_FORMES_STATUT: tuple[tuple[str, Statut], ...] = (
    ("adoptee, non promulguee", Statut.ADOPTEE_NON_PROMULGUEE),
    ("adopte, non promulgue", Statut.ADOPTEE_NON_PROMULGUEE),
    ("deposee, non examinee", Statut.DEPOSEE_NON_EXAMINEE),
    ("depose, non examine", Statut.DEPOSEE_NON_EXAMINEE),
    ("en navette", Statut.NAVETTE),
    ("adoptee par une chambre", Statut.NAVETTE),
    ("adoptee en 1re lecture", Statut.NAVETTE),
    ("adoptes par le senat", Statut.NAVETTE),
    ("ratification deposee", Statut.DEPOSEE_NON_EXAMINEE),
    ("annonce", Statut.ANNONCEE),
    ("rejete", Statut.REJETEE),
)


def _lire_statut(cellule: str) -> Statut:
    """Traduit la cellule de statut du tableau B en valeur d'énumération."""
    plat = sans_accents(nettoyer(cellule)).lower()

    # Cas particulier documenté : la loi « Philippine » est adoptée ET déjà
    # validée par le Conseil constitutionnel (2026-906 DC du 23 juillet), il ne
    # lui manque que la signature. C'est exactement `validee_cc`.
    if "conforme" in plat and "promulgation pendante" in plat:
        return Statut.VALIDEE_CC

    for forme, statut in _FORMES_STATUT:
        if forme in plat:
            return statut

    if plat.startswith("adoptee") or plat.startswith("adopte"):
        return Statut.ADOPTEE_NON_PROMULGUEE

    raise ValueError(f"statut non reconnu : {nettoyer(cellule)!r}")


def _date_du_statut(cellule: str) -> date | None:
    dates = toutes_les_dates(cellule, annee_defaut=ANNEE_CORPUS)
    return max(dates) if dates else None


def _date_adoption(cellule: str, statut: Statut) -> date | None:
    """Première date citée quand la cellule décrit une adoption."""
    if statut in (Statut.DEPOSEE_NON_EXAMINEE, Statut.ANNONCEE):
        return None
    dates = toutes_les_dates(cellule, annee_defaut=ANNEE_CORPUS)
    return dates[0] if dates else None


def _evenements_du_statut(cellule: str, statut: Statut) -> list[Evenement]:
    """Reconstruit la timeline lisible dans la cellule de statut."""
    propre = nettoyer(cellule)
    plat = sans_accents(propre).lower()
    dates = toutes_les_dates(cellule, annee_defaut=ANNEE_CORPUS)
    evenements: list[Evenement] = []

    if not dates:
        return evenements

    if statut is Statut.ADOPTEE_NON_PROMULGUEE or "adopt" in plat:
        etape = (
            TypeEtape.ADOPTION_DEFINITIVE
            if statut in (Statut.ADOPTEE_NON_PROMULGUEE, Statut.VALIDEE_CC)
            else TypeEtape.ADOPTION_1RE_LECTURE
        )
        evenements.append(
            Evenement(date_evenement=dates[0], type_etape=etape, description=propre)
        )
    elif statut is Statut.DEPOSEE_NON_EXAMINEE:
        evenements.append(
            Evenement(date_evenement=dates[0], type_etape=TypeEtape.DEPOT, description=propre)
        )

    # Une saisine du Conseil constitutionnel est un événement à part entière :
    # le §7 du cahier des charges l'exige explicitement pour la loi « Riposte ».
    # Attention : une cellule peut citer une décision SANS saisine (la loi
    # « Philippine » ne mentionne que « déc. 2026-906 DC du 23 juill. »).
    # Confondre les deux inventerait une saisine à la date de la décision.
    affaires = MOTIF_AFFAIRE.findall(cellule)
    date_saisine = _date_de_saisine(cellule)

    if _mentionne_une_saisine(cellule):
        for affaire in affaires:
            quand = date_saisine or (dates[-1] if dates else None)
            if quand:
                evenements.append(
                    Evenement(
                        date_evenement=quand,
                        type_etape=TypeEtape.SAISINE_CC,
                        description=f"Saisine du Conseil constitutionnel n° {affaire} DC "
                        f"du {_en_toutes_lettres(quand)}",
                    )
                )
        # Certaines lignes mentionnent la saisine sans numéro d'affaire — la loi
        # actualisant la programmation militaire, « Conseil constitutionnel saisi
        # le 6 juill. ». L'événement est enregistré quand même : c'est lui qui
        # fait apparaître le texte dans la facette « devant le Conseil
        # constitutionnel ». Aucune `decision_cc` n'est créée, faute de numéro.
        if not affaires and date_saisine:
            evenements.append(
                Evenement(
                    date_evenement=date_saisine,
                    type_etape=TypeEtape.SAISINE_CC,
                    description="Saisine du Conseil constitutionnel du "
                    f"{_en_toutes_lettres(date_saisine)} (numéro d'affaire non "
                    "précisé par la source)",
                )
            )

    # Décision déjà rendue et datée dans la cellule.
    for affaire in affaires:
        rendue = _date_de_decision(cellule, affaire)
        if rendue:
            evenements.append(
                Evenement(
                    date_evenement=rendue,
                    type_etape=TypeEtape.DECISION_CC,
                    description=f"Décision n° {affaire} DC du {_en_toutes_lettres(rendue)}",
                )
            )

    return evenements


def _mentionne_une_saisine(cellule: str) -> bool:
    plat = sans_accents(nettoyer(cellule)).lower()
    return "conseil constitutionnel saisi" in plat or "saisin" in plat


_MENTION_SAISINE = re.compile(r"saisi\w*", re.IGNORECASE)


def _date_de_saisine(cellule: str) -> date | None:
    """Date rattachée au mot « saisine », jamais au vote d'adoption.

    La recherche est bornée au fragment qui **suit** la mention de la saisine.
    Sans cette borne, « Adoptée (15 juill., 291/241) ; quatre saisines réunies
    sous le n° 2026-910 DC » daterait la saisine du jour du vote — alors que le
    corpus ne la date pas du tout. Mieux vaut aucune date qu'une date fausse.

    Formes couvertes : « saisine du 23 juill. », « saisines des 23-24 juill. »
    (on retient la dernière), « Conseil constitutionnel saisi le 6 juill. ».
    """
    plat = sans_accents(cellule)
    mention = _MENTION_SAISINE.search(plat)
    if not mention:
        return None

    apres = cellule[mention.end() :]
    dates = toutes_les_dates(apres, annee_defaut=ANNEE_CORPUS)
    return dates[-1] if dates else None


_MOIS_LETTRES = (
    "janvier", "février", "mars", "avril", "mai", "juin",
    "juillet", "août", "septembre", "octobre", "novembre", "décembre",
)


def _en_toutes_lettres(d: date) -> str:
    jour = "1er" if d.day == 1 else str(d.day)
    return f"{jour} {_MOIS_LETTRES[d.month - 1]} {d.year}"


def _lire_type(intitule: str) -> TypeTexte:
    plat = sans_accents(intitule).lower()
    if plat.startswith("ppl"):
        return TypeTexte.PPL
    if plat.startswith("pjl"):
        return TypeTexte.PJL
    if plat.startswith(("accord", "accords")):
        return TypeTexte.ACCORD_INTERNATIONAL
    if "organique" in plat:
        return TypeTexte.LOI_ORGANIQUE
    if plat.startswith("loi"):
        return TypeTexte.LOI
    return TypeTexte.PJL


def _lire_decisions_cc(
    fragment: str, *, echeance_par_defaut: date | None = None
) -> list[DecisionCC]:
    """Extrait les affaires « n° 2026-915 DC » citées dans une cellule."""
    decisions: list[DecisionCC] = []
    vues: set[str] = set()

    for numero in MOTIF_AFFAIRE.findall(fragment):
        affaire = f"{numero} DC"
        if affaire in vues:
            continue
        vues.add(affaire)

        connue = RESULTATS_CC_CONNUS.get(affaire)
        if connue:
            resultat, resume = connue
            decisions.append(
                DecisionCC(
                    numero_affaire=affaire,
                    date_decision=_date_de_decision(fragment, affaire),
                    resultat=resultat,
                    resume=resume,
                )
            )
        else:
            decisions.append(
                DecisionCC(
                    numero_affaire=affaire,
                    date_saisine=_date_de_saisine(fragment),
                    resultat=ResultatCC.EN_INSTANCE,
                    date_decision_attendue=echeance_par_defaut,
                )
            )

    return decisions


_DECISION_DU = re.compile(r"dec\.?\s*\d{4}-\d{1,4}\s*DC\s*du\s+([^);,]+)", re.IGNORECASE)


def _date_de_decision(fragment: str, affaire: str) -> date | None:
    plat = sans_accents(fragment)
    if m := _DECISION_DU.search(plat):
        return lire_date(m.group(1), annee_defaut=ANNEE_CORPUS)
    return None


def _compiler_motifs_themes() -> dict[Theme, re.Pattern[str]]:
    """Compile un motif par thème, ancré sur des frontières de mot.

    Sans cette ancre, « eau » se déclenche sur « résEAUx », « RetaillEAU » et
    « drapEAU » — trois faux positifs observés sur le corpus réel. Le suffixe
    `\\w*` reste autorisé pour attraper les flexions (« agricole/agricoles »).
    """
    motifs: dict[Theme, re.Pattern[str]] = {}
    for theme, mots in MOTS_CLES_THEMES.items():
        alternatives = "|".join(re.escape(mot) for mot in mots)
        motifs[theme] = re.compile(rf"(?<!\w)(?:{alternatives})\w*", re.IGNORECASE)
    return motifs


MOTIFS_THEMES = _compiler_motifs_themes()


def _deduire_themes(texte: str) -> list[Theme]:
    """Thèmes déduits par mots-clés, sans jamais dépasser quatre entrées."""
    plat = sans_accents(texte).lower()
    scores: list[tuple[int, Theme]] = []

    for theme, motif in MOTIFS_THEMES.items():
        occurrences = len(set(motif.findall(plat)))
        if occurrences:
            scores.append((occurrences, theme))

    scores.sort(key=lambda paire: (-paire[0], str(paire[1])))
    return [theme for _, theme in scores[:4]]


def extraire_marqueurs_annexe(markdown: str) -> dict[str, list[str]]:
    """Associe chaque ligne de tableau aux marqueurs de citation qu'elle porte.

    Sert au rattachement des sources en phase suivante : la clé est le numéro de
    loi ou l'intitulé, la valeur la liste des marqueurs bruts (« dim07-24 »).
    """
    associations: dict[str, list[str]] = {}

    for tableau in extraire_tableaux(markdown):
        if not tableau.entetes:
            continue
        colonne_cle = tableau.entetes[0]
        for ligne in tableau.lignes:
            cle = nettoyer(ligne[colonne_cle])
            marqueurs = [
                m.brut for cellule in ligne.values() for m in extraire_marqueurs(cellule)
            ]
            if marqueurs:
                associations.setdefault(cle, []).extend(marqueurs)

    return associations
