#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
build.py — construit le relevé de conformité publié par la charte (chantier 2 « Conformité »).

Entrées (lecture seule) :
  --raw     dossier des JSON bruts de l'audit AEO (un <domaine>.json + <domaine>.home.html par domaine)
  --tables  dossier aeo-2026-10-21 contenant fondations/TABLEAU-DOMAINES.md, fondations/PLAN-FONDATIONS.md,
            citations/TABLEAU.md, agentique/TABLEAU-TACHES.md
Sorties :
  <site>/assets/conformite/releve-<date>.json      le relevé structuré (domaines, requêtes, tâches, chantiers, formule)
  <site>/conformite.html                           repli inline injecté entre <!-- releve:inline --> et <!-- /releve:inline -->
  <site>/assets/conformite/build.py                copie de ce script (la formule est publiée avec les données)

Règle : rien d'inventé. Chaque valeur vient d'un fichier brut (JSON ou HTML relevé) ou d'un tableau du relevé ;
sinon le verdict est « non relevé ». Les seuils sont déclarés dans FORMULE et publiés dans le JSON.

Usage :
  python -X utf8 build.py --raw N:\\dev\\Koen\\aeo-2026-10-21\\fondations\\raw --tables N:\\dev\\Koen\\aeo-2026-10-21 ^
                          --site N:\\dev\\Koen\\charte-tero-site\\site-2026-10-21 --date 2026-10-21
