#!/usr/bin/env python3
"""Matrice d'acceptation / traçabilité MVP (Sprint 8 · QA · buffer L75).

Document de RECETTE de MÉTA-NIVEAU : il mappe chaque PROMESSE de la roadmap
(8 livrables de sprint L33..L76 + 7 métriques succès MVP L81..L87) vers sa PREUVE
livrée (un module gated du CI) OU un hors-périmètre worker SOURCÉ. Il répond à la
seule question de recette : « le MVP promis est-il honoré, et ce qui reste
est-il explicitement hors périmètre worker ? »

Axe distinct des autres harnais méta (non redondant · #5) :
  - qa/audit_4big         note la QUALITÉ statique de chaque module ;
  - qa/regression         prouve que chaque suite S'EXÉCUTE au vert ;
  - devops/deploy_runbook ORDONNE le déploiement VPS des modules ;
  - qa/acceptance (ici)   prouve la COUVERTURE des promesses roadmap.

Périmètre PROUVÉ, pas déclaré : l'ensemble des modules-preuve est confronté à
`.gitea/workflows/ci.yml` (réutilise `q4lib.registry.parse_ci` — zéro duplication
· #5) de façon BIJECTIVE, et la fenêtre de sprint de chaque module est LUE dans
le registre de l'auditeur 4Big (anti-dérive). Tout module gated non tracé OU tout
module tracé non gated → génération refusée.

Anti-invention (#6) : les nombres des énoncés (<1h, 95/100, 7 dashboards, 2
stores) sont des CITATIONS verbatim de la roadmap ; la matrice ne PRÉTEND aucune
mesure de performance ; tout hors-périmètre porte une source. Le n° de ligne
(`roadmap_line`) de chaque promesse est VÉRIFIÉ (INV11) contre la structure réelle
de la roadmap — il ne peut pas pointer à côté en silence si la roadmap est éditée.

Produit :
  - acceptance_matrix.json → la matrice (une ligne par promesse : preuves
                             résolues, hors-périmètre sourcé, drapeaux) + verdict.
  - MANIFEST.json          → manifeste agrégé (comptes, preuve de couverture
                             bijective vs CI, partition par sprint).

Sous-commandes :
  build    [-o OUT]  → écrit acceptance_matrix.json / MANIFEST.json (après validation)
  validate [-o OUT]  → (re)génère en mémoire, valide vs acceptance.schema.json +
                       invariants — sort en erreur si un invariant casse.

Sortie déterministe (ordre du spec, listes triées, aucun horodatage).
"""

from __future__ import annotations

import argparse
import json
import os
import sys

_HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, _HERE)

from acclib import builder, deps  # noqa: E402

_SPEC_PATH = os.path.join(_HERE, "acceptance_spec.json")
_SCHEMA_PATH = os.path.join(_HERE, "acceptance.schema.json")
_DEFAULT_OUT = os.path.join(_HERE, "out")

_STATUS = {"in_repo", "out_of_scope"}
_KINDS = {"sprint_deliverable", "mvp_metric"}


def _eprint(*args) -> None:
    print(*args, file=sys.stderr)


def _load(path: str) -> dict:
    with open(path, encoding="utf-8") as fh:
        return json.load(fh)


def _write_json(path: str, data) -> None:
    with open(path, "w", encoding="utf-8") as fh:
        json.dump(data, fh, ensure_ascii=False, indent=2)
        fh.write("\n")


def _build_bundle() -> dict:
    """Charge le spec, lit la réalité du CI + le registre 4Big, assemble."""
    spec = _load(_SPEC_PATH)
    ci = deps.parse_ci()
    audit = deps.audit_sprints()
    return builder.build_matrix(spec, ci, audit, deps)


