"""Run de veille : collecte, rapprochement, mise à jour, rapport.

Enchaînement, conforme au §5 du cahier des charges :

1. **Collecte** — chaque collecteur isolé, tolérant à l'échec, avec cache HTTP
   et user-agent honnête. Un collecteur en panne n'arrête pas le run.
2. **Rapprochement** — par numéro officiel puis par similarité de titre, avec
   une zone de signalement où le pipeline s'abstient plutôt que de fusionner.
3. **Classification** — thèmes et pertinence Guadeloupe pour les textes
   nouveaux uniquement ; les entrées issues du corpus gardent leurs valeurs.
   Toute classification automatique est marquée `confiance = 'low'` et
   « à vérifier ».
4. **Rapport de run** — écrit dans `veille_log` et `veille_changements`.
5. **Sortie statique** — `data/textes.json` régénéré pour le mode dégradé.

`--dry-run` effectue la collecte réelle et calcule tous les écarts, sans écrire
une seule ligne en base.
"""

from __future__ import annotations

import argparse
import json
import sqlite3
import time
from dataclasses import dataclass, field
from datetime import date

from pipeline import chemins, db, guadeloupe
from pipeline.collecteurs.base import ResultatCollecte, TexteCollecte
from pipeline.collecteurs.conseil_constitutionnel import CollecteurConseilConstitutionnel
from pipeline.collecteurs.http import ClientHttp
from pipeline.collecteurs.legifrance import CollecteurLegifrance
from pipeline.collecteurs.senat import CollecteurSenat
from pipeline.journal import configurer, logger
from pipeline.modeles import Confiance, DecisionCC, Statut, Texte
from pipeline.parseurs.tableaux_sec10 import _deduire_themes
from pipeline.rapprochement import Issue, rapprocher

log = logger("update")


@dataclass
class Ecart:
    """Une différence constatée entre la base et les sources officielles."""

    texte_id: str | None
    nature: str            # ajout | statut | date | source | decision_cc | signalement
    champ: str | None = None
    ancienne_valeur: str | None = None
    nouvelle_valeur: str | None = None
    description: str = ""
    collecteur: str = ""
    # Renseignée pour les ajouts : c'est elle qui permet de créer le texte au
    # moment d'appliquer, sans relancer une collecte.
    collecte: TexteCollecte | None = None
    # Idem pour les affaires du Conseil constitutionnel. Sans elle, un écart de
    # décision serait journalisé mais jamais écrit — et redétecté à chaque
    # passage, indéfiniment.
    decision: DecisionCC | None = None

    def en_ligne(self) -> str:
        cible = self.texte_id or "(nouveau)"
        if self.ancienne_valeur or self.nouvelle_valeur:
            return (
                f"  [{self.nature:12}] {cible:32} {self.champ or ''} : "
                f"{self.ancienne_valeur or '—'} → {self.nouvelle_valeur or '—'}"
            )
        return f"  [{self.nature:12}] {cible:32} {self.description}"


