#!/usr/bin/env python3
"""Générateur de l'audit 4Big qualité · Sprint 7 · QA.

Roadmap Sprint 7 · QA : « Audit 4Big niveau 95+/100 sur 100% deliverables ».
Ce générateur est un audit de MÉTA-NIVEAU : il note la qualité 4Big de TOUS les
livrables du mandat à partir de FAITS du dépôt (documentation, contrat de sortie
schema, couverture de tests, CLI reproductible, intégrité du hand-off out/) et
exige une note ≥ 95/100 sur chacun (CLAUDE.md #5).

Le périmètre « 100% des livrables » est PROUVÉ, pas déclaré : le registre est
recoupé avec les `working-directory` du CI Gitea (moins l'auditeur lui-même,
séparation des pouvoirs · ISA 315). Toute divergence casse le verdict.

ANTI-INVENTION (#6) : aucune note n'est saisie à la main — chaque note est
RECALCULÉE depuis des faits vérifiables (présence de fichiers, taille, comptage
des méthodes test_*, validité JSON du hand-off). Un module dont un critère
applicable échoue tombe sous 95 → FAIL global (l'audit est un GATE).

Sous-commandes :
  build    [-o OUT]  → écrit quality_report.json + MANIFEST.json
  validate           → (re)génère en mémoire, valide schéma + invariants ;
                       sort en erreur si un module < 95, si la couverture n'est
                       pas 100%, ou si un invariant casse.

Sortie déterministe (tri stable, aucun horodatage) → diffable + re-générable.
"""

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

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

_KNOWN_CRITERIA = {"DOC", "CONTRAT", "TESTS", "CLI", "HANDOFF"}


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, report: dict) -> dict:
    return {
        "generated_from": "quality_spec.json",
        "generator": "audit_4big_gen.py",
        "audit": report["audit"],
        "version": report["version"],
        "verdict": report["verdict"],
        "modules_audited": report["totals"]["modules"],
        "min_score": report["totals"]["min_score"],
        "pass_score": report["pass_score"],
        "coverage_ok": report["coverage"]["ok"],
        "artifacts": ["quality_report.json"],
        "roadmap": "Sprint 7 · QA · Audit 4Big 95+/100 sur 100% deliverables",
        "hors_perimetre_worker": (
            "Publication du rapport dans le desk ERPNext + branchement du gate "
            "4Big sur le pipeline de release VPS → agent QA / DevOps (#8)."
        ),
    }


