#!/usr/bin/env python3
"""Générateur du DocType porteur `OTO Dossier Vente` · Sprint 4 · CRM ERPNext.

Complète le hand-off du générateur workflow vente (`crm/workflow_vente/`) : le
Workflow `OTO Vente Pipeline` cible un `document_type` custom qui doit exister
AVANT son import. Ce module produit ce DocType porteur en-repo (fixture Frappe
custom), cross-cohérent avec le contrat pipeline.

Ce worker n'écrit JAMAIS sur le VPS (#8) : il émet les fichiers ; l'application
réelle (`bench migrate` / `import-fixtures`) reste côté agent ERPNext Backend.

Sous-commandes :
  build    [-o OUT]  → écrit doctype_oto_dossier_vente.json + MANIFEST.json
  validate           → (re)génère en mémoire, valide schéma + 12 invariants de
                       cross-cohérence workflow↔DocType ; sort en erreur sinon.

Sortie déterministe (tri stable, aucun horodatage) → diffable + re-générable.
Anti-invention (#6) : nom du DocType, champ d'état, valeurs de statut et rôles
proviennent des contrats (workflow + RBAC) ; aucun chiffre n'est peuplé.
"""

from __future__ import annotations

import argparse
import json
import os
import sys

_HERE = os.path.dirname(os.path.abspath(__file__))
_CRM = os.path.normpath(os.path.join(_HERE, ".."))               # crm/
_DELIVERABLES = os.path.normpath(os.path.join(_CRM, ".."))       # 05_deliverables_mvp/

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 dvlib import builder, frappe  # noqa: E402
from dvlib.builder import _UPDATE_FIELD  # noqa: E402
from workflow_vente.wflib.rbac import RoleResolver  # noqa: E402
from lib import validator as maison  # type: ignore  # noqa: E402