@dataclass
class RapportRun:
    """Ce qu'un run a vu et fait."""

    mode: str
    ecarts: list[Ecart] = field(default_factory=list)
    collectes: list[ResultatCollecte] = field(default_factory=list)
    alertes: list[str] = field(default_factory=list)

    @property
    def ajouts(self) -> int:
        return sum(1 for e in self.ecarts if e.nature == "ajout")

    @property
    def modifications(self) -> int:
        return sum(1 for e in self.ecarts if e.nature not in ("ajout", "signalement"))

    @property
    def signalements(self) -> int:
        return sum(1 for e in self.ecarts if e.nature == "signalement")

    def en_texte(self) -> str:
        lignes = [
            f"Rapport de veille — mode « {self.mode} »",
            "=" * 44,
            "",
            "Collecteurs",
        ]
        for collecte in self.collectes:
            if collecte.ignore:
                etat = f"ignoré — {collecte.motif_ignore}"
            elif collecte.erreurs:
                etat = f"échec — {' ; '.join(collecte.erreurs)[:200]}"
            else:
                etat = (
                    f"{len(collecte.textes)} texte(s), "
                    f"{len(collecte.decisions_cc)} décision(s)"
                )
            lignes.append(f"  {collecte.collecteur:26} {etat}  ({collecte.duree_s:.1f} s)")

        lignes += [
            "",
            f"Écarts détectés : {len(self.ecarts)}",
            f"  ajouts        : {self.ajouts}",
            f"  modifications : {self.modifications}",
            f"  signalements  : {self.signalements}",
        ]
        if self.ecarts:
            lignes.append("")
            lignes.extend(e.en_ligne() for e in self.ecarts)
        if self.alertes:
            lignes += ["", "Alertes"] + [f"  ! {a}" for a in self.alertes]
        if not self.ecarts:
            lignes += ["", "  La base est conforme aux sources officielles consultées."]
        return "\n".join(lignes)


# ─────────────────────────────────────────────────────────────────────────────
# Collecte
# ─────────────────────────────────────────────────────────────────────────────
def collecter(cx: sqlite3.Connection, client: ClientHttp) -> list[ResultatCollecte]:
    """Lance tous les collecteurs, chacun isolé de ses voisins."""
    numeros = [
        ligne["numero_officiel"]
        for ligne in cx.execute(
            "SELECT numero_officiel FROM textes WHERE numero_officiel IS NOT NULL "
            "ORDER BY numero_officiel"
        )
    ]

    collecteurs = [
        CollecteurLegifrance(client, numeros_a_verifier=numeros),
        CollecteurSenat(client),
        CollecteurConseilConstitutionnel(client),
    ]
    return [collecteur.collecter() for collecteur in collecteurs]


# ─────────────────────────────────────────────────────────────────────────────
# Comparaison
# ─────────────────────────────────────────────────────────────────────────────
def comparer(cx: sqlite3.Connection, collectes: list[ResultatCollecte]) -> RapportRun:
    """Calcule les écarts entre la base et ce que les sources rapportent."""
    rapport = RapportRun(mode="comparaison", collectes=collectes)

    candidats = [
        (ligne["id"], ligne["titre_court"], ligne["numero_officiel"])
        for ligne in cx.execute("SELECT id, titre_court, numero_officiel FROM textes")
    ]
    connus = {
        ligne["id"]: ligne
        for ligne in cx.execute("SELECT * FROM textes")
    }
    affaires_connues = {
        ligne["numero_affaire"]: ligne
        for ligne in cx.execute("SELECT * FROM decisions_cc")
    }

    for collecte in collectes:
        for texte in collecte.textes:
            _comparer_texte(texte, candidats, connus, rapport, collecte.collecteur)
        for decision in collecte.decisions_cc:
            _comparer_decision(
                decision, affaires_connues, rapport, collecte.collecteur, candidats
            )

    return rapport


