#!/usr/bin/env python3
"""Générateur de `Role Profile` par portail · RBAC 50 rôles (Sprint 2).

Troisième et dernier maillon RBAC en-repo, complément de `../fixtures_gen`
(Role + Custom DocPerm) et `../userperm_gen` (row-level) : il produit les
**bundles assignables** — un `Role Profile` ERPNext v15 natif par portail — qui
permettent à l'agent ERPNext d'attribuer à un utilisateur, en un seul geste,
tous les rôles de SON portail (champ `User.role_profile_name`).

Cible de portage VPS : agent ERPNext Backend (SPEC §7). Ce worker n'écrit JAMAIS
sur le VPS — il produit en-repo un bundle de fixtures que l'agent dépose dans
`fixtures/` de l'app OTO puis applique via `bench migrate`.

Transforme `rbac_50_roles.json` (contrat validé par `rbac.schema.json`) en :
  - role_profile.json → 1 fixture `Role Profile` par portail (+ ses `Has Role`)
  - MANIFEST.json      → traçabilité : comptes, portails métier vs technique,
                         mapping portail → profil

Sous-commandes :
  build    [-o OUT]   → écrit role_profile.json / MANIFEST.json
  validate [-o OUT]   → (re)génère en mémoire, valide vs roleprofile.schema.json
                        + invariants — sort en erreur si un invariant casse.

Sortie déterministe (tri stable, aucun horodatage) → diffable + re-générable.
Anti-invention (#6) : 100 % des rôles proviennent du contrat ; le nom du profil
est une convention déterministe dérivée de la clé `portail` (aucun libellé
métier fabriqué) ; aucun DocType custom (que du natif v15).
"""

from __future__ import annotations

import argparse
import json
import os
import sys

_HERE = os.path.dirname(os.path.abspath(__file__))
_RBAC_DIR = os.path.normpath(os.path.join(_HERE, ".."))          # rbac/
_DELIVERABLES = os.path.normpath(os.path.join(_RBAC_DIR, ".."))  # 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 profilelib import builder, frappe  # noqa: E402
from lib import validator as maison  # type: ignore  # noqa: E402

