#!/usr/bin/env python3
"""Générateur de fixtures ERPNext Workflow · pipeline vente (Sprint 4 · CRM natif).

Transforme le contrat pipeline `workflow_vente_spec.json` (lead → visite → devis
→ réservation → contrat → CONFOTUR) en **fixtures Frappe/ERPNext v15 natives** :
  - workflow.json               → DocType `Workflow` (states[] + transitions[])
  - workflow_state.json         → DocType maître `Workflow State`
  - workflow_action_master.json → DocType maître `Workflow Action Master`
  - MANIFEST.json               → traçabilité + DocType porteur `custom` à confirmer VPS

Les rôles qui gardent les états/transitions sont **résolus depuis le contrat
RBAC** `rbac_50_roles.json` (réutilisation · zéro duplication · #6) : le pipeline
ne cite qu'un `id` de rôle, jamais un nom Frappe en dur.

Ce worker n'écrit JAMAIS sur le VPS (contrainte #8) : l'application réelle
(`bench --site frontend migrate` / `import-fixtures`) reste côté serveur, faite
par l'agent ERPNext Backend.

Sous-commandes :
  build    [-o OUT]  → écrit les 4 fichiers de fixtures
  validate           → (re)génère en mémoire, valide schéma + invariants métier
                       (graphe, monotonie doc_status, séparation des pouvoirs) ;
                       sort en erreur si un invariant casse (anti-régression).

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__))
_DELIVERABLES = os.path.normpath(os.path.join(_HERE, "..", ".."))

sys.path.insert(0, _HERE)
# Réutilisation (workflow #5 : zéro duplication) du validateur maison Publiciste.
sys.path.insert(0, os.path.join(_DELIVERABLES, "publiciste"))

from wflib import builder, erpnext  # noqa: E402
from wflib.rbac import RoleResolver  # noqa: E402
from lib import validator as maison  # type: ignore  # noqa: E402

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

# doc_status atteignable en une transition (monotonie native Frappe) :
#   0→0 (mise à jour), 0→1 (soumission), 1→1 (mise à jour), 1→2 (annulation).
_ALLOWED_DOCSTATUS_STEPS = {("0", "0"), ("0", "1"), ("1", "1"), ("1", "2")}


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() -> tuple[dict, dict]:
    spec = _load(_SPEC_PATH)
    resolver = RoleResolver.from_path()
    return spec, builder.build_bundle(spec, resolver)


def _validate_bundle(spec: dict, bundle: dict) -> list[str]:
    """Schéma de sortie + invariants métier du graphe de workflow."""
    schema = _load(_SCHEMA_PATH)
    errors = list(maison.validate(bundle, schema))

    wf = bundle["workflow"]
    states = wf["states"]
    transitions = wf["transitions"]
    manifest = bundle["manifest"]

    state_names = [s["state"] for s in states]
    state_set = set(state_names)
    docstatus_of = {s["state"]: s["doc_status"] for s in states}

    # Invariant 1 · unicité des noms d'état.
    if len(state_names) != len(state_set):
        errors.append("Nom d'état dupliqué dans la table `states`.")

    # Invariant 2 · toute transition référence des états déclarés.
    for t in transitions:
        if t["state"] not in state_set:
            errors.append(f"Transition depuis un état inconnu : {t['state']!r}")
        if t["next_state"] not in state_set:
            errors.append(f"Transition vers un état inconnu : {t['next_state']!r}")

    # Invariant 3 · monotonie doc_status (natif Frappe, pas de 0→2 ni décroissant).
    for t in transitions:
        pair = (docstatus_of.get(t["state"]), docstatus_of.get(t["next_state"]))
        if None in pair:
            continue  # déjà signalé par l'invariant 2
        if pair not in _ALLOWED_DOCSTATUS_STEPS:
            errors.append(
                f"Saut doc_status illégal {pair[0]}→{pair[1]} "
                f"({t['state']} --{t['action']}--> {t['next_state']})."
            )

    # Invariant 4 · unicité (état, action) — Frappe exige une action déterministe.
    keys = [(t["state"], t["action"]) for t in transitions]
    if len(keys) != len(set(keys)):
        errors.append("Couple (état, action) dupliqué dans les transitions.")

    # Invariant 5 · atteignabilité depuis l'état initial (première ligne = Lead).
    initial = state_names[0]
    adj: dict[str, list[str]] = {}
    for t in transitions:
        adj.setdefault(t["state"], []).append(t["next_state"])
    seen = {initial}
    stack = [initial]
    while stack:
        cur = stack.pop()
        for nxt in adj.get(cur, []):
            if nxt not in seen:
                seen.add(nxt)
                stack.append(nxt)
    unreachable = state_set - seen
    if unreachable:
        errors.append(
            f"État(s) inatteignable(s) depuis {initial!r} : {sorted(unreachable)}"
        )

    # Invariant 6 · au moins un état terminal de succès (soumis, sans sortie) et
    # les états terminaux annoncés au contrat n'ont effectivement pas de sortie.
    outgoing = {t["state"] for t in transitions}
    terminal_success = [
        s["state"] for s in states
        if s["doc_status"] == erpnext.DOCSTATUS_SUBMITTED and s["state"] not in outgoing
    ]
    if not terminal_success:
        errors.append("Aucun état terminal de succès (soumis + sans transition sortante).")
    for st in spec["states"]:
        if st.get("is_terminal") and st["state"] in outgoing:
            errors.append(
                f"État {st['state']!r} déclaré terminal mais porte une transition sortante."
            )

    # Invariant 7 · séparation des pouvoirs (#6) : toute transition marquée
    # `separation_of_duties` DOIT interdire l'auto-approbation.
    sep_actions = {
        (t["state"], t["action"])
        for t in spec["transitions"]
        if t.get("separation_of_duties")
    }
    for t in transitions:
        if (t["state"], t["action"]) in sep_actions and t["allow_self_approval"] != 0:
            errors.append(
                f"Séparation des pouvoirs violée : {t['state']} --{t['action']}--> "
                f"{t['next_state']} autorise l'auto-approbation."
            )

    # Invariant 8 · les maîtres couvrent exactement les états/actions du graphe.
    master_states = {m["name"] for m in bundle["workflow_state"]}
    if master_states != state_set:
        errors.append("Les maîtres `Workflow State` ne couvrent pas exactement les états.")
    master_actions = {m["name"] for m in bundle["workflow_action_master"]}
    if master_actions != {t["action"] for t in transitions}:
        errors.append("Les maîtres `Workflow Action Master` ne couvrent pas exactement les actions.")

    # Invariant 9 · cohérence des comptes du manifeste.
    c = manifest["counts"]
    if c["states"] != len(states) or c["transitions"] != len(transitions):
        errors.append("counts (états/transitions) incohérents avec le workflow.")

    return errors


def cmd_build(args: argparse.Namespace) -> int:
    spec, bundle = _build_bundle()
    errors = _validate_bundle(spec, bundle)
    if errors:
        _eprint("❌ Bundle invalide — génération refusée (anti-régression) :")
        for e in errors:
            _eprint(f"   - {e}")
        return 1

    out = os.path.abspath(args.out)
    os.makedirs(out, exist_ok=True)
    _write_json(os.path.join(out, "workflow.json"), [bundle["workflow"]])
    _write_json(os.path.join(out, "workflow_state.json"), bundle["workflow_state"])
    _write_json(
        os.path.join(out, "workflow_action_master.json"), bundle["workflow_action_master"]
    )
    _write_json(os.path.join(out, "MANIFEST.json"), bundle["manifest"])

    m = bundle["manifest"]
    print(f"✅ Fixtures Workflow générées dans {out}")
    print(f"   workflow.json               : {m['workflow_name']} · {m['counts']['states']} états / {m['counts']['transitions']} transitions")
    print(f"   workflow_state.json         : {m['counts']['workflow_state_masters']} maîtres")
    print(f"   workflow_action_master.json : {m['counts']['workflow_action_masters']} maîtres")
    if m["custom_doctypes_a_confirmer"]:
        print(
            "   ⚠ DocType porteur `custom` à créer + confirmer VPS avant import : "
            + ", ".join(m["custom_doctypes_a_confirmer"])
        )
    return 0


def cmd_validate(args: argparse.Namespace) -> int:
    spec, bundle = _build_bundle()
    errors = _validate_bundle(spec, bundle)
    if errors:
        _eprint("❌ Validation KO :")
        for e in errors:
            _eprint(f"   - {e}")
        return 1
    m = bundle["manifest"]
    print(
        f"✅ Validation OK — {m['counts']['states']} états, "
        f"{m['counts']['transitions']} transitions, schéma + 9 invariants verts."
    )
    return 0


def main(argv: list[str] | None = None) -> int:
    p = argparse.ArgumentParser(
        description="Générateur de fixtures ERPNext Workflow depuis le contrat pipeline vente."
    )
    sub = p.add_subparsers(dest="cmd", required=True)

    pb = sub.add_parser("build", help="génère workflow.json / workflow_state.json / workflow_action_master.json / MANIFEST.json")
    pb.add_argument("-o", "--out", default=_DEFAULT_OUT, help="dossier de sortie (défaut: ./out)")
    pb.set_defaults(func=cmd_build)

    pv = sub.add_parser("validate", help="valide le bundle (schéma + invariants) sans écrire")
    pv.set_defaults(func=cmd_validate)

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


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