def _comparer_texte(
    collecte: TexteCollecte,
    candidats: list[tuple[str, str, str | None]],
    connus: dict[str, sqlite3.Row],
    rapport: RapportRun,
    collecteur: str,
) -> None:
    correspondance = rapprocher(
        titre=collecte.titre, numero_officiel=collecte.numero_officiel, candidats=candidats
    )

    if correspondance.issue is Issue.NOUVEAU:
        rapport.ecarts.append(
            Ecart(
                texte_id=None,
                nature="ajout",
                description=f"{collecte.numero_officiel or '—'} · {collecte.titre[:110]}",
                collecteur=collecteur,
                collecte=collecte,
            )
        )
        return

    if correspondance.issue is Issue.A_SIGNALER:
        rapport.ecarts.append(
            Ecart(
                texte_id=correspondance.texte_id,
                nature="signalement",
                description=f"{correspondance.motif} — « {collecte.titre[:80]} »",
                collecteur=collecteur,
            )
        )
        return

    existant = connus.get(correspondance.texte_id or "")
    if existant is None:
        return

    # Changement de statut : c'est l'événement que la veille existe pour voir.
    if collecte.statut and existant["statut"] != str(collecte.statut):
        rapport.ecarts.append(
            Ecart(
                texte_id=existant["id"],
                nature="statut",
                champ="statut",
                ancienne_valeur=existant["statut"],
                nouvelle_valeur=str(collecte.statut),
                collecteur=collecteur,
            )
        )

    for champ, valeur in (
        ("numero_officiel", collecte.numero_officiel),
        ("date_promulgation", collecte.date_promulgation),
        ("date_adoption", collecte.date_adoption),
    ):
        if valeur is None:
            continue
        attendu = valeur.isoformat() if isinstance(valeur, date) else str(valeur)
        if existant[champ] != attendu:
            rapport.ecarts.append(
                Ecart(
                    texte_id=existant["id"],
                    nature="date" if champ.startswith("date") else "autre",
                    champ=champ,
                    ancienne_valeur=existant[champ],
                    nouvelle_valeur=attendu,
                    collecteur=collecteur,
                )
            )


def _comparer_decision(
    decision: DecisionCC,
    connues: dict[str, sqlite3.Row],
    rapport: RapportRun,
    collecteur: str,
    candidats: list[tuple[str, str, str | None]] | None = None,
) -> None:
    existante = connues.get(decision.numero_affaire)

    if existante is None:
        # Le registre du Conseil donne l'intitulé complet de la loi déférée :
        # on tente de la rattacher à un texte suivi plutôt que de laisser
        # l'affaire orpheline.
        rattachement = None
        if candidats and decision.resume:
            correspondance = rapprocher(
                titre=decision.resume, numero_officiel=None, candidats=candidats
            )
            if correspondance.issue is Issue.RAPPROCHE:
                rattachement = correspondance.texte_id

        rapport.ecarts.append(
            Ecart(
                texte_id=rattachement,
                nature="decision_cc",
                description=(
                    f"affaire inconnue en base : {decision.numero_affaire} — "
                    f"{(decision.resume or '')[:90]}"
                    + (f" → rattachée à {rattachement}" if rattachement else "")
                ),
                collecteur=collecteur,
                decision=decision,
            )
        )
        return

    if decision.date_decision and not existante["date_decision"]:
        rapport.ecarts.append(
            Ecart(
                texte_id=existante["texte_id"],
                nature="decision_cc",
                champ=f"{decision.numero_affaire} · date_decision",
                ancienne_valeur=None,
                nouvelle_valeur=decision.date_decision.isoformat(),
                collecteur=collecteur,
                decision=decision,
            )
        )

    if decision.date_saisine and not existante["date_saisine"]:
        rapport.ecarts.append(
            Ecart(
                texte_id=existante["texte_id"],
                nature="decision_cc",
                champ=f"{decision.numero_affaire} · date_saisine",
                ancienne_valeur=None,
                nouvelle_valeur=decision.date_saisine.isoformat(),
                collecteur=collecteur,
                decision=decision,
            )
        )

    if (
        decision.resultat
        and str(decision.resultat) != "en_instance"
        and existante["resultat"] != str(decision.resultat)
    ):
        rapport.ecarts.append(
            Ecart(
                texte_id=existante["texte_id"],
                nature="decision_cc",
                champ=f"{decision.numero_affaire} · resultat",
                ancienne_valeur=existante["resultat"],
                nouvelle_valeur=str(decision.resultat),
                collecteur=collecteur,
                decision=decision,
            )
        )


