#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Reproduire les analyses du budget wallon
========================================

Ce script recalcule, depuis les seules données ouvertes publiées, les chiffres qui
fondent l'analyse. Il écrit un fichier HTML par analyse, plus un index, et compare
chaque résultat à la valeur publiée : si les deux diffèrent, il le dit.

Il n'a besoin de rien d'autre que Python 3.8 ou plus récent. Aucune bibliothèque à
installer : tout vient de la bibliothèque standard.

USAGE
-----
    python3 reproduire.py                 # télécharge les données et calcule
    python3 reproduire.py --local data/   # utilise des fichiers déjà téléchargés
    python3 reproduire.py --sortie ./out  # choisit le dossier de sortie

DONNÉES UTILISÉES (toutes publiques, page « Données ouvertes »)
--------------------------------------------------------------
    depenses_classees_2015_2026.csv   chaque article de dépense, son montant, son
                                      domaine fonctionnel et LA RÈGLE qui l'y a classé
    recettes_2016_2026.csv            chaque ligne de recettes
    deflateur_pib_belge.csv           l'indice qui sert à passer en euros constants
    surcouches_comparabilite.csv      les rares corrections de périmètre appliquées en plus
                                      du classement, chacune avec son motif et sa source
    postes_dossier_2026.csv           pour chaque article de 2026, le poste sous lequel le
                                      dossier le présente (regroupements compris)

CE QUE CE SCRIPT NE REPRODUIT PAS
---------------------------------
Les analyses qui s'appuient sur des sources extérieures au budget régional : comparaisons
avec la Flandre, données Eurostat, comptes d'organismes déposés à la Banque nationale,
projections du CERPE. Elles sont sourcées une par une dans le dossier, mais elles ne se
recalculent pas depuis ces trois fichiers.
"""

import argparse
import csv
import html
import io
import json
import sys
import urllib.request
from pathlib import Path

BASE = "https://budget.akt.be/data/opendata/"
FICHIERS = {
    "depenses": "depenses_classees_2015_2026.csv",
    "recettes": "recettes_2016_2026.csv",
    "deflateur": "deflateur_pib_belge.csv",
    "surcouches": "surcouches_comparabilite.csv",
    "postes": "postes_dossier_2026.csv",
}

# Valeurs telles qu'elles sont publiées dans le dossier, pour comparaison. Le script
# ne s'en sert JAMAIS pour calculer : uniquement pour signaler un écart.
PUBLIE = {
    "depenses_2026": 20230.2,
    "depenses_2016": 12866.0,
    "recettes_2026": 17410.2,
    "recettes_2016": 11866.0,
    "inflation_2016_2026_pct": 32.5,
    "part_dix_postes_2026_pct": 73.0,
    "eco_recherche_recul_reel": -318.0,
    "emploi_formation_recul_reel": -195.0,
    "total_recul_reel": 513.0,
}

# Le dossier publie ses montants arrondis à l'unité de million et ses parts au dixième de
# point. Un recalcul exact peut donc différer de quelques dixièmes sans que rien ne cloche.
# On tolère 0,3 %, soit 1,5 M€ sur un montant de 500 M€ : assez pour absorber un arrondi,
# trop peu pour laisser passer une erreur de méthode. Une tolérance de 1,5 % aurait laissé
# passer 300 M€ sur les dépenses, soit plus que le recul réel que le dossier documente.
TOLERANCE = 0.003

# Si un chiffre du dossier appliquait un retraitement que les fichiers publiés ne portent
# pas, il serait déclaré ici avec son motif, et le script afficherait l'écart au lieu de le
# masquer. La table est vide : tout ce que le dossier affirme se recalcule depuis les
# fichiers publiés. La dernière exception — la part des dix plus grands postes — est tombée
# le 24/09/2026, quand la table article → poste a été publiée à son tour.
NON_REPRODUCTIBLE = {}

# ───────────────────────────── lecture des données ─────────────────────────────

def charger(nom, dossier):
    """Lit un CSV publié, depuis le disque si on l'a, sinon depuis le site."""
    fichier = FICHIERS[nom]
    if dossier:
        chemin = Path(dossier) / fichier
        if not chemin.exists():
            sys.exit(f"introuvable : {chemin}")
        texte = chemin.read_text(encoding="utf-8-sig")
    else:
        url = BASE + fichier
        print(f"  téléchargement de {fichier} …", flush=True)
        try:
            with urllib.request.urlopen(url, timeout=120) as r:
                texte = r.read().decode("utf-8-sig")
        except Exception as e:
            # Plutôt qu'une trace illisible : on dit ce qui s'est passé et quoi faire.
            sys.exit(
                f"\nLe téléchargement de {fichier} a échoué ({e}).\n\n"
                f"Deux causes possibles :\n"
                f"  · vous n'avez pas de connexion, ou elle passe par un proxy ;\n"
                f"  · l'adresse {BASE} n'est pas encore ouverte.\n\n"
                f"Dans les deux cas, téléchargez les fichiers depuis la page « Données\n"
                f"ouvertes » du site, mettez-les dans un dossier, et relancez ainsi :\n\n"
                f"    python3 reproduire.py --local mon_dossier\n")
    return list(csv.DictReader(io.StringIO(texte)))