Dépendances : Python 3 seul.
"""

from __future__ import annotations

import argparse
import datetime as dt
import json
import re
import shutil
import sys
import unicodedata
from pathlib import Path

if hasattr(sys.stdout, "reconfigure"):
    sys.stdout.reconfigure(encoding="utf-8", errors="replace")

# ---------------------------------------------------------------------------
# Paramètres publiés (ils sont recopiés dans le JSON sous « formule »)
# ---------------------------------------------------------------------------
FORMULE = {
    "score_domaine": "score = arrondi(100 × critères réussis ÷ critères mesurés) ; un critère « non relevé » sort du dénominateur ; 13 critères, poids égaux.",
    "score_groupe": "score d'un pilier = arrondi(100 × somme des réussis ÷ somme des mesurés) sur les domaines du pilier (pas une moyenne de scores).",
    "seuils": {
        "fraicheur_jours": 90,
        "rendu_sans_js_mots_min": 100,
        "faits_chiffres_min": 2,
    },
    "verdicts": ["réussi", "échoué", "non relevé"],
}

METIER_TYPES = [
    "LocalBusiness",
    "Hotel",
    "Restaurant",
    "FoodEstablishment",
    "SportsActivityLocation",
    "SportsClub",
    "ExerciseGym",
    "Event",
    "SportsEvent",
    "Offer",
    "FAQPage",
    "LodgingBusiness",
    "EventVenue",
    "Place",
    "Product",
]
DEFAULT_WP_DESCRIPTIONS = {
    "un site utilisant wordpress",
    "a wordpress website",
    "just another wordpress site",
}
BOTS_CLES = ["GPTBot", "ClaudeBot", "PerplexityBot"]

MOIS = {
    "janvier": 1,
    "january": 1,
    "januari": 1,
    "jan": 1,
    "fevrier": 2,
    "février": 2,
    "february": 2,
    "februari": 2,
    "feb": 2,
    "mars": 3,
    "march": 3,
    "maart": 3,
    "mar": 3,
    "avril": 4,
    "april": 4,
    "apr": 4,
    "mai": 5,
    "may": 5,
    "mei": 5,
    "juin": 6,
    "june": 6,
    "juni": 6,
    "jun": 6,
    "juillet": 7,
    "july": 7,
    "juli": 7,
    "jul": 7,
    "aout": 8,
    "août": 8,
    "august": 8,
    "augustus": 8,
    "aug": 8,
    "septembre": 9,
    "september": 9,
    "sept": 9,
    "sep": 9,
    "octobre": 10,
    "october": 10,
    "oktober": 10,
    "oct": 10,
    "okt": 10,
    "novembre": 11,
    "november": 11,
    "nov": 11,
    "decembre": 12,
    "décembre": 12,
    "december": 12,
    "dec": 12,
}

# Pilier : la valeur du tableau est conservée (pilier_releve) ; le regroupement suit la fiche marque du site
# (marques/garisart.html = Tero Leisure, marques/river-woods.html = Tero Hospitality) quand le tableau hésite.
PILIER_GROUPES = {
    "groupe": "Tero Group",
    "hospitality": "Tero Hospitality",
    "leisure": "Tero Leisure",
    "sport-events": "Tero Sport Events",
    "hors-perimetre": "Hors périmètre",
}
PILIER_ORDRE = ["groupe", "hospitality", "leisure", "sport-events", "hors-perimetre"]
PILIER_OVERRIDE = {"garisart.be": "leisure", "riverwoodsbeachclub.be": "hospitality"}

# Critères : id, libellé, chantier(s) du plan, règle de la charte citée
CRITERES = [
    {
        "id": "robots_ia",
        "label": "Robots IA autorisés",
        "chantiers": [1, 2],
        "regle": "13-seo-et-geo-regle",
        "definition": "robots.txt valide sans interdiction des bots IA (GPTBot, ClaudeBot, PerplexityBot) et accueil servi en 200 à ces User-Agents. Un 403 compte comme échec, avec la réserve Cloudflare (IP non vérifiée).",
    },
    {
        "id": "llms_txt",
        "label": "llms.txt présent",
        "chantiers": [4],
        "regle": "13-seo-et-geo-regle",
        "definition": "/llms.txt répond 200 avec un contenu texte non vide.",
    },
    {
        "id": "sitemap_valide",
        "label": "Sitemap valide",
        "chantiers": [2, 10],
        "regle": "13-seo-et-geo-regle",
        "definition": "un sitemap lisible avec au moins une URL, et chaque sitemap déclaré dans robots.txt répond 200.",
    },
    {
        "id": "jsonld_metier",
        "label": "JSON-LD métier",
        "chantiers": [5],
        "regle": "13-seo-et-geo-regle",
        "definition": "l'accueil porte au moins un type schema.org métier (LocalBusiness, Hotel, Restaurant, EventVenue, SportsEvent, FAQPage…), pas seulement Organization/WebPage/Article.",
    },
    {
        "id": "organization",
        "label": "Organization correcte",
        "chantiers": [5],
        "regle": "13-seo-et-geo-regle",
        "definition": "un nœud Organization existe, son name correspond à la marque ou au domaine, sa description n'est pas le texte par défaut de WordPress.",
    },
    {
        "id": "title_h1",
        "label": "Title et H1",
        "chantiers": [6],
        "regle": "6-structure-et-composition",
        "definition": "title non vide et exactement un H1 non vide.",
    },
    {
        "id": "hreflang",
        "label": "hreflang",
        "chantiers": [9],
        "regle": "14-langues-fr-nl-en-regle",
        "definition": "au moins une balise hreflang sur l'accueil.",
    },
    {
        "id": "reponse_directe",
        "label": "Réponse directe",
        "chantiers": [6, 8],
        "regle": "13-seo-et-geo-regle",
        "definition": "lecture humaine du relevé (qui / quoi / où / combien sous le H1) : « Oui » réussit ; « Non » et « Partiel » échouent ; « n/a » ou « à vérifier » = non relevé.",
    },
    {
        "id": "faits_chiffres",
        "label": "Faits chiffrés",
        "chantiers": [7],
        "regle": "12-contenu-et-sources-regle",
        "definition": "au moins 2 des 5 faits sur l'accueil : adresse (lecture du tableau), téléphone, capacités, horaires, prix (détections du JSON brut).",
    },
    {
        "id": "fraicheur",
        "label": "Fraîcheur",
        "chantiers": [11],
        "regle": "12-contenu-et-sources-regle",
        "definition": "lastmod du sitemap à moins de 90 jours du relevé, aucune date visible passée sans date future, et aucune année de copyright antérieure à l'année du relevé.",
    },
    {
        "id": "erreurs",
        "label": "Sans erreur",
        "chantiers": [10],
        "regle": "16-qa-verification-et-deploiement-regle",
        "definition": "accueil en 200, TLS valide, pas de ressource http:// dans une page https, sitemap déclaré joignable, aucun lien interne testé en 404.",
    },
    {
        "id": "rendu_sans_js",
        "label": "Rendu sans JavaScript",
        "chantiers": [3],
        "regle": "11-performance-regle",
        "definition": "au moins 100 mots de texte dans le HTML livré sans exécuter JavaScript.",
    },
    {
        "id": "action_declaree",
        "label": "Action déclarée",
        "chantiers": [12],
        "regle": "13-seo-et-geo-regle",
        "definition": "une action lisible par un agent : potentialAction Reserve/Register/Buy/Order dans le JSON-LD, data-mcp-*, agent-permissions.json ou ai-plugin.json.",
    },
]


# ---------------------------------------------------------------------------
# Utilitaires
# ---------------------------------------------------------------------------
def read(p: Path) -> str:
    return p.read_text(encoding="utf-8", errors="replace")


def strip_accents(s: str) -> str:
    return "".join(
        c for c in unicodedata.normalize("NFD", s) if unicodedata.category(c) != "Mn"
    )


def md_rows(text: str) -> list[list[str]]:
    """Lignes de tableau Markdown ; respecte les « \\| » échappés. Ignore les lignes de séparation."""
    rows = []
    for line in text.splitlines():
        if not line.startswith("|"):
            continue
        cells = [c.strip() for c in re.split(r"(?<!\\)\|", line)[1:-1]]
        if cells and all(re.fullmatch(r":?-{3,}:?", c) for c in cells):
            continue
        rows.append([c.replace("\\|", "|") for c in cells])
    return rows


def md_table(text: str, first_header_cell: str) -> list[list[str]]:
    """Retourne les lignes (hors en-tête) du premier tableau dont la première cellule d'en-tête vaut first_header_cell."""
    rows = md_rows(text)
    out, inside = [], False
    for r in rows:
        if not inside:
            if r and r[0].strip("* ") == first_header_cell:
                inside = True
                width = len(r)
            continue
        if len(r) != width:
            # un autre tableau commence : on s'arrête à la première rupture après avoir lu des lignes
            if out:
                break
            continue
        if r[0].strip("* ") == first_header_cell:
            break
        out.append(r)
    return out


