#!/usr/bin/env python3
"""Générateur de la matrice de régression exhaustive · Sprint 8 · QA.

Roadmap Sprint 8 · QA : « Regression tests exhaustifs ». Harnais de MÉTA-NIVEAU :
il agrège l'exécution de TOUTES les suites de tests gated du mandat en une matrice
unique + un verdict PASS/FAIL, et fournit le compte agrégé faisant autorité
(« N tests verts ») — celui que les rapports citaient jusqu'ici à la main.

Le périmètre « exhaustif » est PROUVÉ, pas déclaré : les suites sont DÉRIVÉES de
`.gitea/workflows/ci.yml` (réutilise `q4lib/registry.parse_ci` · zéro duplication
· #5), jamais listées à la main. Le harnais s'exclut lui-même (séparation des
pouvoirs · ISA 315 · évite la récursion).

ANTI-INVENTION (#6) : le `build` (plan) ne contient AUCUN compteur de résultat —
seuls des faits de disque (fichiers/méthodes `test_*`) et de CI (job, gate). Les
compteurs verts/rouges sont produits UNIQUEMENT par `run`, en parsant la sortie
réelle de unittest.

Sous-commandes :
  build    [-o OUT]  → écrit regression_plan.json + MANIFEST.json (déterministe)
  validate           → (re)génère le plan en mémoire, valide schéma + invariants ;
                       sort en erreur si la couverture n'est pas prouvée ou si une
                       suite passe sous les planchers structurels.
  run      [-o OUT]  → exécute réellement toutes les suites, agrège la matrice
                       live + verdict ; sort en erreur (code ≠ 0) si une suite est
                       rouge. Écrit `regression_run.json` : bien qu'il porte les
                       compteurs réellement exécutés, il ne contient AUCUN
                       horodatage/hôte/chemin absolu → deux `run` sont
                       byte-identiques, donc l'artefact EST commité et byte-gaté
                       par `ci/check_regression.sh` (toute dérive = CI rouge).

Toutes les sous-commandes productrices sont déterministes (tri stable, aucun
horodatage) → artefacts diffables + commités + byte-gatés : `regression_plan.json`
par `ci/check_artifacts.sh`, `regression_run.json` par `ci/check_regression.sh`.
"""

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 reglib import builder, discovery  # noqa: E402
from reglib.deps import validate as maison_validate  # noqa: E402

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


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 _manifest(spec: dict, plan: dict) -> dict:
    return {
        "generated_from": "regression_spec.json",
        "generator": "regression_gen.py",
        "matrix": plan["matrix"],
        "version": plan["version"],
        "verdict": plan["verdict"],
        "suites": plan["totals"]["suites"],
        "test_methods": plan["totals"]["test_methods"],
        "coverage_ok": plan["coverage"]["ok"],
        "artifacts": ["regression_plan.json"],
        "roadmap": "Sprint 8 · QA · Regression tests exhaustifs",
        "hors_perimetre_worker": (
            "Exécution planifiée de `run` sur le runner CI/VPS + publication du "
            "compte agrégé dans le desk ERPNext / le pipeline de release → agent "
            "QA / DevOps (#8) : hors périmètre car planifiée sur le runner/VPS, "
            "non parce que la sortie dériverait — `regression_run.json` est "
            "byte-déterministe · commité · gaté par `ci/check_regression.sh`."
        ),
    }


def check_invariants(plan: dict, spec: dict) -> list[str]:
    """Invariants de cohérence de la matrice. Retourne la liste des violations."""
    errs: list[str] = []

    # 1. Schéma de sortie.
    schema = _load(_SCHEMA_PATH)
    errs += [f"schema: {e}" for e in maison_validate(plan, schema)]

    # 2. Anti-invention : le plan ne DOIT contenir aucun compteur de résultat.
    #    (verdict vert/rouge, tests passés) — ceux-ci n'existent qu'en mode run.
    forbidden = {"green", "red", "passed", "ran", "failures", "errors"}
    leaked = forbidden & set(plan["totals"].keys())
    if leaked:
        errs.append(f"INV2 compteurs de résultat dans le plan (invention ?) : "
                    f"{sorted(leaked)}")

    # 3. Le harnais ne s'auto-recense pas (séparation des pouvoirs · récursion).
    self_mod = spec["self_module"]
    if any(s["path"] == self_mod for s in plan["suites"]):
        errs.append(f"INV3 harnais {self_mod} présent dans les suites exécutées")

    # 4. Couverture prouvée (disque + gate + self gated + self exclu).
    cov = plan["coverage"]
    if not cov["ok"]:
        errs.append(
            f"INV4 couverture non prouvée : missing_tests_dir="
            f"{cov['missing_tests_dir']} not_in_gate={cov['not_in_gate']} "
            f"orphan_tests_dirs={cov['orphan_tests_dirs']} "
            f"self_module_gated={cov['self_module_gated']}")
    if cov["self_module_excluded"] != self_mod:
        errs.append("INV4 self_module_excluded incohérent avec le spec")

    # 5. Recompute des suites depuis le disque (aucun compte figé/fabriqué).
    fresh = discovery.discover_suites(spec)
    if [s["id"] for s in fresh] != [s["id"] for s in plan["suites"]]:
        errs.append("INV5 ensemble/ordre des suites non reproductible")
    fresh_by_id = {s["id"]: s for s in fresh}
    for s in plan["suites"]:
        f = fresh_by_id.get(s["id"])
        if f and (f["test_files"], f["test_methods"]) != (
                s["test_files"], s["test_methods"]):
            errs.append(f"INV5 {s['id']} comptes disque {s['test_files']}/"
                        f"{s['test_methods']} ≠ recomputés {f['test_files']}/"
                        f"{f['test_methods']} (compte figé ?)")

    # 6. Planchers structurels : chaque suite ≥ seuils, cohérent avec la liste.
    th = spec["thresholds"]
    under = [s["path"] for s in plan["suites"]
             if s["test_files"] < th["min_test_files"]
             or s["test_methods"] < th["min_methods_per_suite"]]
    if under != plan["under_threshold"]:
        errs.append(f"INV6 under_threshold {plan['under_threshold']} ≠ "
                    f"recomputé {under}")

    # 7. Totaux cohérents (recomputés depuis les suites).
    t = plan["totals"]
    suites = plan["suites"]
    exp = {
        "suites": len(suites),
        "test_files": sum(s["test_files"] for s in suites),
        "test_methods": sum(s["test_methods"] for s in suites),
        "min_methods": min((s["test_methods"] for s in suites), default=0),
        "under_threshold": len(under),
    }
    for k, v in exp.items():
        if t.get(k) != v:
            errs.append(f"INV7 totals[{k}]={t.get(k)} ≠ recomputé {v}")

    # 8. Verdict global cohérent (GATE : couverture prouvée ET zéro sous-plancher).
    expect = "PASS" if (cov["ok"] and not under) else "FAIL"
    if plan["verdict"] != expect:
        errs.append(f"INV8 verdict {plan['verdict']} ≠ attendu {expect}")

    return errs