def validate_bundle(bundle: dict, spec: dict, ci: dict, audit: dict) -> list[str]:
    """Valide la matrice contre acceptance.schema.json + invariants de recette.

    Fonction pure sur (bundle, spec, ci, audit) → les tests injectent des
    dictionnaires synthétiques pour prouver le basculement en erreur.
    """
    schema = _load(_SCHEMA_PATH)
    errors = list(deps.validate(bundle, schema))

    matrix = bundle["matrix"]
    manifest = bundle["manifest"]
    cov = manifest["coverage"]
    self_module = spec["self_module"]

    deliverables = builder.all_deliverables(spec)

    # INV1 · ids uniques ; livrables de sprint = S1..S8 ordonnés ; métriques =
    # M1..M7 ordonnées (aucune promesse roadmap omise ni dupliquée).
    ids = [r["id"] for r in matrix]
    if len(set(ids)) != len(ids):
        errors.append(f"lignes: ids en double {ids}.")
    sprint_ids = [d["id"] for d in spec.get("sprint_deliverables", [])]
    metric_ids = [d["id"] for d in spec.get("mvp_metrics", [])]
    if sprint_ids != sorted(sprint_ids):
        errors.append(f"livrables de sprint non ordonnés {sprint_ids}.")
    if metric_ids != sorted(metric_ids):
        errors.append(f"métriques MVP non ordonnées {metric_ids}.")

    # INV2 · toute preuve module citée est gated (aucune preuve fantôme). On
    # RECALCULE l'ensemble gated depuis le CI (on ne fait pas confiance au
    # manifeste).
    gated = set(builder.gated_modules(ci, self_module))
    for r in matrix:
        for m in r["modules"]:
            if m["module"] not in gated:
                errors.append(f"ligne {r['id']}: preuve non gated {m['module']!r}.")
            if m["gated"] != (m["module"] in gated):
                errors.append(f"ligne {r['id']}: drapeau `gated` faux pour {m['module']!r}.")

    # INV3 · COUVERTURE BIJECTIVE (cœur anti-invention · #6) : les modules cités
    # (lignes in_repo) == l'ensemble gated (hors self). Aucun oubli, aucune
    # invention.
    cited: set[str] = set()
    for d in deliverables:
        if d.get("status") == "in_repo":
            cited.update(d.get("evidence_modules", []))
    missing = sorted(gated - cited)
    phantom = sorted(cited - gated)
    if missing:
        errors.append(f"couverture: modules gated non tracés {missing}.")
    if phantom:
        errors.append(f"couverture: preuves non gated {phantom}.")
    if not cov["bijective"] and not (missing or phantom):
        errors.append("couverture: `bijective=false` mais aucun écart recalculé.")
    if cov["bijective"] and (missing or phantom):
        errors.append("couverture: `bijective=true` mais écart recalculé présent.")

    # INV4 · la fenêtre de sprint de chaque module gated est connue (registre
    # 4Big + extra du spec) — aucun module gated sans sprint.
    ms_map = builder.module_sprint_map(spec, audit)
    unknown = sorted(m for m in gated if m not in ms_map)
    if unknown:
        errors.append(f"sprint: modules gated sans fenêtre {unknown}.")
    for path, meta in spec.get("extra_module_sprint", {}).items():
        if path in audit and audit[path] != meta["sprint"]:
            errors.append(
                f"sprint: extra {path!r} en conflit avec le registre 4Big "
                f"({meta['sprint']} vs {audit[path]})."
            )

    # INV5 · PARTITION EXACTE par sprint : chaque livrable de sprint SX cite
    # EXACTEMENT les modules gated de fenêtre SX (ni trou ni chevauchement).
    gated_by_sprint: dict[str, set] = {}
    for m in gated:
        if m in ms_map:
            gated_by_sprint.setdefault(ms_map[m], set()).add(m)
    for d in spec.get("sprint_deliverables", []):
        sx = d["sprint"]
        want = gated_by_sprint.get(sx, set())
        have = set(d.get("evidence_modules", []))
        if want != have:
            errors.append(
                f"partition {sx}: attendu {sorted(want)} ≠ cité {sorted(have)}."
            )
    if cov["partition_ok"] != all(p["exact"] for p in cov["sprint_partition"]) or \
            (cov["unknown_sprint"] and cov["partition_ok"]):
        errors.append("partition: drapeau `partition_ok` incohérent.")

    # INV6 · tout hors-périmètre porte une source non vide (aucune omission
    # silencieuse d'une promesse roadmap).
    for d in deliverables:
        for o in d.get("out_of_scope", []):
            if not o.get("source", "").strip():
                errors.append(f"ligne {d['id']}: hors-périmètre sans source: {o.get('item')!r}.")

    # INV7 · cohérence statut/preuve : in_repo ⇒ ≥1 module OU ≥1 artefact ;
    # out_of_scope ⇒ 0 module + ≥1 hors-périmètre sourcé. Tout artefact cité
    # existe sur disque (fait · #6).
    for d in deliverables:
        st = d.get("status")
        if st not in _STATUS:
            errors.append(f"ligne {d['id']}: statut invalide {st!r}.")
        mods = d.get("evidence_modules", [])
        arts = d.get("evidence_artifacts", [])
        oos = d.get("out_of_scope", [])
        if st == "in_repo" and not mods and not arts:
            errors.append(f"ligne {d['id']}: in_repo sans aucune preuve.")
        if st == "out_of_scope":
            if mods:
                errors.append(f"ligne {d['id']}: out_of_scope avec preuve module {mods}.")
            if not oos:
                errors.append(f"ligne {d['id']}: out_of_scope sans hors-périmètre sourcé.")
        for a in arts:
            if not deps.artifact_exists(a):
                errors.append(f"ligne {d['id']}: artefact-preuve inexistant {a!r}.")

    # INV8 · kind cohérent : sprint_deliverable ⇒ sprint renseigné ; mvp_metric
    # ⇒ sprint null (les métriques transcendent les sprints).
    for d in deliverables:
        if d["kind"] not in _KINDS:
            errors.append(f"ligne {d['id']}: kind invalide {d['kind']!r}.")
        if d["kind"] == "sprint_deliverable" and not d.get("sprint"):
            errors.append(f"ligne {d['id']}: sprint_deliverable sans sprint.")
        if d["kind"] == "mvp_metric" and d.get("sprint") is not None:
            errors.append(f"ligne {d['id']}: mvp_metric avec sprint non nul {d.get('sprint')!r}.")

    # INV9 · le manifeste s'exclut lui-même (SoD · ISA 315) — la matrice ne se
    # cite jamais comme preuve.
    if self_module in cited:
        errors.append("SoD: la matrice se cite elle-même comme preuve.")
    if cov["self_module_excluded"] != self_module:
        errors.append("SoD: self_module_excluded divergent.")

    # INV10 · roadmap_line = entier positif ; pas de doublon de ligne.
    lines = [d["roadmap_line"] for d in deliverables]
    if any(not isinstance(x, int) or x < 1 for x in lines):
        errors.append(f"roadmap_line: valeur non positive {lines}.")

    # INV11 · ANCRAGE ROADMAP (traçabilité VÉRIFIÉE, pas déclarée · #6). INV10 ne
    # garantit que « roadmap_line est un entier » : un n° de ligne qui pointe à
    # côté (roadmap éditée, lignes décalées) reste « vert » alors qu'il cite un
    # bullet qui n'existe plus. On DÉRIVE la structure réelle de la roadmap et on
    # exige que chaque roadmap_line pointe RÉELLEMENT le bullet qu'il prétend :
    #   · nombre de bullets DELIVERABLE == nombre de livrables de sprint du spec,
    #     et nombre de bullets métrique == nombre de métriques (le « 8 + 7 » n'est
    #     plus un compte figé mais dérivé du fichier roadmap) ;
    #   · chaque livrable SX pointe le bullet **DELIVERABLE** du sprint SX ;
    #   · chaque métrique (dans l'ordre) pointe le k-ième bullet « - ✅ ».
    try:
        anchors = deps.parse_roadmap_anchors(spec["roadmap_ref"])
    except OSError as e:
        errors.append(f"roadmap: {spec.get('roadmap_ref')!r} illisible ({e}).")
    else:
        dmap = anchors["deliverables"]
        specd = spec.get("sprint_deliverables", [])
        specm = spec.get("mvp_metrics", [])
        if len(dmap) != len(specd):
            errors.append(
                f"roadmap: {len(dmap)} bullet(s) DELIVERABLE dans la roadmap ≠ "
                f"{len(specd)} livrable(s) de sprint du spec (promesse ajoutée/retirée non tracée).")
        if len(anchors["metrics"]) != len(specm):
            errors.append(
                f"roadmap: {len(anchors['metrics'])} bullet(s) métrique dans la roadmap ≠ "
                f"{len(specm)} métrique(s) du spec (promesse ajoutée/retirée non tracée).")
        for d in specd:
            want = dmap.get(d["sprint"])
            if want is None:
                errors.append(
                    f"roadmap: {d['id']}: aucun bullet DELIVERABLE pour le sprint {d['sprint']}.")
            elif d["roadmap_line"] != want:
                errors.append(
                    f"roadmap: {d['id']}: roadmap_line={d['roadmap_line']} ne pointe pas "
                    f"le DELIVERABLE de {d['sprint']} (ligne {want}).")
        for k, d in enumerate(specm):
            want = anchors["metrics"][k] if k < len(anchors["metrics"]) else None
            if want is None:
                errors.append(
                    f"roadmap: {d['id']}: aucun {k + 1}e bullet métrique dans la roadmap.")
            elif d["roadmap_line"] != want:
                errors.append(
                    f"roadmap: {d['id']}: roadmap_line={d['roadmap_line']} ≠ {k + 1}e bullet "
                    f"métrique (ligne {want}).")

    return errors