def clean_md(s: str) -> str:
    s = re.sub(r"\*\*(.+?)\*\*", r"\1", s)
    s = re.sub(r"`([^`]*)`", r"\1", s)
    s = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", s)
    return s.strip()


def parse_date_text(s: str) -> dt.date | None:
    m = re.search(r"(\d{1,2})(?:er|st|nd|rd|th)?\s+([A-Za-zéû\.]+)\s+(\d{4})", s)
    if not m:
        return None
    mois = MOIS.get(strip_accents(m.group(2).lower().rstrip(".")))
    if not mois:
        return None
    try:
        return dt.date(int(m.group(3)), mois, int(m.group(1)))
    except ValueError:
        return None


def jsonld_nodes(html: str) -> list[dict]:
    nodes = []
    for block in re.findall(
        r"<script[^>]*type=[\"']application/ld\+json[\"'][^>]*>(.*?)</script>",
        html,
        re.S | re.I,
    ):
        try:
            d = json.loads(block.strip())
        except Exception:
            continue
        items = d.get("@graph", [d]) if isinstance(d, dict) else d
        for n in items if isinstance(items, list) else []:
            if isinstance(n, dict):
                nodes.append(n)
    return nodes


def ko(n: int) -> str:
    return f"{round(n / 1024)} Ko"


# ---------------------------------------------------------------------------
# Lecture des tableaux du relevé
# ---------------------------------------------------------------------------
def load_tableau_domaines(p: Path) -> dict[str, dict]:
    rows = md_table(read(p), "Domaine")
    out = {}
    for r in rows:
        if len(r) < 23:
            continue
        out[r[0]] = {
            "marque": clean_md(r[1]),
            "pilier_releve": clean_md(r[2]),
            "plateforme": clean_md(r[3]),
            "page_cle": clean_md(r[10]),
            "reponse_directe": clean_md(r[15]),
            "faits_chiffres": clean_md(r[16]),
            "fraicheur": clean_md(r[17]),
            "erreurs": clean_md(r[18]),
            "action_sans_humain": clean_md(r[19]),
            "test_ua_ia": clean_md(r[20]),
        }
    return out


def load_notes(p: Path) -> list[str]:
    text = read(p)
    m = re.search(r"## Notes de releve.*?\n(.*)", text, re.S)
    if not m:
        return []
    return [
        clean_md(re.sub(r"^\d+\.\s*", "", l))
        for l in m.group(1).splitlines()
        if re.match(r"^\d+\.\s", l)
    ]


def load_citations(p: Path) -> dict:
    text = read(p)
    rows = md_table(text, "#")
    requetes: dict[str, dict] = {}
    ordre = []
    for r in rows:
        if len(r) < 7:
            continue
        num, requete, moteur, domaines, present, concurrent, pourquoi = (
            clean_md(c) for c in r[:7]
        )
        mnum = re.match(r"(\d+)\s*([A-Z]{2})?", num)
        if not mnum:
            continue
        # « 6 NL » et « 10 EN » sont des variantes de langue des requêtes 6 et 10 : 23 requêtes, 25 passes web
        key, variante = mnum.group(1), mnum.group(2)
        if key not in requetes:
            requetes[key] = {"num": int(key), "requete": requete, "resultats": []}
            ordre.append(key)
        p_low = present.lower()
        statut = (
            "domaine"
            if "domaine" in p_low
            else ("marque" if "marque" in p_low else "absent")
        )
        moteur_lib = (
            "Recherche web (US)" if moteur.lower().startswith("web") else moteur
        )
        if variante:
            moteur_lib += f" — variante {variante} : « {requete} »"
        requetes[key]["resultats"].append(
            {
                "moteur": moteur_lib,
                "variante": variante,
                "domaines_cites": domaines,
                "tero_present": present,
                "statut": statut,
                "concurrent": concurrent,
                "pourquoi": pourquoi,
            }
        )
    # synthèse chiffrée
    synth = {}
    m = re.search(r"Tero nommé explicitement[^|]*\|[^|]*\|\s*(\d+)/(\d+)", text)
    if m:
        synth["claude_nomme_tero"] = {
            "valeur": int(m.group(1)),
            "total": int(m.group(2)),
        }
    m = re.search(r"(\d+) domaine Tero dans un top 10 \((\d+)/(\d+)\)", text)
    if m:
        synth["web_domaine_top10"] = {
            "valeur": int(m.group(2)),
            "total": int(m.group(3)),
        }
    m = re.search(r"marque via tiers\s*:\s*(\d+)/(\d+)", text)
    if m:
        synth["web_marque_via_tiers"] = {
            "valeur": int(m.group(1)),
            "total": int(m.group(2)),
        }
    m = re.search(
        r"Marque Tero citée sans attribution au groupe[^|]*\|[^|]*\|\s*(\d+)/(\d+)",
        text,
    )
    if m:
        synth["claude_marque_sans_attribution"] = {
            "valeur": int(m.group(1)),
            "total": int(m.group(2)),
        }
    # limites : moteurs non interrogés
    moteurs = []
    for r in md_table(text, "Moteur / assistant"):
        if len(r) >= 4:
            moteurs.append(
                {
                    "moteur": clean_md(r[0]),
                    "interroge": clean_md(r[1]),
                    "outil": clean_md(r[2]),
                    "limite": clean_md(r[3]),
                }
            )
    return {
        "requetes": [requetes[k] for k in ordre],
        "synthese": synth,
        "moteurs": moteurs,
    }