# ─────────────────────────────────────────────────────────────────────────────
# Application
# ─────────────────────────────────────────────────────────────────────────────
def appliquer(cx: sqlite3.Connection, rapport: RapportRun, run_id: int) -> int:
    """Écrit les écarts en base. Jamais appelé en mode `--dry-run`.

    Retourne le nombre de textes réellement créés. Les textes ajoutés par cette
    voie entrent avec la confiance la plus basse et le drapeau de revue : la
    machine constate qu'un texte existe, elle ne prétend pas l'avoir analysé.
    """
    crees = 0

    with db.transaction(cx):
        for ecart in rapport.ecarts:
            if ecart.nature == "ajout" and ecart.collecte is not None:
                texte = classer_nouveau(ecart.collecte)
                db.enregistrer_texte(cx, texte)
                ecart.texte_id = texte.id
                crees += 1

        for ecart in rapport.ecarts:
            db.enregistrer_changement(
                cx,
                run_id,
                texte_id=ecart.texte_id,
                nature=ecart.nature if ecart.nature in _NATURES_SQL else "autre",
                champ=ecart.champ,
                ancienne_valeur=ecart.ancienne_valeur,
                nouvelle_valeur=ecart.nouvelle_valeur,
                description=ecart.description or None,
            )

            if ecart.nature == "decision_cc" and ecart.decision is not None:
                db.enregistrer_decision_cc(cx, ecart.decision, texte_id=ecart.texte_id)
                continue

            if ecart.texte_id and ecart.champ and ecart.nature in ("statut", "date"):
                cx.execute(
                    f"UPDATE textes SET {ecart.champ} = ?, maj_le = datetime('now'), "  # noqa: S608
                    "derniere_verif = datetime('now') WHERE id = ?",
                    (ecart.nouvelle_valeur, ecart.texte_id),
                )

        cx.execute(
            "UPDATE textes SET derniere_verif = datetime('now') "
            "WHERE numero_officiel IS NOT NULL"
        )

    return crees


_NATURES_SQL = {"ajout", "statut", "date", "source", "autre"}


def classer_nouveau(collecte: TexteCollecte) -> Texte:
    """Construit un texte à partir d'une collecte, en signalant l'automatisme.

    Conformément au §5.3, toute classification automatique porte la confiance
    la plus basse et le drapeau de revue : la machine propose, l'humain valide.
    """
    cotation = guadeloupe.coter(collecte.titre, collecte.resume)

    return Texte(
        id=_identifiant(collecte),
        numero_officiel=collecte.numero_officiel,
        type=collecte.type or "loi",
        titre_court=collecte.titre[:300],
        titre_officiel=collecte.titre,
        statut=collecte.statut or Statut.PROMULGUEE,
        date_promulgation=collecte.date_promulgation,
        date_adoption=collecte.date_adoption,
        themes=_deduire_themes(collecte.titre),
        guadeloupe_pertinence=cotation.pertinence,
        guadeloupe_note=cotation.note,
        resume=collecte.resume,
        confiance=Confiance.LOW,
        source_seed=f"collecteur:{collecte.collecteur}",
        a_verifier=True,
        motif_verification=(
            "Texte ajouté automatiquement par le pipeline : thèmes, impacts et "
            "pertinence Guadeloupe sont déduits et n'ont pas été relus."
        ),
        sources=collecte.sources,
        evenements=collecte.evenements,
    )


def _identifiant(collecte: TexteCollecte) -> str:
    if collecte.numero_officiel:
        return f"loi-{collecte.numero_officiel}"
    from pipeline.rapprochement import normaliser

    mots = normaliser(collecte.titre).split()[:6]
    return "-".join(mots) or "texte-sans-titre"