def nombre(v):
    if v is None or v == "":
        return 0.0
    return float(str(v).replace(",", ".").replace(" ", "").replace(" ", ""))


# ─────────────────────────────── les analyses ───────────────────────────────

def analyse_agregats(dep, rec, defl):
    """Dépenses, recettes et écart, année par année."""
    par_an = {}
    for r in dep:
        a = r["annee"]
        par_an.setdefault(a, {"dep": 0.0, "rec": 0.0})["dep"] += nombre(r["liquidation_kEUR"])
    ecarte = {}
    for r in rec:
        # Le dossier raisonne en recettes HORS EMPRUNTS : le titre III n'est pas une
        # ressource, c'est de la dette. Sans cette exclusion, 2026 afficherait
        # 21 486 M€ au lieu de 17 410 M€.
        #
        # On filtre sur le NUMÉRO du titre, jamais sur son libellé : celui-ci change
        # d'une année à l'autre. En 2022 il vaut « Titre III - PRODUITS D », tronqué à
        # l'apostrophe par l'export, et en 2023 « Titre III - Dette publique ». Un filtre
        # cherchant le mot « emprunt » laissait donc passer 5 331 M€ en 2022 et 4 098 M€
        # en 2023, et affichait la Région en boni ces deux années-là.
        a = r["annee"]
        par_an.setdefault(a, {"dep": 0.0, "rec": 0.0})
        if (r.get("titre") or "").strip().upper().startswith("TITRE III"):
            ecarte[a] = ecarte.get(a, 0.0) + nombre(r["montant_kEUR"])
            continue
        par_an[a]["rec"] += nombre(r["montant_kEUR"])
    lignes = []
    for a in sorted(par_an):
        d, e = par_an[a]["dep"] / 1000, par_an[a]["rec"] / 1000
        ex = ecarte.get(a, 0.0) / 1000
        lignes.append([a, f"{d:,.1f}".replace(",", " "),
                       f"{e:,.1f}".replace(",", " ") if e else "—",
                       f"{e - d:,.1f}".replace(",", " ") if e else "—",
                       f"{ex:,.1f}".replace(",", " ") if ex else "—"])
    controles = [
        ("Dépenses 2026 (M€)", par_an["2026"]["dep"] / 1000, PUBLIE["depenses_2026"]),
        ("Dépenses 2016 (M€)", par_an["2016"]["dep"] / 1000, PUBLIE["depenses_2016"]),
        ("Recettes 2026 (M€)", par_an["2026"]["rec"] / 1000, PUBLIE["recettes_2026"]),
        ("Recettes 2016 (M€)", par_an["2016"]["rec"] / 1000, PUBLIE["recettes_2016"]),
    ]
    return {
        "titre": "Dépenses, recettes et écart, année par année",
        "methode": "Somme des crédits de liquidation de chaque article de dépense, et de "
                   "chaque ligne de recettes, par année. Les produits d'emprunts (titre III) "
                   "sont écartés des recettes : ce n'est pas une ressource, c'est de la dette. "
                   "La dernière colonne montre le montant écarté chaque année, pour qu'un "
                   "filtre défaillant ne puisse pas passer inaperçu.",
        "colonnes": ["Année", "Dépenses (M€)", "Recettes (M€)", "Écart (M€)",
                     "Emprunts écartés (M€)"],
        "lignes": lignes,
        "controles": controles,
    }