def load_taches(p: Path) -> dict:
    text = read(p)
    rows = md_table(text, "#")
    taches = []
    for r in rows:
        if len(r) < 8 or not r[0].strip().isdigit():
            continue
        fais = clean_md(r[7])
        m = re.match(r"(\d)", fais)
        taches.append(
            {
                "num": int(r[0]),
                "tache": clean_md(r[1]),
                "url": clean_md(r[2]),
                "clics": clean_md(r[3]),
                "mecanisme": clean_md(r[4]),
                "agent_peut": clean_md(r[5]),
                "blocages": clean_md(r[6]),
                "faisabilite": int(m.group(1)) if m else None,
                "faisabilite_note": fais,
            }
        )
    echelle = []
    for l in text.splitlines():
        m = re.match(r"- \*\*(\d)\*\*\s*:\s*(.+)", l)
        if m:
            echelle.append({"niveau": int(m.group(1)), "sens": clean_md(m.group(2))})
    signaux = []
    for r in md_table(text, "Signal"):
        if len(r) >= 2:
            signaux.append({"signal": clean_md(r[0]), "constat": clean_md(r[1])})
    return {"taches": taches, "echelle": echelle, "signaux": signaux}


def load_plan(p: Path) -> list[dict]:
    text = read(p)
    out = []
    for r in md_table(text, "#"):
        if len(r) >= 5 and r[0].strip().isdigit():
            out.append(
                {
                    "num": int(r[0]),
                    "chantier": clean_md(r[1]),
                    "impact": clean_md(r[2]),
                    "effort": clean_md(r[3]),
                    "livrable": clean_md(r[4]),
                }
            )
    return out


# ---------------------------------------------------------------------------
# Évaluation d'un domaine
# ---------------------------------------------------------------------------
def pilier_groupe(domaine: str, pilier_releve: str) -> str:
    if domaine in PILIER_OVERRIDE:
        return PILIER_OVERRIDE[domaine]
    p = strip_accents(pilier_releve.lower())
    if p.startswith("groupe"):
        return "groupe"
    if "sport" in p:
        return "sport-events"
    if "leisure" in p and "hospitality" not in p:
        return "leisure"
    if "hospitality" in p:
        return "hospitality"
    return "hors-perimetre"


