#!/usr/bin/env python3
"""Générateur de l'audit 5D de conformité · Sprint 5 · QA.

Roadmap Sprint 5 · QA : « Audit UAF + normes ISA/IFRS 5D ». Ce générateur est un
audit de SECOND NIVEAU : il lit les hand-off `out/` déjà commités par les
générateurs amont (workflow vente, DocType Dossier Vente, barème commissions,
plan e-CF DGII, DocType CONFOTUR) et vérifie 17 contrôles répartis en 5
dimensions (Traçabilité ISA 500 · AML/UAF Ley 155-17 · Fiscal e-CF Ley 32-23 ·
Intégrité IFRS · Gouvernance ISA 315).

ANTI-INVENTION (#6) : l'audit ne fabrique AUCUN paramètre réglementaire. Un
paramètre réel non confirmé (taux commission, RNC/ITBIS/TipoCambio, seuil UAF)
produit un statut A_CONFIRMER — un « open item » remonté au métier propriétaire —
jamais une valeur inventée « pour faire PASS ». Un FAIL signale une incohérence
inter-livrables ou une valeur chiffrée présente SANS `source`.

Sous-commandes :
  build    [-o OUT]  → écrit audit_report.json + MANIFEST.json
  validate           → (re)génère en mémoire, valide schéma + 15 invariants ;
                       sort en erreur si un contrôle FAIL ou 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 qalib import artifacts, builder, controls  # noqa: E402
from qalib.deps import validate as maison_validate  # noqa: E402

_SPEC_PATH = os.path.join(_HERE, "audit_spec.json")
_SCHEMA_PATH = os.path.join(_HERE, "audit.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, report: dict) -> dict:
    return {
        "generated_from": "audit_spec.json",
        "audited_artifacts": sorted(artifacts.ARTIFACT_PATHS.values()),
        "dimensions": [d["id"] for d in report["dimensions"]],
        "controls_total": report["totals"]["controls"],
        "verdict": report["verdict"],
        "open_items": [oi["control"] for oi in report["open_items"]],
        "note_anti_invention": (
            "Audit de second niveau : lit les hand-off out/ des livrables, ne "
            "fabrique aucun paramètre réglementaire (#6). A_CONFIRMER = open "
            "item à confirmer par le métier, jamais une valeur inventée."
        ),
        "hand_off_vps": (
            "Confirmer les paramètres réglementaires (taux, RNC/ITBIS/"
            "TipoCambio, seuil UAF) + exécuter les tests E2E Playwright sur le "
            "desk réel → agents Direction/Compta/ONAPI-Legal (#8)."
        ),
    }


def _validate(spec: dict, report: dict) -> list[str]:
    """Schéma de sortie + 15 invariants de cohérence audit/contrôles."""
    errors = list(maison_validate(report, _load(_SCHEMA_PATH)))

    code_ids = controls.control_ids()
    spec_ids = sorted(c["id"] for c in spec["controls"])
    report_ids = sorted(c["id"] for c in report["controls"])
    code_dims = [d["id"] for d in controls.DIMENSIONS]
    spec_dims = [d["id"] for d in spec["dimensions"]]

    spec_by_id = {c["id"]: c for c in spec["controls"]}
    code_by_id = {c["id"]: c for c in controls.CONTROLS}
    known_art = set(artifacts.ARTIFACT_PATHS)

    # 1 · parité des id de contrôle : code == spec == rapport (aucune dérive).
    if not (code_ids == spec_ids == report_ids):
        errors.append(f"id de contrôle désalignés — code={code_ids} spec={spec_ids} report={report_ids}")

    # 2 · parité des dimensions : code == spec, ordre identique.
    if code_dims != spec_dims:
        errors.append(f"dimensions désalignées — code={code_dims} spec={spec_dims}")

    # 3 · chaque contrôle vise une dimension déclarée.
    for c in report["controls"]:
        if c["dimension"] not in code_dims:
            errors.append(f"contrôle {c['id']} → dimension inconnue {c['dimension']!r}")

    # 4 · artefacts déclarés (spec) == artefacts du code, tous connus.
    for cid in code_ids:
        sart = spec_by_id.get(cid, {}).get("artifacts")
        cart = code_by_id[cid]["artifacts"]
        if sart != cart:
            errors.append(f"contrôle {cid} : artefacts spec {sart} ≠ code {cart}")
        for a in cart:
            if a not in known_art:
                errors.append(f"contrôle {cid} : artefact inconnu {a!r}")

    # 5 · parité des propriétaires (owner) code ↔ spec.
    for cid in code_ids:
        if code_by_id[cid]["owner"] != spec_by_id.get(cid, {}).get("owner"):
            errors.append(f"contrôle {cid} : owner spec ≠ code")

    # 6 · aucun id de contrôle dupliqué dans le rapport.
    if len(report_ids) != len(set(report_ids)):
        errors.append("id de contrôle dupliqué dans le rapport")

    # 7 · totaux = somme des statuts, cohérents avec le nombre de contrôles.
    t = report["totals"]
    if t["pass"] + t["fail"] + t["a_confirmer"] != t["controls"]:
        errors.append("totals: pass+fail+a_confirmer != controls")
    if t["controls"] != len(report["controls"]):
        errors.append("totals.controls != nombre de contrôles")

    # 8 · totaux = somme des synthèses par dimension.
    for key in ("pass", "fail", "a_confirmer"):
        s = sum(d[key] for d in report["dimensions"])
        if s != t[key]:
            errors.append(f"somme dimension.{key} ({s}) != totals.{key} ({t[key]})")

    # 9 · verdict cohérent avec les totaux.
    expected = ("FAIL" if t["fail"] else
                "PASS_WITH_OPEN_ITEMS" if t["a_confirmer"] else "PASS")
    if report["verdict"] != expected:
        errors.append(f"verdict {report['verdict']!r} != attendu {expected!r}")

    # 10 · chaque contrôle A_CONFIRMER a un owner non nul et un open_item.
    oi_by_ctrl = {oi["control"]: oi for oi in report["open_items"]}
    for c in report["controls"]:
        if c["statut"] == controls.A_CONFIRMER:
            if not c["owner"]:
                errors.append(f"contrôle A_CONFIRMER {c['id']} sans owner")
            oi = oi_by_ctrl.get(c["id"])
            if oi is None:
                errors.append(f"contrôle A_CONFIRMER {c['id']} absent des open_items")
            elif oi["owner"] != c["owner"]:
                errors.append(f"open_item {c['id']} : owner incohérent")

    # 11 · tout open_item correspond à un contrôle A_CONFIRMER (pas d'orphelin).
    ac_ids = {c["id"] for c in report["controls"] if c["statut"] == controls.A_CONFIRMER}
    for oi in report["open_items"]:
        if oi["control"] not in ac_ids:
            errors.append(f"open_item orphelin : {oi['control']}")

    # 12 · statut de dimension cohérent avec ses compteurs.
    for d in report["dimensions"]:
        exp = ("FAIL" if d["fail"] else
               "PASS_WITH_OPEN_ITEMS" if d["a_confirmer"] else "PASS")
        if d["statut"] != exp:
            errors.append(f"dimension {d['id']} statut {d['statut']!r} != {exp!r}")
        if d["controls_total"] != d["pass"] + d["fail"] + d["a_confirmer"]:
            errors.append(f"dimension {d['id']} : controls_total incohérent")
        if d["controls_total"] < 1:
            errors.append(f"dimension {d['id']} : aucun contrôle")

    # 13 · les 5 dimensions sont présentes.
    if len(report["dimensions"]) != 5:
        errors.append(f"{len(report['dimensions'])} dimensions (attendu 5)")

    # 14 · AUCUN contrôle FAIL sur les livrables courants (cohérence structurelle).
    fails = [c["id"] for c in report["controls"] if c["statut"] == controls.FAIL]
    if fails:
        errors.append(f"contrôle(s) FAIL sur les livrables : {fails}")

    # 15 · déterminisme : re-générer donne un rapport identique.
    if builder.build(spec) != report:
        errors.append("build non déterministe (deux exécutions divergent)")

    return errors


def cmd_build(args) -> int:
    spec = _load(_SPEC_PATH)
    report = builder.build(spec)
    errors = _validate(spec, report)
    if errors:
        _eprint("ÉCHEC validation — build refusé :")
        for e in errors:
            _eprint("  -", e)
        return 1
    out = args.out or _DEFAULT_OUT
    os.makedirs(out, exist_ok=True)
    _write_json(os.path.join(out, "audit_report.json"), report)
    _write_json(os.path.join(out, "MANIFEST.json"), _manifest(spec, report))
    print(f"OK · audit 5D → {out}")
    print(f"   verdict={report['verdict']} · {report['totals']['controls']} contrôles "
          f"({report['totals']['pass']} PASS / {report['totals']['fail']} FAIL / "
          f"{report['totals']['a_confirmer']} À CONFIRMER)")
    return 0


def cmd_validate(args) -> int:
    spec = _load(_SPEC_PATH)
    report = builder.build(spec)
    errors = _validate(spec, report)
    if errors:
        _eprint("ÉCHEC validation :")
        for e in errors:
            _eprint("  -", e)
        return 1
    print(f"OK · {report['totals']['controls']} contrôles, 15 invariants — "
          f"verdict {report['verdict']}")
    return 0


def main(argv=None) -> int:
    p = argparse.ArgumentParser(description="Audit 5D de conformité (QA · Sprint 5).")
    sub = p.add_subparsers(dest="cmd", required=True)
    b = sub.add_parser("build", help="écrit audit_report.json + MANIFEST.json")
    b.add_argument("-o", "--out", help="dossier de sortie (défaut : ./out)")
    b.set_defaults(func=cmd_build)
    v = sub.add_parser("validate", help="valide schéma + 15 invariants")
    v.set_defaults(func=cmd_validate)
    args = p.parse_args(argv)
    return args.func(args)


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