def analyse_inflation(defl):
    """Le facteur de passage en euros constants."""
    d = {r["annee"]: nombre(r["deflateur_pib_belge_indice"]) for r in defl}
    facteur = d["2026"] / d["2016"]
    return {
        "titre": "Passage en euros constants",
        "methode": "Le déflateur du produit intérieur brut belge, et non l'indice des prix "
                   "à la consommation. Un montant de 2016 se compare à 2026 en le "
                   "multipliant par le rapport des deux indices.",
        "colonnes": ["Année", "Indice"],
        "lignes": [[a, f"{d[a]:.3f}"] for a in sorted(d)],
        "controles": [("Inflation 2016 → 2026 (%)", (facteur - 1) * 100,
                       PUBLIE["inflation_2016_2026_pct"])],
        "facteur": facteur,
    }


def analyse_dix_postes(dep, postes):
    """Les postes qui concentrent l'essentiel de la dépense en 2026.

    Le dossier ne présente pas ses dix plus grands postes au niveau de la nomenclature
    fonctionnelle : il réunit tout l'AViQ hors allocations familiales en une ligne, joint
    le plan de relance à la facilité européenne, et isole ailleurs des ensembles plus fins
    que le domaine — le Fonds des communes plutôt que l'ensemble des pouvoirs locaux. Ce
    regroupement est publié article par article dans postes_dossier_2026.csv : le calcul
    ci-dessous s'y appuie, il ne le devine pas.
    """
    par_poste = {}
    for r in dep:
        if r["annee"] != "2026":
            continue
        cle = (r["do"], r["programme"], r["article"])
        poste = postes.get(cle)
        if poste is None:          # article absent de la table : compté, jamais ignoré
            poste = "(hors table des postes)"
        par_poste[poste] = par_poste.get(poste, 0.0) + nombre(r["liquidation_kEUR"])
    total = sum(par_poste.values())
    # Le résiduel n'est pas un poste : c'est ce qui reste une fois les postes nommés.
    classables = {k: v for k, v in par_poste.items() if not k.startswith("RÉSIDUEL")}
    ordre = sorted(classables.items(), key=lambda x: -x[1])
    dix = ordre[:10]
    part = sum(v for _, v in dix) / total * 100
    lignes = [[n, f"{v/1000:,.1f}".replace(",", " "), f"{v/total*100:.1f}"]
              for n, v in dix]
    lignes.append(["Total des dix", f"{sum(v for _, v in dix)/1000:,.1f}".replace(",", " "),
                   f"{part:.1f}"])
    return {
        "titre": "Les dix plus grands postes de 2026",
        "methode": "Les articles de 2026 regroupés selon postes_dossier_2026.csv, qui donne "
                   "pour chaque article le poste sous lequel le dossier le présente. La part "
                   "se rapporte au total des dépenses de l'année, amortissements de dette "
                   "exclus. Les articles rangés en « résiduel » comptent dans le total mais "
                   "ne peuvent pas figurer parmi les dix : le résiduel n'est pas un poste.",
        "colonnes": ["Poste", "Montant (M€)", "Part (%)"],
        "lignes": lignes,
        "controles": [("Part des dix premiers (%)", part, PUBLIE["part_dix_postes_2026_pct"])],
    }