def evaluer(
    domaine: str, raw: dict, html: str, table: dict, releve_date: dt.date
) -> dict:
    acc = raw.get("accueil", {})
    home = raw.get("home", {})
    robots = raw.get("robots", {})
    disc = raw.get("discovery", {})
    sm = raw.get("sitemap", {})
    ua = raw.get("ua_tests", {})
    src_json = f"raw/{domaine}.json"
    src_html = f"raw/{domaine}.home.html"
    src_tab = "fondations/TABLEAU-DOMAINES.md"
    crit = {}

    def put(cid, verdict, valeur, source, detail=None):
        crit[cid] = {"verdict": verdict, "valeur": valeur, "source": source}
        if detail:
            crit[cid]["detail"] = detail

    # 1. robots IA
    ia = robots.get("ia", {}) or {}
    interdits = [b for b, v in ia.items() if v == "interdit"]
    wildcard = robots.get("wildcard", "n/a")
    ua_codes = {b: (ua.get(b) or {}).get("code") for b in BOTS_CLES}
    ua_403 = [b for b, c in ua_codes.items() if c and c != 200]
    if not robots.get("valide"):
        base = "robots.txt absent ou illisible (aucune interdiction)"
    else:
        base = f"wildcard : {wildcard} ; " + " ".join(
            f"{b} : {ia.get(b, 'non cité')}" for b in BOTS_CLES
        )
    ua_txt = " ; UA IA simulés → " + ", ".join(
        f"{b} {c}" for b, c in ua_codes.items() if c is not None
    )
    bloque = (
        bool(interdits)
        or str(wildcard).lower().startswith("disallow: /")
        or bool(ua_403)
    )
    put(
        "robots_ia",
        "échoué" if bloque else "réussi",
        base + ua_txt,
        f"{src_json} › robots, ua_tests",
        ("interdits : " + ", ".join(interdits) if interdits else None)
        or (
            f"403 pour {', '.join(ua_403)} — challenge Cloudflare : l'IP n'étant pas celle du bot, ce n'est pas une preuve définitive, mais WebFetch (fetcher Anthropic) a aussi reçu 403 sur tero.be"
            if ua_403 and any((ua.get(b) or {}).get("cf_challenge") for b in ua_403)
            else None
        ),
    )

    # 2. llms.txt
    l = disc.get("llms.txt", {})
    put(
        "llms_txt",
        "réussi" if l.get("present") else "échoué",
        f"présent ({ko(l.get('taille', 0))})"
        if l.get("present")
        else f"absent ({l.get('code', '?')})",
        f"{src_json} › discovery",
    )

    # 3. sitemap
    tests = sm.get("tests", {})
    declares = robots.get("sitemaps_declares", []) or []
    declares_ko = [
        d
        for d in declares
        if (tests.get(d) or {}).get("code") not in (200, None)
        or (d in tests and tests[d].get("code") != 200)
    ]
    urls = sm.get("urls", 0) or 0
    ok_sm = urls > 0 and not declares_ko
    val = (
        f"{urls} URL"
        + (
            f", lastmod max {sm.get('lastmod_max')[:10]}"
            if sm.get("lastmod_max")
            else ", lastmod absent"
        )
        if urls
        else "absent ou illisible"
    )
    if declares_ko:
        val += " ; déclaré dans robots.txt → " + ", ".join(
            f"{d} ({(tests.get(d) or {}).get('code')})" for d in declares_ko
        )
    put(
        "sitemap_valide",
        "réussi" if ok_sm else "échoué",
        val,
        f"{src_json} › sitemap, robots.sitemaps_declares",
    )

    # 4. JSON-LD métier
    types = acc.get("jsonld_types", []) or []
    metier = [t for t in types if t in METIER_TYPES]
    if not types:
        val = "aucun JSON-LD"
    elif metier:
        val = ", ".join(metier)
    else:
        val = "génériques seulement : " + ", ".join(types)
    put(
        "jsonld_metier",
        "réussi" if metier else "échoué",
        val,
        f"{src_json} › accueil.jsonld_types",
    )

    # 5. Organization correcte (lecture du HTML brut)
    orgs = [n for n in jsonld_nodes(html) if "Organization" in str(n.get("@type"))]
    if not orgs:
        put("organization", "échoué", "aucun nœud Organization", src_html)
    else:
        o = orgs[0]
        name = str(o.get("name") or "").strip()
        desc = str(o.get("description") or "").strip()
        tokens = [
            t
            for t in re.split(r"[^a-z0-9]+", strip_accents(name.lower()))
            if len(t) >= 4
        ]
        cible = strip_accents((domaine + " " + table.get("marque", "")).lower())
        name_ok = bool(name) and any(t in cible for t in tokens)
        desc_ok = strip_accents(desc.lower()) not in DEFAULT_WP_DESCRIPTIONS
        probl = []
        if not name:
            probl.append("name vide")
        elif not name_ok:
            probl.append(
                f"name « {name} » ne correspond pas au domaine (copie d'un autre site)"
            )
        if not desc_ok:
            probl.append(f"description par défaut « {desc} »")
        val = (
            f"name « {name or '—'} »"
            + (
                f" ; description « {desc[:60]}{'…' if len(desc) > 60 else ''} »"
                if desc
                else " ; description absente"
            )
            + (f" ; url {o.get('url')}" if o.get("url") else "")
        )
        put(
            "organization",
            "échoué" if probl else "réussi",
            val,
            f"{src_html} › script ld+json",
            " ; ".join(probl) or None,
        )

    # 6. title / h1
    title = (acc.get("title") or "").strip()
    h1s = [h for h in (acc.get("h1") or []) if str(h).strip()]
    ok_th = bool(title) and len(h1s) == 1
    val = (
        (f"title « {title} »" if title else "title absent")
        + " ; "
        + (
            f"h1 ×{len(h1s)} : " + " / ".join(f"« {h[:70]} »" for h in h1s)
            if h1s
            else "h1 absent"
        )
    )
    put(
        "title_h1",
        "réussi" if ok_th else "échoué",
        val,
        f"{src_json} › accueil.title, accueil.h1",
    )

    # 7. hreflang
    hl = acc.get("hreflang") or []
    put(
        "hreflang",
        "réussi" if hl else "échoué",
        ", ".join(hl) if hl else f"absent (lang={acc.get('html_lang') or '?'})",
        f"{src_json} › accueil.hreflang",
    )

    # 8. réponse directe (lecture humaine du tableau)
    rd = table.get("reponse_directe", "")
    rdl = strip_accents(rd.lower())
    if not rd or rdl.startswith("n/a") or rdl.startswith("a verifier"):
        put("reponse_directe", "non relevé", rd or "non relevé", src_tab)
    else:
        put(
            "reponse_directe",
            "réussi" if rdl.startswith("oui") else "échoué",
            rd,
            src_tab,
        )

    # 9. faits chiffrés
    fc = table.get("faits_chiffres", "")
    adresse = bool(re.search(r"adresse\s+oui", fc))
    faits = {
        "adresse": adresse,
        "téléphone": bool(acc.get("tel")),
        "capacités": bool(acc.get("capacites_detectees")),
        "horaires": bool(acc.get("horaires_detectes")),
        "prix": bool(acc.get("prix_detectes")),
    }
    n_faits = sum(faits.values())
    val = (
        " · ".join(f"{k} {'oui' if v else 'absent'}" for k, v in faits.items())
        + f" → {n_faits}/5"
    )
    put(
        "faits_chiffres",
        "réussi" if n_faits >= FORMULE["seuils"]["faits_chiffres_min"] else "échoué",
        val,
        f"{src_json} › accueil.tel, capacites_detectees, horaires_detectes, prix_detectes ; adresse : {src_tab}",
    )

    # 10. fraîcheur
    lastmod = sm.get("lastmod_max") or ""
    lm_date = None
    if lastmod:
        try:
            lm_date = dt.date.fromisoformat(lastmod[:10])
        except ValueError:
            lm_date = None
    dates = []
    for d in acc.get("dates_visibles") or []:
        for part in re.split(r"\s*;\s*", str(d)):
            pd = parse_date_text(part)
            if pd:
                dates.append(pd)
    passees = [d for d in dates if d < releve_date]
    futures = [d for d in dates if d >= releve_date]
    annees = [int(a) for a in (acc.get("annees_citees") or []) if str(a).isdigit()]
    probl = []
    if lm_date is None:
        probl.append("aucun lastmod dans le sitemap")
    elif (releve_date - lm_date).days > FORMULE["seuils"]["fraicheur_jours"]:
        probl.append(
            f"sitemap lastmod {lm_date.isoformat()} (> {FORMULE['seuils']['fraicheur_jours']} j)"
        )
    if passees and not futures:
        probl.append(
            "date passée présentée : " + ", ".join(d.isoformat() for d in passees)
        )
    if annees and max(annees) < releve_date.year:
        probl.append(f"année la plus récente citée : {max(annees)}")
    val = f"sitemap {lm_date.isoformat()}" if lm_date else "sitemap sans lastmod"
    if dates:
        val += " ; dates visibles : " + ", ".join(
            sorted({d.isoformat() for d in dates})
        )
    if annees:
        val += " ; années citées : " + ", ".join(str(a) for a in sorted(set(annees)))
    put(
        "fraicheur",
        "échoué" if probl else "réussi",
        val,
        f"{src_json} › sitemap.lastmod_max, accueil.dates_visibles, accueil.annees_citees",
        " ; ".join(probl) or None,
    )

    # 11. erreurs
    errs = []
    if home.get("code") != 200:
        errs.append(f"accueil code {home.get('code')}")
    if home.get("err"):
        errs.append(str(home.get("err"))[:90])
    if raw.get("https_vers_http"):
        errs.append("ressources http:// dans une page https")
    for d in declares_ko:
        errs.append(f"robots.txt déclare {d} → {(tests.get(d) or {}).get('code')}")
    for lc in raw.get("liens_casses") or []:
        if lc.get("code") == 404 or (lc.get("code") or 0) >= 500:
            errs.append(f"{lc.get('url')} → {lc.get('code')}")
    put(
        "erreurs",
        "échoué" if errs else "réussi",
        " ; ".join(errs) if errs else "aucune",
        f"{src_json} › home, https_vers_http, liens_casses, sitemap.tests",
    )

    # 12. rendu sans JS
    mots = acc.get("texte_mots", 0) or 0
    put(
        "rendu_sans_js",
        "réussi" if mots >= FORMULE["seuils"]["rendu_sans_js_mots_min"] else "échoué",
        f"{mots} mots dans le HTML livré ({acc.get('tokens_estimes', 0)} tokens)"
        + (
            f" ; début : « {acc.get('debut_texte')[:60]}… »"
            if mots < 100 and acc.get("debut_texte")
            else ""
        ),
        f"{src_json} › accueil.texte_mots",
    )

    # 13. action déclarée
    actions = sorted(
        set(
            re.findall(
                r"\b(ReserveAction|RegisterAction|BuyAction|OrderAction|JoinAction)\b",
                html,
            )
        )
    )
    mcp = bool(
        re.search(r"data-mcp-|rel=[\"']mcp-actions|navigator\.modelContext", html)
    )
    fichiers = [
        k
        for k in ("agent-permissions.json", ".well-known/ai-plugin.json")
        if (disc.get(k) or {}).get("present")
    ]
    ok_act = bool(actions or mcp or fichiers)
    pa = [t for t in types if t.endswith("Action")]
    val = (
        ", ".join(actions + (["WebMCP"] if mcp else []) + fichiers)
        if ok_act
        else (
            "aucune action déclarée"
            + (f" (potentialAction : {', '.join(pa)} seulement)" if pa else "")
            + " ; agent-permissions.json et ai-plugin.json : "
            + ", ".join(
                str((disc.get(k) or {}).get("code"))
                for k in ("agent-permissions.json", ".well-known/ai-plugin.json")
            )
        )
    )
    put(
        "action_declaree",
        "réussi" if ok_act else "échoué",
        val,
        f"{src_html} ; {src_json} › discovery",
        f"canal humain relevé : {table.get('action_sans_humain')}"
        if table.get("action_sans_humain")
        else None,
    )

    reussis = sum(1 for c in crit.values() if c["verdict"] == "réussi")
    mesures = sum(1 for c in crit.values() if c["verdict"] != "non relevé")
    score = round(100 * reussis / mesures) if mesures else None
    return {
        "domaine": domaine,
        "marque": table.get("marque", ""),
        "pilier_releve": table.get("pilier_releve", ""),
        "pilier": pilier_groupe(domaine, table.get("pilier_releve", "")),
        "plateforme": table.get("plateforme", ""),
        "url_effective": home.get("url_effective", ""),
        "releve_utc": raw.get("releve_utc", ""),
        "accueil": {
            "code": home.get("code"),
            "poids": ko(home.get("size", 0) or 0),
            "temps_s": round(home.get("time", 0) or 0, 2),
        },
        "page_cle": table.get("page_cle", ""),
        "criteres": crit,
        "reussis": reussis,
        "mesures": mesures,
        "non_releves": len(crit) - mesures,
        "score": score,
    }