def check_invariants(report: dict, spec: dict) -> list[str]:
    """Invariants de cohérence 4Big. 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(report, schema)]

    # 2. Poids des critères = 100 (base avant renormalisation).
    total_w = sum(c["weight"] for c in spec["criteria"])
    if total_w != 100:
        errs.append(f"INV2 somme des poids critères = {total_w} ≠ 100")

    # 3. Archétypes bien formés (applicable non vide ⊂ critères connus).
    for name, arch in spec["archetypes"].items():
        appl = set(arch["applicable"])
        if not appl:
            errs.append(f"INV3 archétype {name} sans critère applicable")
        if not appl <= _KNOWN_CRITERIA:
            errs.append(f"INV3 archétype {name} critères inconnus "
                        f"{sorted(appl - _KNOWN_CRITERIA)}")

    # 4. L'auditeur ne s'auto-audite pas.
    self_mod = spec["self_module"]
    if any(m["path"] == self_mod for m in report["modules"]):
        errs.append(f"INV4 auditeur {self_mod} présent dans le périmètre audité")

    # 5. Couverture 100% (bijection registre ↔ CI, tous dans le gate).
    cov = report["coverage"]
    if not cov["ok"]:
        errs.append(
            f"INV5 couverture incomplète : missing_in_registry="
            f"{cov['missing_in_registry']} missing_in_ci={cov['missing_in_ci']} "
            f"not_in_gate={cov['not_in_gate']}")
    if not (cov["ci_modules_count"] == cov["registry_modules_count"]
            == len(report["modules"]) == len(spec["modules"])):
        errs.append("INV5 comptages couverture/registre/rapport incohérents")

    # 6. Par module : recompute note + cohérence des checks avec l'archétype.
    for m in report["modules"]:
        appl = set(spec["archetypes"][m["archetype"]]["applicable"])
        got = {c["criterion"] for c in m["checks"]}
        if got != appl:
            errs.append(f"INV6 {m['id']} checks {sorted(got)} ≠ applicables "
                        f"{sorted(appl)}")
        wsum = sum(c["weight"] for c in m["checks"])
        if wsum != m["applicable_weight"]:
            errs.append(f"INV6 {m['id']} Σpoids checks {wsum} ≠ applicable_weight "
                        f"{m['applicable_weight']}")
        earned = sum(c["weight"] for c in m["checks"] if c["passed"])
        if earned != m["earned_weight"]:
            errs.append(f"INV6 {m['id']} earned recomputé {earned} ≠ "
                        f"{m['earned_weight']}")
        recomputed = scoring._round_half_up(100.0 * earned / m["applicable_weight"])
        if recomputed != m["score"]:
            errs.append(f"INV6 {m['id']} note recomputée {recomputed} ≠ "
                        f"{m['score']} (note fabriquée ?)")

    # 7. Le gate : chaque module ≥ pass_score et verdict cohérent.
    ps = spec["thresholds"]["pass_score"]
    for m in report["modules"]:
        expect = "PASS" if m["score"] >= ps else "FAIL"
        if m["verdict"] != expect:
            errs.append(f"INV7 {m['id']} verdict {m['verdict']} ≠ attendu {expect}")
        if m["verdict"] != "PASS":
            errs.append(f"INV7 {m['id']} note {m['score']} < seuil 4Big {ps}")

    # 8. Totaux cohérents.
    t = report["totals"]
    scores = [m["score"] for m in report["modules"]]
    if t["pass"] + t["fail"] != t["modules"]:
        errs.append("INV8 pass+fail ≠ modules")
    if scores and (t["min_score"] != min(scores) or t["max_score"] != max(scores)):
        errs.append("INV8 min/max score incohérents")

    # 9. Verdict global.
    expect_global = "PASS" if (cov["ok"] and t["fail"] == 0) else "FAIL"
    if report["verdict"] != expect_global:
        errs.append(f"INV9 verdict global {report['verdict']} ≠ {expect_global}")

    return errs


def cmd_build(args: argparse.Namespace) -> int:
    spec = _load(_SPEC_PATH)
    report = builder.build(spec)
    errs = check_invariants(report, 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, "quality_report.json"), report)
    _write_json(os.path.join(out_dir, "MANIFEST.json"), _manifest(spec, report))
    print(f"✅ Audit 4Big écrit dans {out_dir}/ — verdict {report['verdict']} · "
          f"{report['totals']['pass']}/{report['totals']['modules']} modules ≥ "
          f"{report['pass_score']} (min {report['totals']['min_score']}).")
    return 0


def cmd_validate(_args: argparse.Namespace) -> int:
    spec = _load(_SPEC_PATH)
    report = builder.build(spec)
    errs = check_invariants(report, spec)
    if errs:
        _eprint("❌ Audit 4Big NON conforme :")
        for e in errs:
            _eprint(f"   · {e}")
        return 1
    print(f"✅ Audit 4Big conforme — verdict {report['verdict']} · couverture "
          f"{report['coverage']['registry_modules_count']} modules · min "
          f"{report['totals']['min_score']}/100 (seuil {report['pass_score']}).")
    return 0


def build_argparser() -> argparse.ArgumentParser:
    ap = argparse.ArgumentParser(prog="audit_4big_gen",
                                 description=__doc__.splitlines()[0])
    sub = ap.add_subparsers(dest="cmd", required=True)
    p = sub.add_parser("build", help="écrit quality_report.json + MANIFEST.json")
    p.add_argument("-o", "--out", default=None, help="répertoire de sortie")
    p.set_defaults(func=cmd_build)
    p = sub.add_parser("validate", help="valide schéma + invariants (sans écrire)")
    p.set_defaults(func=cmd_validate)
    return ap


def main(argv: list[str] | None = None) -> int:
    ns = build_argparser().parse_args(argv)
    return ns.func(ns)


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