def appliquer_surcouches(par_domaine, annee, surcouches):
    """Corrections de périmètre publiées, appliquées APRÈS le classement par article.

    Le budget change parfois de main un même dispositif d'une année à l'autre : la série
    deviendrait incomparable dans le temps si on n'y touchait pas. Chaque correction est
    publiée avec son montant, ses années, son motif et sa source — elles sont trois.
    Celles dont la source et la cible sont le même domaine ne changent rien ici : elles
    ne déplacent qu'à l'intérieur du domaine.
    """
    an = int(annee)
    for s in surcouches:
        if not (int(s["annee_debut"]) <= an <= int(s["annee_fin"])):
            continue
        if (s.get("portee") or "").strip() == "interne":
            continue          # déplace à l'intérieur d'un domaine : sans effet sur ce total
        src, cible = s["domaine_source"], s["domaine_cible"]
        if src not in par_domaine and cible not in par_domaine:
            continue
        m = nombre(s["montant_kEUR"])
        if src in par_domaine:
            par_domaine[src] -= m
            par_domaine[cible] = par_domaine.get(cible, 0.0) + m
    return par_domaine


def analyse_domaines(dep, facteur, surcouches):
    """Ce que chaque domaine a gagné ou perdu, une fois l'inflation neutralisée."""
    d16, d26 = {}, {}
    for r in dep:
        if r["annee"] == "2016":
            d16[r["domaine"]] = d16.get(r["domaine"], 0.0) + nombre(r["liquidation_kEUR"])
        elif r["annee"] == "2026":
            d26[r["domaine"]] = d26.get(r["domaine"], 0.0) + nombre(r["liquidation_kEUR"])
    d16 = appliquer_surcouches(d16, "2016", surcouches)
    d26 = appliquer_surcouches(d26, "2026", surcouches)
    lignes, ecarts = [], {}
    for nom in sorted(set(d16) | set(d26)):
        a, b = d16.get(nom, 0.0), d26.get(nom, 0.0)
        base = a * facteur                      # 2016 revalorisé aux prix de 2026
        ecart = (b - base) / 1000
        ecarts[nom] = ecart
        lignes.append([nom, f"{a/1000:,.1f}".replace(",", " "),
                       f"{b/1000:,.1f}".replace(",", " "),
                       f"{ecart:+,.1f}".replace(",", " ")])
    lignes.sort(key=lambda x: -nombre(x[3].replace("+", "")))
    eco = next((v for k, v in ecarts.items() if "conomie" in k), None)
    emp = next((v for k, v in ecarts.items() if k.startswith("Emploi")), None)
    controles = []
    if eco is not None:
        controles.append(("Économie & recherche, écart réel (M€)", eco,
                          PUBLIE["eco_recherche_recul_reel"]))
    if emp is not None:
        controles.append(("Emploi & formation, écart réel (M€)", emp,
                          PUBLIE["emploi_formation_recul_reel"]))
    if eco is not None and emp is not None:
        controles.append(("Total des deux reculs (M€)", abs(eco + emp),
                          PUBLIE["total_recul_reel"]))
    return {
        "titre": "Ce que chaque domaine a gagné ou perdu depuis 2016, en euros constants",
        "methode": "Pour chaque domaine, la dépense de 2026 moins celle de 2016 revalorisée "
                   "aux prix de 2026. Un écart négatif signifie des moyens réels inférieurs "
                   "à leur niveau de 2016. Les corrections de périmètre publiées dans "
                   "surcouches_comparabilite.csv sont appliquées : sans elles, un dispositif "
                   "qui change de domaine d'une année à l'autre fausserait la comparaison.",
        "colonnes": ["Domaine", "2016 (M€)", "2026 (M€)", "Écart réel (M€)"],
        "lignes": lignes,
        "controles": controles,
    }