# ---------------------------------------------------------------------------
# Assemblage
# ---------------------------------------------------------------------------
def build(args) -> dict:
    raw_dir = Path(args.raw)
    tables = Path(args.tables)
    releve_date = dt.date.fromisoformat(args.date)
    table = load_tableau_domaines(tables / "fondations" / "TABLEAU-DOMAINES.md")
    notes = load_notes(tables / "fondations" / "TABLEAU-DOMAINES.md")
    plan = load_plan(tables / "fondations" / "PLAN-FONDATIONS.md")
    citations = load_citations(tables / "citations" / "TABLEAU.md")
    agentique = load_taches(tables / "agentique" / "TABLEAU-TACHES.md")

    domaines = []
    for jp in sorted(raw_dir.glob("*.json")):
        domaine = jp.name[:-5]
        raw = json.loads(read(jp))
        hp = raw_dir / f"{domaine}.home.html"
        html = read(hp) if hp.exists() else ""
        domaines.append(
            evaluer(domaine, raw, html, table.get(domaine, {}), releve_date)
        )

    ordre = {k: i for i, k in enumerate(PILIER_ORDRE)}
    domaines.sort(key=lambda d: (ordre.get(d["pilier"], 99), d["domaine"]))

    piliers = []
    for key in PILIER_ORDRE:
        ds = [d for d in domaines if d["pilier"] == key]
        if not ds:
            continue
        r = sum(d["reussis"] for d in ds)
        m = sum(d["mesures"] for d in ds)
        piliers.append(
            {
                "id": key,
                "label": PILIER_GROUPES[key],
                "domaines": [d["domaine"] for d in ds],
                "reussis": r,
                "mesures": m,
                "score": round(100 * r / m) if m else None,
            }
        )

    par_critere = {}
    for c in CRITERES:
        vs = [d["criteres"][c["id"]]["verdict"] for d in domaines]
        par_critere[c["id"]] = {
            "réussi": vs.count("réussi"),
            "échoué": vs.count("échoué"),
            "non relevé": vs.count("non relevé"),
        }

    taches = agentique["taches"]
    completables = sum(1 for t in taches if t["faisabilite"] == 3)
    lisibles = sum(1 for t in taches if (t["faisabilite"] or 0) >= 2)
    synth = citations["synthese"]
    total_r = sum(d["reussis"] for d in domaines)
    total_m = sum(d["mesures"] for d in domaines)

    return {
        "titre": "Relevé de conformité du groupe Tero",
        "date": args.date,
        "genere_le": dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%d %H:%M UTC"),
        "sources": {
            "raw": "aeo-2026-10-21/fondations/raw/<domaine>.json et <domaine>.home.html (24 domaines)",
            "tableaux": [
                "fondations/TABLEAU-DOMAINES.md",
                "fondations/PLAN-FONDATIONS.md",
                "citations/TABLEAU.md",
                "agentique/TABLEAU-TACHES.md",
            ],
            "script_releve": "fondations/scripts/audit_domain.py",
            "script_build": "assets/conformite/build.py",
        },
        "chiffres_du_jour": [
            {
                "id": "web",
                "valeur": synth.get("web_domaine_top10", {}).get("valeur"),
                "total": synth.get("web_domaine_top10", {}).get("total"),
                "libelle": "requête web où un domaine Tero entre dans le top 10",
                "source": "citations/TABLEAU.md › synthèse chiffrée",
            },
            {
                "id": "claude",
                "valeur": synth.get("claude_nomme_tero", {}).get("valeur"),
                "total": synth.get("claude_nomme_tero", {}).get("total"),
                "libelle": "requêtes où Claude nomme Tero spontanément",
                "source": "citations/TABLEAU.md › synthèse chiffrée",
            },
            {
                "id": "agents",
                "valeur": completables,
                "total": len(taches),
                "libelle": "tâches qu'un agent navigateur peut mener de bout en bout",
                "source": "agentique/TABLEAU-TACHES.md › faisabilité = 3",
            },
        ],
        "formule": FORMULE,
        "criteres": CRITERES,
        "piliers": piliers,
        "piliers_libelles": PILIER_GROUPES,
        "total": {
            "domaines": len(domaines),
            "reussis": total_r,
            "mesures": total_m,
            "score": round(100 * total_r / total_m) if total_m else None,
            "par_critere": par_critere,
        },
        "domaines": domaines,
        "notes_releve": notes,
        "requetes": {
            "total": len(citations["requetes"]),
            "liste": citations["requetes"],
            "synthese": synth,
            "moteurs": citations["moteurs"],
        },
        "taches": {
            "total": len(taches),
            "completables": completables,
            "lisibles": lisibles,
            "echelle": agentique["echelle"],
            "liste": taches,
            "signaux": agentique["signaux"],
        },
        "chantiers": plan,
    }