def _validate_or_die(bundle: dict) -> None:
    spec = _load(_SPEC_PATH)
    ci = deps.parse_ci()
    audit = deps.audit_sprints()
    errs = validate_bundle(bundle, spec, ci, audit)
    if errs:
        _eprint("❌ Validation échouée :")
        for e in errs:
            _eprint(f"   · {e}")
        sys.exit(1)


def cmd_build(args) -> None:
    bundle = _build_bundle()
    _validate_or_die(bundle)
    out_dir = args.out or _DEFAULT_OUT
    os.makedirs(out_dir, exist_ok=True)
    _write_json(os.path.join(out_dir, "acceptance_matrix.json"),
                {"matrix": bundle["matrix"], "verdict": bundle["verdict"]})
    _write_json(os.path.join(out_dir, "MANIFEST.json"), bundle["manifest"])
    cov = bundle["manifest"]["coverage"]
    c = bundle["manifest"]["counts"]
    verdict = "✅ RECETTE PASS" if bundle["verdict"] else "❌ RECETTE FAIL"
    print(f"{verdict} — {c['sprint_deliverables']} livrables de sprint + "
          f"{c['mvp_metrics']} métriques MVP · {c['gated_modules']} modules gated "
          f"tracés (bijectif={cov['bijective']}) · {c['out_of_scope_items']} "
          f"hors-périmètre sourcés.")
    print(f"   → {os.path.join(out_dir, 'acceptance_matrix.json')}")
    print(f"   → {os.path.join(out_dir, 'MANIFEST.json')}")


def cmd_validate(args) -> None:
    bundle = _build_bundle()
    _validate_or_die(bundle)
    cov = bundle["manifest"]["coverage"]
    print(f"✅ Matrice valide — bijective={cov['bijective']} · "
          f"partition_ok={cov['partition_ok']} · verdict={bundle['verdict']}.")


def main(argv=None) -> None:
    p = argparse.ArgumentParser(
        prog="acceptance_gen",
        description="Matrice d'acceptation / traçabilité MVP (roadmap → preuve).")
    sub = p.add_subparsers(dest="cmd", required=True)

    b = sub.add_parser("build", help="écrit acceptance_matrix.json / MANIFEST.json")
    b.add_argument("-o", "--out", help="répertoire de sortie (défaut: ./out)")
    b.set_defaults(func=cmd_build)

    v = sub.add_parser("validate", help="valide sans écrire")
    v.add_argument("-o", "--out", help="(ignoré · symétrie CLI)")
    v.set_defaults(func=cmd_validate)

    args = p.parse_args(argv)
    args.func(args)


if __name__ == "__main__":
    main()