def analyse_parts(dep, surcouches):
    """La photographie de 2026 : le poids de chaque domaine."""
    par_dom = {}
    for r in dep:
        if r["annee"] != "2026":
            continue
        par_dom[r["domaine"]] = par_dom.get(r["domaine"], 0.0) + nombre(r["liquidation_kEUR"])
    # Memes corrections de perimetre que l analyse precedente : sans cela, le meme
    # dossier publierait deux montants differents pour le meme domaine.
    par_dom = appliquer_surcouches(par_dom, "2026", surcouches)
    total = sum(par_dom.values())
    lignes = [[n, f"{v/1000:,.1f}".replace(",", " "), f"{v/total*100:.1f}"]
              for n, v in sorted(par_dom.items(), key=lambda x: -x[1])]
    return {
        "titre": "La photographie de 2026 : le poids de chaque domaine",
        "methode": "Les articles de 2026 regroupes par domaine fonctionnel, en part du total, corrections de perimetre comprises — les memes que dans l’analyse precedente, pour que les deux pages disent la meme chose.",
        "colonnes": ["Domaine", "Montant (M€)", "Part (%)"],
        "lignes": lignes,
        # Pas de controle « somme des parts = 100 % » : total etant la somme elle-meme,
        # il vaudrait 100 quelles que soient les donnees, y compris fausses. Un controle
        # qui ne peut pas echouer gonfle le decompte sans rien garantir. On compare
        # plutot le total des domaines au total publie, ce qui attrape une perte de lignes.
        "controles": [("Total par domaine (M€)",
                       sum(par_dom.values()) / 1000, PUBLIE["depenses_2026"])],
    }


def analyse_regles(dep):
    """Comment chaque article a été classé : la règle appliquée, et son poids."""
    par_regle = {}
    for r in dep:
        if r["annee"] != "2026":
            continue
        cle = (r.get("regle") or "(sans règle)").split(":")[0]
        e = par_regle.setdefault(cle, {"n": 0, "m": 0.0})
        e["n"] += 1
        e["m"] += nombre(r["liquidation_kEUR"])
    total = sum(v["m"] for v in par_regle.values())
    lignes = [[k, str(v["n"]), f"{v['m']/1000:,.1f}".replace(",", " "),
               f"{v['m']/total*100:.1f}"]
              for k, v in sorted(par_regle.items(), key=lambda x: -x[1]["m"])]
    return {
        "titre": "Comment les articles ont été classés",
        "methode": "Chaque article publié porte la règle qui l'a rangé dans son domaine. "
                   "Ce tableau montre le poids de chaque type de règle : il permet de voir "
                   "quelle part du budget repose sur un classement automatique et quelle "
                   "part sur une décision explicite.",
        "colonnes": ["Type de règle", "Articles", "Montant 2026 (M€)", "Part (%)"],
        "lignes": lignes,
        "controles": [],
    }


# ───────────────────────────────── sortie HTML ─────────────────────────────────

CSS = """
:root{--violet:#710097;--lavande:#ECDCF0;--encre:#1a1a1a;--gris:#6b6760;--filet:#e7e1d6;--fond:#fff}
*{box-sizing:border-box}
body{margin:0;padding:0 16px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;
color:var(--encre);background:var(--fond);line-height:1.6}
.wrap{max-width:960px;margin:0 auto;padding:2rem 0 4rem}
a{color:var(--violet)}
h1{font-size:1.7rem;margin:0 0 .3rem}
h2{font-size:1.05rem;margin:2rem 0 .4rem}
.sub{color:var(--gris);margin:0 0 1.6rem}
.methode{background:var(--lavande);border-left:3px solid var(--violet);padding:.8rem 1rem;
margin:0 0 1.2rem;font-size:.92rem}
table{border-collapse:collapse;width:100%;font-size:.9rem;margin:0 0 1.5rem}
th,td{text-align:left;padding:.42rem .6rem;border-bottom:1px solid var(--filet)}
th{color:var(--violet);font-weight:700;white-space:nowrap}
td:not(:first-child),th:not(:first-child){text-align:right;font-variant-numeric:tabular-nums}
.ok{color:#1d7a3e;font-weight:700}
.exp{color:#8a6d00;font-weight:700}
.motif{color:var(--gris);font-size:.85rem;text-align:left!important;padding-bottom:.8rem}
.ko{color:#b3261e;font-weight:700}
.ctrl td:first-child{font-weight:600}
footer{margin-top:2.5rem;padding-top:1rem;border-top:1px solid var(--filet);color:var(--gris);font-size:.8rem}
ul.idx{list-style:none;padding:0}
ul.idx li{padding:.5rem 0;border-bottom:1px solid var(--filet)}
ul.idx a{font-weight:700;text-decoration:none}
@media(prefers-color-scheme:dark){
:root{--encre:#ececec;--gris:#a8a49c;--filet:#3a3630;--lavande:#2a1533;--fond:#161616}
a,ul.idx a{color:#c98ae0}}
"""