def inject_inline(html_path: Path, data: dict) -> bool:
    if not html_path.exists():
        return False
    html = read(html_path)
    start, end = "<!-- releve:inline -->", "<!-- /releve:inline -->"
    if start not in html or end not in html:
        return False
    payload = json.dumps(data, ensure_ascii=False, separators=(",", ":")).replace(
        "</", "<\\/"
    )
    block = f'{start}<script type="application/json" id="releve-inline">{payload}</script>{end}'
    new = re.sub(
        re.escape(start) + r".*?" + re.escape(end), lambda _: block, html, flags=re.S
    )
    if new != html:
        html_path.write_text(new, encoding="utf-8", newline="\n")
    return True


def main():
    ap = argparse.ArgumentParser(
        description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
    )
    ap.add_argument("--raw", default=r"N:\dev\Koen\aeo-2026-10-21\fondations\raw")
    ap.add_argument("--tables", default=r"N:\dev\Koen\aeo-2026-10-21")
    ap.add_argument("--site", default=r"N:\dev\Koen\charte-tero-site\site-2026-10-21")
    ap.add_argument("--date", default="2026-10-21")
    ap.add_argument(
        "--no-inline",
        action="store_true",
        help="ne pas injecter le repli inline dans conformite.html",
    )
    args = ap.parse_args()

    data = build(args)
    site = Path(args.site)
    out_dir = site / "assets" / "conformite"
    out_dir.mkdir(parents=True, exist_ok=True)
    out = out_dir / f"releve-{args.date}.json"
    out.write_text(
        json.dumps(data, ensure_ascii=False, indent=1), encoding="utf-8", newline="\n"
    )
    print(
        f"écrit {out} ({out.stat().st_size // 1024} Ko) — {data['total']['domaines']} domaines, score global {data['total']['score']}"
    )
    for p in data["piliers"]:
        print(
            f"  {p['label']:<18} {p['score']:>3} ({p['reussis']}/{p['mesures']})  {', '.join(p['domaines'])}"
        )
    for c in data["criteres"]:
        pc = data["total"]["par_critere"][c["id"]]
        print(
            f"  {c['label']:<24} réussi {pc['réussi']:>2} · échoué {pc['échoué']:>2} · non relevé {pc['non relevé']}"
        )
    print(
        f"  requêtes {data['requetes']['total']} · tâches {data['taches']['total']} (complétables {data['taches']['completables']}, lisibles {data['taches']['lisibles']}) · chantiers {len(data['chantiers'])}"
    )
    if not args.no_inline:
        ok = inject_inline(site / "conformite.html", data)
        print(
            "repli inline :",
            "injecté dans conformite.html"
            if ok
            else "conformite.html ou ses marqueurs absents — rien injecté",
        )
    me = Path(__file__).resolve()
    dest = out_dir / "build.py"
    if me != dest.resolve():
        shutil.copyfile(me, dest)
        print(f"copie du script : {dest}")


if __name__ == "__main__":
    main()