_SPEC_PATH = os.path.join(_HERE, "doctype_spec.json")
_WF_SPEC_PATH = os.path.join(_CRM, "workflow_vente", "workflow_vente_spec.json")
_SCHEMA_PATH = os.path.join(_HERE, "doctype.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 _build() -> tuple[dict, dict, RoleResolver]:
    spec = _load(_SPEC_PATH)
    wf_spec = _load(_WF_SPEC_PATH)
    resolver = RoleResolver.from_path()
    bundle = builder.build_bundle(spec, wf_spec, resolver)
    return bundle, wf_spec, resolver


def _select_options(field: dict) -> list[str]:
    """Options d'un Select Frappe : lignes non vides de `options`."""
    return [ln for ln in field.get("options", "").split("\n") if ln]


def _validate(bundle: dict, wf_spec: dict, resolver: RoleResolver) -> list[str]:
    """Schéma de sortie + 12 invariants de cross-cohérence workflow ↔ DocType."""
    schema = _load(_SCHEMA_PATH)
    errors = list(maison.validate(bundle, schema))

    dt = bundle["doctype"]
    m = bundle["manifest"]
    fields = {f["fieldname"]: f for f in dt["fields"]}

    # 1 · Le nom du DocType == document_type du workflow (source unique).
    if dt["name"] != wf_spec["document_type"]:
        errors.append(f"nom DocType {dt['name']!r} ≠ workflow.document_type {wf_spec['document_type']!r}")

    # 2 · `custom` cohérent avec document_type_custom du workflow.
    if bool(dt["custom"]) != bool(wf_spec.get("document_type_custom")):
        errors.append("drapeau `custom` incohérent avec workflow.document_type_custom")

    # 3 · Le champ d'état existe, est un Select, options == noms d'états.
    state_field = wf_spec["workflow_state_field"]
    state_names = builder._ordered_unique([s["state"] for s in wf_spec["states"]])
    fsf = fields.get(state_field)
    if fsf is None:
        errors.append(f"champ d'état {state_field!r} absent du DocType")
    else:
        if fsf["fieldtype"] != "Select":
            errors.append(f"champ d'état {state_field!r} doit être Select, trouvé {fsf['fieldtype']!r}")
        if _select_options(fsf) != state_names:
            errors.append(f"options de {state_field!r} ≠ noms d'états du workflow")
        if not fsf.get("read_only"):
            errors.append(f"champ d'état {state_field!r} doit être read_only (piloté par le moteur)")

    # 4 · Le champ de valeur machine existe, Select, options == update_value.
    pipeline_values = builder._ordered_unique([s["update_value"] for s in wf_spec["states"]])
    pvf = fields.get(_UPDATE_FIELD)
    if pvf is None:
        errors.append(f"champ de statut {_UPDATE_FIELD!r} absent du DocType")
    elif _select_options(pvf) != pipeline_values:
        errors.append(f"options de {_UPDATE_FIELD!r} ≠ update_value du workflow")

    # 5 · is_submittable DÉDUIT des doc_status (1/2 présents ⇒ soumissible).
    max_ds = max(s["doc_status"] for s in wf_spec["states"])
    expected_sub = 1 if max_ds >= "1" else 0
    if dt["is_submittable"] != expected_sub:
        errors.append(f"is_submittable={dt['is_submittable']} ≠ attendu {expected_sub} (doc_status max {max_ds})")

    # 6 · field_order == fieldnames, tous uniques.
    order = dt["field_order"]
    names = [f["fieldname"] for f in dt["fields"]]
    if order != names:
        errors.append("field_order ≠ ordre réel des champs")
    if len(names) != len(set(names)):
        errors.append("fieldname dupliqué dans le DocType")

    # 7 · Nommage : champ naming_series + autoname cohérents.
    if dt["autoname"] != "naming_series:":
        errors.append("autoname doit être 'naming_series:'")
    if "naming_series" not in fields:
        errors.append("champ `naming_series` absent alors que autoname='naming_series:'")

    # 8 · Tout rôle cité par le workflow est présent en permission (≥ read).
    perm_roles = {p["role"] for p in dt["permissions"]}
    wf_role_ids = {s["role_id"] for s in wf_spec["states"]} | {t["role_id"] for t in wf_spec["transitions"]}
    for rid in sorted(wf_role_ids):
        rn = resolver.erpnext_name(rid)
        if rn not in perm_roles:
            errors.append(f"rôle workflow {rid!r} ({rn}) absent des permissions du DocType")

    # 9 · Cohérence des capacités déduites (édition brouillon / submit / cancel).
    perm_by_role = {p["role"]: p for p in dt["permissions"]}
    docstatus_by_state = {s["state"]: s["doc_status"] for s in wf_spec["states"]}
    for s in wf_spec["states"]:
        p = perm_by_role.get(resolver.erpnext_name(s["role_id"]), {})
        if not p.get("write"):
            errors.append(f"rôle éditant l'état {s['state']!r} sans droit write")
        if s["doc_status"] == "0" and not p.get("create"):
            errors.append(f"rôle éditant l'état brouillon {s['state']!r} sans droit create")
    for t in wf_spec["transitions"]:
        p = perm_by_role.get(resolver.erpnext_name(t["role_id"]), {})
        nxt = docstatus_by_state[t["next_state"]]
        if nxt == "1" and not p.get("submit"):
            errors.append(f"transition {t['action']!r} vers état soumis sans droit submit")
        if nxt == "2" and not p.get("cancel"):
            errors.append(f"transition {t['action']!r} vers état annulé sans droit cancel")

    # 10 · Anti-invention (#6) : aucun champ Currency ne porte de valeur par défaut.
    for f in dt["fields"]:
        if f["fieldtype"] == "Currency" and "default" in f:
            errors.append(f"champ Currency {f['fieldname']!r} porte un défaut ({f['default']!r}) — chiffre inventé")

    # 11 · Tout champ Link cible un DocType listé « à confirmer VPS ».
    declared_links = set(m["doctypes_lies_a_confirmer"])
    for f in dt["fields"]:
        if f["fieldtype"] == "Link" and f.get("options") and f["options"] not in declared_links:
            errors.append(f"Link {f['fieldname']!r} → {f['options']!r} absent de doctypes_lies_a_confirmer")

    # 12 · Cohérence des comptes du manifeste.
    data_fields = [f for f in dt["fields"] if f["fieldtype"] not in frappe.LAYOUT_FIELDTYPES]
    if m["counts"]["fields"] != len(dt["fields"]):
        errors.append("counts.fields incohérent")
    if m["counts"]["data_fields"] != len(data_fields):
        errors.append("counts.data_fields incohérent")
    if m["counts"]["permissions"] != len(dt["permissions"]):
        errors.append("counts.permissions incohérent")
    if m["counts"]["pipeline_states"] != len(state_names):
        errors.append("counts.pipeline_states incohérent")

    return errors


def cmd_build(args: argparse.Namespace) -> int:
    bundle, wf_spec, resolver = _build()
    errors = _validate(bundle, wf_spec, resolver)
    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, "doctype_oto_dossier_vente.json"), bundle["doctype"])
    _write_json(os.path.join(out, "MANIFEST.json"), bundle["manifest"])

    m = bundle["manifest"]
    print(f"✅ Fixture DocType générée dans {out}")
    print(f"   doctype_oto_dossier_vente.json : {m['counts']['data_fields']} champs · "
          f"{m['counts']['permissions']} rôles · submittable={m['is_submittable']}")
    print(f"   ⚠ À créer/confirmer VPS avant import : module {m['module_a_confirmer']!r} "
          f"+ DocTypes liés {m['doctypes_lies_a_confirmer']}")
    return 0


def cmd_validate(args: argparse.Namespace) -> int:
    bundle, wf_spec, resolver = _build()
    errors = _validate(bundle, wf_spec, resolver)
    if errors:
        _eprint("❌ Validation KO :")
        for e in errors:
            _eprint(f"   - {e}")
        return 1
    m = bundle["manifest"]
    print(f"✅ Validation OK — DocType {m['doctype_name']!r} : {m['counts']['data_fields']} champs, "
          f"{m['counts']['permissions']} rôles, schéma + 12 invariants verts.")
    return 0


def main(argv: list[str] | None = None) -> int:
    p = argparse.ArgumentParser(description="Générateur du DocType porteur OTO Dossier Vente.")
    sub = p.add_subparsers(dest="cmd", required=True)

    pb = sub.add_parser("build", help="génère doctype_oto_dossier_vente.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 + 12 invariants) sans écrire")
    pv.set_defaults(func=cmd_validate)

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


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