def bloc_controles(controles):
    if not controles:
        return ""
    def nb(x):                       # 1 234,5 avec une espace insecable, comme le dossier
        return f"{x:,.1f}".replace(",", "\u00a0").replace(".", ",")
    l = []
    for nom, calcule, publie in controles:
        ecart = abs(calcule - publie) / abs(publie) if publie else 0
        ok = ecart <= TOLERANCE
        motif = NON_REPRODUCTIBLE.get(nom)
        if ok:
            classe, verdict = "ok", "concorde"
        elif motif:
            classe, verdict = "exp", "\u00e9cart expliqu\u00e9"
        else:
            classe, verdict = "ko", "\u00c9CART"
        l.append(f"<tr class='ctrl'><td>{html.escape(nom)}</td>"
                 f"<td>{nb(calcule)}</td><td>{nb(publie)}</td>"
                 f"<td class='{classe}'>{verdict}</td></tr>")
        if not ok and motif:
            l.append(f"<tr><td colspan='4' class='motif'>{html.escape(motif)}</td></tr>")
    return ("<h2>Contr\u00f4le : le recalcul contre la valeur publi\u00e9e</h2>"
            "<table><tr><th>Grandeur</th><th>Recalcul\u00e9</th><th>Publi\u00e9</th>"
            "<th>Verdict</th></tr>" + "".join(l) + "</table>")


def page(titre, corps):
    return (f"<!DOCTYPE html><html lang='fr'><head><meta charset='utf-8'>"
            f"<meta name='viewport' content='width=device-width,initial-scale=1'>"
            f"<title>{html.escape(titre)}</title><style>{CSS}</style></head><body>"
            f"<div class='wrap'>{corps}"
            f"<footer>Recalculé depuis les données ouvertes du budget wallon. "
            f"Une erreur&nbsp;? feedback@akt.be</footer></div></body></html>")


def ecrire_analyse(res, chemin, numero):
    t = ["<a href='index.html'>&larr; Toutes les analyses</a>",
         f"<h1>{numero}. {html.escape(res['titre'])}</h1>",
         f"<div class='methode'><strong>Méthode.</strong> {html.escape(res['methode'])}</div>",
         bloc_controles(res["controles"]),
         "<h2>Résultat</h2><table><tr>"
         + "".join(f"<th>{html.escape(c)}</th>" for c in res["colonnes"]) + "</tr>"]
    for ligne in res["lignes"]:
        t.append("<tr>" + "".join(f"<td>{html.escape(str(c))}</td>" for c in ligne) + "</tr>")
    t.append("</table>")
    chemin.write_text(page(res["titre"], "".join(t)), encoding="utf-8")