def cmd_build(args: argparse.Namespace) -> int:
    spec = _load(_SPEC_PATH)
    plan = builder.build_plan(spec)
    errs = check_invariants(plan, spec)
    if errs:
        _eprint("❌ Invariants violés — build refusé :")
        for e in errs:
            _eprint(f"   · {e}")
        return 1
    out_dir = args.out or _DEFAULT_OUT
    os.makedirs(out_dir, exist_ok=True)
    _write_json(os.path.join(out_dir, "regression_plan.json"), plan)
    _write_json(os.path.join(out_dir, "MANIFEST.json"), _manifest(spec, plan))
    print(f"✅ Plan de régression écrit dans {out_dir}/ — verdict "
          f"{plan['verdict']} · {plan['totals']['suites']} suites · "
          f"{plan['totals']['test_methods']} méthodes test_* recensées.")
    return 0


def cmd_validate(args: argparse.Namespace) -> int:
    spec = _load(_SPEC_PATH)
    plan = builder.build_plan(spec)
    errs = check_invariants(plan, spec)
    if errs:
        _eprint("❌ Validation échouée :")
        for e in errs:
            _eprint(f"   · {e}")
        return 1
    if plan["verdict"] != "PASS":
        _eprint(f"❌ Verdict régression = {plan['verdict']} "
                f"(couverture={plan['coverage']['ok']} · "
                f"sous-plancher={plan['under_threshold']})")
        return 1
    print(f"✅ Matrice de régression valide — {plan['totals']['suites']} suites "
          f"gated · {plan['totals']['test_methods']} méthodes recensées · "
          f"couverture prouvée.")
    return 0


def cmd_run(args: argparse.Namespace) -> int:
    spec = _load(_SPEC_PATH)
    print("▶ Exécution exhaustive des suites gated (peut prendre un moment)…",
          file=sys.stderr)
    matrix = builder.run_matrix(spec)
    out_dir = args.out or _DEFAULT_OUT
    os.makedirs(out_dir, exist_ok=True)
    _write_json(os.path.join(out_dir, "regression_run.json"), matrix)
    t = matrix["totals"]
    for r in matrix["suites"]:
        mark = "✅" if r["ok"] else "❌"
        print(f"  {mark} {r['path']:32s} ran={r['ran']:3d} "
              f"fail={r['failures']} err={r['errors']} skip={r['skipped']}")
    print(f"\n{'✅' if matrix['verdict'] == 'PASS' else '❌'} Régression "
          f"{matrix['verdict']} — {t['green']}/{t['suites']} suites vertes · "
          f"{t['passed']} tests passés · {t['failures']} échecs · "
          f"{t['errors']} erreurs.")
    return 0 if matrix["verdict"] == "PASS" else 1


def build_parser() -> argparse.ArgumentParser:
    ap = argparse.ArgumentParser(description="Matrice de régression exhaustive "
                                             "(Sprint 8 · QA).")
    sub = ap.add_subparsers(dest="cmd", required=True)
    b = sub.add_parser("build", help="écrit le plan déterministe + MANIFEST")
    b.add_argument("-o", "--out", help="répertoire de sortie")
    b.set_defaults(func=cmd_build)
    v = sub.add_parser("validate", help="valide schéma + invariants (gate)")
    v.set_defaults(func=cmd_validate)
    r = sub.add_parser("run", help="exécute réellement toutes les suites (live)")
    r.add_argument("-o", "--out", help="répertoire de sortie")
    r.set_defaults(func=cmd_run)
    return ap


def main(argv=None) -> int:
    args = build_parser().parse_args(argv)
    return args.func(args)


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