_CONTRACT_PATH = os.path.join(_RBAC_DIR, "rbac_50_roles.json")
_SCHEMA_PATH = os.path.join(_HERE, "roleprofile.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_bundle() -> dict:
    return builder.build_bundle(_load(_CONTRACT_PATH))


def _validate_bundle(bundle: dict) -> list[str]:
    """Valide le bundle contre roleprofile.schema.json + invariants RBAC."""
    schema = _load(_SCHEMA_PATH)
    errors = list(maison.validate(bundle, schema))

    manifest = bundle["manifest"]
    profiles = bundle["role_profile"]
    contract = _load(_CONTRACT_PATH)
    contract_roles = {r["erpnext_role_name"] for r in contract["roles"]}
    portail_of_role = {r["erpnext_role_name"]: r["portail"] for r in contract["roles"]}
    portails_metier = set(contract.get("portails_business", []))

    # Invariant 1 · cible non-négociable « RBAC 50 rôles » : couverture exhaustive.
    if manifest["cible_rbac_roles"] != len(contract_roles):
        errors.append(
            f"cible_rbac_roles={manifest['cible_rbac_roles']} ≠ rôles contrat={len(contract_roles)}"
        )

    # Invariant 2 · couverture BIJECTIVE : chaque rôle du contrat dans EXACTEMENT
    # un profil, aucun rôle inventé, aucun manquant.
    assigned: list[str] = []
    for prof in profiles:
        for row in prof["roles"]:
            assigned.append(row["role"])
    if len(assigned) != len(set(assigned)):
        errors.append("Un rôle apparaît dans plusieurs Role Profile (couverture non bijective).")
    if set(assigned) != contract_roles:
        missing = sorted(contract_roles - set(assigned))
        extra = sorted(set(assigned) - contract_roles)
        errors.append(f"Couverture ≠ contrat (manquants={missing}, en trop={extra}).")

    # Invariant 3 · un profil par portail distinct du contrat, nom déterministe.
    contract_portails = {r["portail"] for r in contract["roles"]}
    seen_names: set[str] = set()
    for prof in profiles:
        name = prof["name"]
        if name != prof["role_profile"]:
            errors.append(f"{name}: name ≠ role_profile.")
        if name in seen_names:
            errors.append(f"Role Profile dupliqué : {name}.")
        seen_names.add(name)
        # Chaque rôle du profil appartient bien au même portail que le profil.
        expected = {n: p for n, p in portail_of_role.items()}
        portails_in_prof = {expected[row["role"]] for row in prof["roles"]}
        if len(portails_in_prof) != 1:
            errors.append(f"{name}: mélange de portails {sorted(portails_in_prof)}.")
        else:
            portail = portails_in_prof.pop()
            if name != frappe.role_profile_name(portail):
                errors.append(f"{name}: nom ≠ convention pour portail {portail!r}.")

    # Invariant 4 · le manifeste liste EXACTEMENT les portails du contrat.
    meta_portails = {m["portail"] for m in manifest["profiles"]}
    if meta_portails != contract_portails:
        errors.append(f"manifest.profiles portails {sorted(meta_portails)} ≠ contrat {sorted(contract_portails)}.")

    # Invariant 5 · métadonnées fidèles (flag métier + comptes par profil).
    nb_by_portail: dict[str, int] = {}
    for prof in profiles:
        p = portail_of_role[prof["roles"][0]["role"]]
        nb_by_portail[p] = len(prof["roles"])
    for m in manifest["profiles"]:
        p = m["portail"]
        if m["metier"] != (p in portails_metier):
            errors.append(f"profil {p!r}: flag metier incohérent avec portails_business.")
        if m["role_profile"] != frappe.role_profile_name(p):
            errors.append(f"profil {p!r}: role_profile ≠ convention de nommage.")
        if m["nb_roles"] != nb_by_portail.get(p):
            errors.append(f"profil {p!r}: nb_roles {m['nb_roles']} ≠ réel {nb_by_portail.get(p)}.")

    # Invariant 6 · cohérence des comptages agrégés du manifeste.
    counts = manifest["counts"]
    if counts["role_profiles"] != len(profiles):
        errors.append("counts.role_profiles incohérent.")
    if counts["roles_couverts"] != len(assigned):
        errors.append("counts.roles_couverts incohérent avec les profils.")
    nb_metier = sum(1 for m in manifest["profiles"] if m["metier"])
    if counts["portails_metier"] != nb_metier:
        errors.append("counts.portails_metier incohérent.")
    if counts["portails_techniques"] != len(profiles) - nb_metier:
        errors.append("counts.portails_techniques incohérent.")

    # Invariant 7 · déterminisme : profils triés par portail, rôles triés par nom.
    if [m["portail"] for m in manifest["profiles"]] != sorted(meta_portails):
        errors.append("manifest.profiles non trié par portail (déterminisme).")
    for prof in profiles:
        role_seq = [row["role"] for row in prof["roles"]]
        if role_seq != sorted(role_seq):
            errors.append(f"{prof['name']}: Has Role non triés (déterminisme).")

    return errors


def cmd_build(args: argparse.Namespace) -> int:
    bundle = _build_bundle()
    errors = _validate_bundle(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, "role_profile.json"), bundle["role_profile"])
    _write_json(os.path.join(out, "MANIFEST.json"), bundle["manifest"])

    m = bundle["manifest"]
    c = m["counts"]
    print(f"✅ Role Profile générés dans {out}")
    print(
        f"   role_profile.json : {c['role_profiles']} profils "
        f"({c['portails_metier']} métier + {c['portails_techniques']} technique) "
        f"· {c['roles_couverts']}/50 rôles couverts"
    )
    for meta in m["profiles"]:
        tag = "métier" if meta["metier"] else "technique"
        print(f"     - {meta['role_profile']} : {meta['nb_roles']} rôles ({tag})")
    return 0


def cmd_validate(args: argparse.Namespace) -> int:
    bundle = _build_bundle()
    errors = _validate_bundle(bundle)
    if errors:
        _eprint("❌ Validation KO :")
        for e in errors:
            _eprint(f"   - {e}")
        return 1
    c = bundle["manifest"]["counts"]
    print(
        f"✅ Validation OK — {c['role_profiles']} Role Profile, "
        f"{c['roles_couverts']}/50 rôles couverts (bijectif), "
        f"schéma + invariants verts."
    )
    return 0


def main(argv: list[str] | None = None) -> int:
    p = argparse.ArgumentParser(
        description="Générateur de Role Profile (bundles de rôles) depuis le contrat RBAC."
    )
    sub = p.add_subparsers(dest="cmd", required=True)

    pb = sub.add_parser("build", help="génère role_profile.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.add_argument("-o", "--out", default=_DEFAULT_OUT, help="ignoré (compat)")
    pv.set_defaults(func=cmd_validate)

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


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