# ─────────────────────────────────────────────────────────────────────────────
# Sortie statique
# ─────────────────────────────────────────────────────────────────────────────
def exporter_json(cx: sqlite3.Connection) -> int:
    """Régénère `data/textes.json` — dump complet pour le mode dégradé."""
    textes = []
    for ligne in cx.execute("SELECT * FROM v_textes ORDER BY id"):
        entree = dict(ligne)
        for champ in ("themes", "impacts", "points_cles"):
            entree[champ] = json.loads(entree[champ]) if entree[champ] else None
        entree["sources"] = [
            dict(s)
            for s in cx.execute(
                "SELECT url, titre, editeur, date_publication, tier, extrait_verbatim, confiance "
                "FROM sources WHERE texte_id = ? ORDER BY tier, id",
                (ligne["id"],),
            )
        ]
        entree["evenements"] = [
            dict(e)
            for e in cx.execute(
                "SELECT date_evenement, type_etape, description, source_url, previsionnel "
                "FROM evenements WHERE texte_id = ? ORDER BY date_evenement",
                (ligne["id"],),
            )
        ]
        entree["decisions_cc"] = [
            dict(d)
            for d in cx.execute(
                "SELECT numero_affaire, date_saisine, date_decision, resultat, resume "
                "FROM decisions_cc WHERE texte_id = ?",
                (ligne["id"],),
            )
        ]
        textes.append(entree)

    charge = {
        "genere_le": time.strftime("%Y-%m-%dT%H:%M:%S"),
        "date_arrete_corpus": "2026-07-25",
        "textes": textes,
        "echeances": [
            dict(e) for e in cx.execute("SELECT * FROM echeances ORDER BY date_echeance")
        ],
        "insights": [dict(i) for i in cx.execute("SELECT * FROM insights ORDER BY numero")],
    }

    chemins.DUMP_JSON.parent.mkdir(parents=True, exist_ok=True)
    chemins.DUMP_JSON.write_text(
        json.dumps(charge, ensure_ascii=False, indent=2), encoding="utf-8"
    )
    return len(textes)


# ─────────────────────────────────────────────────────────────────────────────
# Ligne de commande
# ─────────────────────────────────────────────────────────────────────────────
def main() -> None:
    analyseur = argparse.ArgumentParser(
        description="Met à jour la base depuis les sources législatives officielles."
    )
    analyseur.add_argument(
        "--dry-run",
        action="store_true",
        help="collecte réelle, aucune écriture en base : affiche les écarts détectés",
    )
    analyseur.add_argument(
        "--hors-ligne",
        action="store_true",
        help="n'utilise que le cache HTTP, sans accès réseau",
    )
    analyseur.add_argument("--verbeux", action="store_true")
    arguments = analyseur.parse_args()

    configurer("DEBUG" if arguments.verbeux else "INFO")
    depart = time.monotonic()

    cx = db.initialiser()
    mode = "dry-run" if arguments.dry_run else "update"

    with ClientHttp(hors_ligne=arguments.hors_ligne) as client:
        collectes = collecter(cx, client)

    rapport = comparer(cx, collectes)
    rapport.mode = mode
    for collecte in collectes:
        if collecte.ignore:
            rapport.alertes.append(f"{collecte.collecteur} ignoré : {collecte.motif_ignore}")
        rapport.alertes.extend(f"{collecte.collecteur} : {e}" for e in collecte.erreurs)

    run_id = db.ouvrir_run(cx, mode)
    if not arguments.dry_run:
        crees = appliquer(cx, rapport, run_id)
        exportes = exporter_json(cx)
        log.info(
            "écarts appliqués",
            textes_crees=crees,
            dump=str(chemins.DUMP_JSON),
            textes_exportes=exportes,
        )

    db.cloturer_run(
        cx,
        run_id,
        duree_s=time.monotonic() - depart,
        ajouts=rapport.ajouts,
        modifications=rapport.modifications,
        echecs=sum(len(c.erreurs) for c in collectes),
        alertes=rapport.alertes,
        rapport=rapport.en_texte(),
    )

    print()
    print(rapport.en_texte())
    print()
    if arguments.dry_run:
        print("  Mode --dry-run : aucune écriture en base.")
        print()


if __name__ == "__main__":
    main()