def ecrire_index(resultats, dossier):
    ok = sum(1 for r in resultats for n, c, p in r["controles"]
             if abs(c - p) / abs(p) <= TOLERANCE)
    expliques = sum(1 for r in resultats for n, c, p in r["controles"]
                    if abs(c - p) / abs(p) > TOLERANCE and n in NON_REPRODUCTIBLE)
    total = sum(len(r["controles"]) for r in resultats)
    li = "".join(
        f"<li><a href='analyse_{i+1:02d}.html'>{i+1}. {html.escape(r['titre'])}</a></li>"
        for i, r in enumerate(resultats))
    corps = (f"<h1>Reproduire les analyses</h1>"
             f"<p class='sub'>Chaque analyse ci-dessous a été recalculée à l'instant, depuis "
             f"les seules données ouvertes publiées, par le script <code>reproduire.py</code>. "
             f"Aucun chiffre du dossier n'a servi au calcul&nbsp;: ils ne servent qu'à la "
             f"comparaison.</p>"
             f"<div class='methode'><strong>{ok} contrôles sur {total}</strong> retrouvent la "
             f"valeur publiée à moins de {str(round(TOLERANCE*100, 2)).replace(".", ",")}&nbsp;% près"
             + (f", et <strong>{expliques}</strong> s'en écartent pour un motif indiqué à "
                f"côté du chiffre." if expliques else ".") + "</div>"
             f"<ul class='idx'>{li}</ul>")
    (dossier / "index.html").write_text(page("Reproduire les analyses", corps), encoding="utf-8")
    return ok, expliques, total


# ─────────────────────────────────── main ───────────────────────────────────

def main():
    ap = argparse.ArgumentParser(description="Recalcule les analyses du budget wallon.")
    ap.add_argument("--local", metavar="DOSSIER",
                    help="dossier contenant les CSV déjà téléchargés")
    ap.add_argument("--sortie", metavar="DOSSIER", default="analyses",
                    help="où écrire les pages (défaut : ./analyses)")
    args = ap.parse_args()

    print("Lecture des données ouvertes")
    dep = charger("depenses", args.local)
    rec = charger("recettes", args.local)
    defl = charger("deflateur", args.local)
    surc = charger("surcouches", args.local)
    postes = {(r["do"], r["programme"], r["article"]): r["poste_dossier"]
              for r in charger("postes", args.local)}
    print(f"  {len(dep)} articles de dépense, {len(rec)} lignes de recettes, "
          f"{len(postes)} articles cartographiés\n")

    infl = analyse_inflation(defl)
    resultats = [
        analyse_agregats(dep, rec, defl),
        infl,
        analyse_dix_postes(dep, postes),
        analyse_domaines(dep, infl["facteur"], surc),
        analyse_parts(dep, surc),
        analyse_regles(dep),
    ]

    sortie = Path(args.sortie)
    sortie.mkdir(parents=True, exist_ok=True)
    print("Calcul et écriture")
    for i, r in enumerate(resultats):
        ecrire_analyse(r, sortie / f"analyse_{i+1:02d}.html", i + 1)
        for nom, calcule, publie in r["controles"]:
            ecart = abs(calcule - publie) / abs(publie) if publie else 0
            marque = ("ok   " if ecart <= TOLERANCE
                      else ("expl." if nom in NON_REPRODUCTIBLE else "ÉCART"))
            print(f"  [{marque}] {nom} : {calcule:,.1f} (publié {publie:,.1f})"
                  .replace(",", " "))
    ok, expliques, total = ecrire_index(resultats, sortie)
    restants = total - ok - expliques
    print(f"\n{ok}/{total} contrôles concordent"
          + ((f", {expliques} s'écarte" + ("nt" if expliques > 1 else "")
              + " pour un motif documenté") if expliques else "")
          + ((f", {restants} s'écarte" + ("nt" if restants > 1 else "")
              + " SANS motif connu") if restants else "") + ".")
    if restants:
        # Un écart sans motif n'est pas un détail : soit le dossier se trompe, soit vos
        # données ne sont pas celles qu'il a utilisées. Le code de sortie vaut 1.
        print("  Si vous n'avez rien modifié, écrivez à feedback@akt.be : "
              "les corrections sont publiées.")
    print(f"→ ouvrir {sortie / 'index.html'}")
    return 0 if ok + expliques == total else 1


if __name__ == "__main__":
    raise SystemExit(main())
