#!/usr/bin/env python3
"""Generateur de Workspaces ERPNext v15 · 5 portails role (Sprint 4 · Frontend Console).

Livrable Frontend Console de la roadmap Sprint 4 (l.49 « 5 portails role :
Ventes/Construction/Achat/Compta/Direction »). Contrainte #1 « ERPNext natif =
priorite absolue » : un portail role, dans ERPNext v15, EST un `Workspace` (page
de landing du desk, visible selon les roles). On produit donc des fixtures
`Workspace` importables — aucun framework de dashboard externe.

Transforme (rbac_50_roles.json + portails_spec.json) en :
  - workspace.json -> 1 fixture `Workspace` par portail metier (shortcuts + links + roles)
  - MANIFEST.json  -> tracabilite (comptes, roles couverts, DocTypes custom, marque)

Sous-commandes :
  build    [-o OUT]   -> ecrit workspace.json / MANIFEST.json (refuse si invariant KO)
  validate [-o OUT]   -> (re)genere en memoire, valide vs workspace.schema.json + invariants

Sortie deterministe (tri stable, aucun horodatage) -> diffable + re-generable.
Anti-invention (#6) : chaque lien / raccourci vise un DocType present dans les
permissions_cibles du portail (droit prouve) ; couverture exhaustive ; flag custom
issu du contrat ; roles issus du contrat ; tokens de marque repris de CLAUDE.md.
Ce worker n'ecrit JAMAIS sur le VPS (#8) : hand-off a l'agent ERPNext Backend.
"""

from __future__ import annotations

import argparse
import json
import os
import sys

_HERE = os.path.dirname(os.path.abspath(__file__))
_FRONTEND = os.path.normpath(os.path.join(_HERE, ".."))          # frontend/
_DELIVERABLES = os.path.normpath(os.path.join(_FRONTEND, ".."))  # 05_deliverables_mvp/
_RBAC_DIR = os.path.join(_DELIVERABLES, "rbac")

sys.path.insert(0, _HERE)
# Reutilisation (workflow #5 : zero duplication) du validateur maison Publiciste.
sys.path.insert(0, os.path.join(_DELIVERABLES, "publiciste"))

from wslib 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")
_SPEC_PATH = os.path.join(_HERE, "portails_spec.json")
_SCHEMA_PATH = os.path.join(_HERE, "workspace.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), _load(_SPEC_PATH))


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

    contract = _load(_CONTRACT_PATH)
    spec = _load(_SPEC_PATH)
    workspaces = bundle["workspace"]
    manifest = bundle["manifest"]

    roles_map = builder.roles_by_portail(contract)
    dt_map = builder.doctypes_by_portail(contract)
    flags = builder.custom_flags(contract)
    portails_metier = set(contract.get("portails_business", []))
    spec_keys = [p["key"] for p in spec["portails"]]

    # Invariant 1 · exactement les 5 portails METIER du contrat (roadmap « 5 portails »),
    # la console technique `plateforme` etant explicitement exclue.
    if set(spec_keys) != portails_metier:
        errors.append(
            f"portails spec {sorted(set(spec_keys))} != portails_business contrat {sorted(portails_metier)}."
        )
    if len(workspaces) != len(portails_metier):
        errors.append(f"{len(workspaces)} Workspaces != {len(portails_metier)} portails metier.")

    spec_by_key = {p["key"]: p for p in spec["portails"]}
    for ws, key in zip(workspaces, spec_keys):
        p = spec_by_key[key]
        authorized = dt_map.get(key, set())
        carte_dts = [dt for carte in p["cartes"] for dt in carte["doctypes"]]

        # Invariant 2 · ANTI-INVENTION : tout DocType lie/raccourci est autorise
        # (present dans les permissions_cibles du portail).
        for dt in carte_dts:
            if dt not in authorized:
                errors.append(f"[{key}] carte vise DocType non autorise: {dt!r}.")
        for sc in p["raccourcis"]:
            if sc not in authorized:
                errors.append(f"[{key}] raccourci vise DocType non autorise: {sc!r}.")

        # Invariant 3 · COUVERTURE EXHAUSTIVE + sans doublon : chaque DocType
        # autorise apparait dans exactement une carte (aucun oubli silencieux).
        if len(carte_dts) != len(set(carte_dts)):
            errors.append(f"[{key}] un DocType apparait dans plusieurs cartes.")
        if set(carte_dts) != authorized:
            manquants = sorted(authorized - set(carte_dts))
            en_trop = sorted(set(carte_dts) - authorized)
            errors.append(f"[{key}] couverture != autorises (manquants={manquants}, en_trop={en_trop}).")

        # Invariant 4 · restriction de visibilite = TOUS les roles du portail, tries.
        ws_roles = [row["role"] for row in ws["roles"]]
        if ws_roles != sorted(ws_roles):
            errors.append(f"[{key}] Has Role non tries (determinisme).")
        if set(ws_roles) != set(roles_map.get(key, [])):
            errors.append(f"[{key}] roles du Workspace != roles du portail (contrat).")

        # Invariant 5 · table Links coherente : chaque Card Break annonce le bon
        # nombre de Link qui la suivent, et le total de Link == DocTypes couverts.
        nb_link_rows = 0
        i = 0
        rows = ws["links"]
        while i < len(rows):
            row = rows[i]
            if row["type"] != "Card Break":
                errors.append(f"[{key}] table links : attendu Card Break en position {i}.")
                break
            declared = row["link_count"]
            j = i + 1
            seen = 0
            while j < len(rows) and rows[j]["type"] == "Link":
                seen += 1
                nb_link_rows += 1
                j += 1
            if seen != declared:
                errors.append(f"[{key}] Card Break {row['label']!r}: link_count={declared} != {seen} liens reels.")
            i = j
        if nb_link_rows != len(authorized):
            errors.append(f"[{key}] {nb_link_rows} liens != {len(authorized)} DocTypes autorises.")

        # Invariant 6 · un raccourci par shortcut, couleur = accent du portail.
        sc_targets = [s["link_to"] for s in ws["shortcuts"]]
        if sc_targets != p["raccourcis"]:
            errors.append(f"[{key}] shortcuts != raccourcis spec (ordre/contenu).")
        for s in ws["shortcuts"]:
            if s["color"] != p["accent_desk"]:
                errors.append(f"[{key}] raccourci {s['link_to']!r}: couleur != accent portail.")

        # Invariant 7 · module owner laisse a None (a_confirmer, hand-off VPS #8).
        if ws["module"] is not None:
            errors.append(f"[{key}] Workspace.module doit rester None (a_confirmer).")

        # Invariant 8 · le champ `content` est un JSON de blocs coherent avec les
        # raccourcis + cartes (aucun bloc oriente vers un element inexistant).
        blocks = json.loads(ws["content"])
        headers = [b for b in blocks if b["type"] == "header"]
        sc_blocks = [b["data"]["shortcut_name"] for b in blocks if b["type"] == "shortcut"]
        card_blocks = [b["data"]["card_name"] for b in blocks if b["type"] == "card"]
        if len(headers) != 1 or headers[0]["data"]["text"] != p["label"]:
            errors.append(f"[{key}] content: header manquant/incoherent.")
        if sc_blocks != p["raccourcis"]:
            errors.append(f"[{key}] content: blocs shortcut != raccourcis.")
        if card_blocks != [c["titre"] for c in p["cartes"]]:
            errors.append(f"[{key}] content: blocs card != titres de cartes.")

    # Invariant 9 · flag custom du manifeste = flag du CONTRAT (source unique).
    for m in manifest["workspaces"]:
        expected_custom = sorted(
            dt for carte in spec_by_key[m["portail"]]["cartes"]
            for dt in carte["doctypes"] if flags.get(dt, False)
        )
        # dedoublonne en conservant l'ordre trie
        expected_custom = sorted(set(expected_custom))
        if m["doctypes_custom"] != expected_custom:
            errors.append(f"[{m['portail']}] doctypes_custom manifeste != contrat.")

    # Invariant 10 · comptes agreges du manifeste coherents.
    c = manifest["counts"]
    if c["workspaces"] != len(workspaces):
        errors.append("counts.workspaces incoherent.")
    if c["roles_couverts"] != sum(len(w["roles"]) for w in workspaces):
        errors.append("counts.roles_couverts incoherent.")

    # Invariant 11 · les tokens de marque proviennent de la spec (CLAUDE.md #4),
    # jamais fabriques : fond + accent presents et sources.
    brand = manifest["brand"]
    if brand.get("accent", {}).get("valeur") != "#f0b429":
        errors.append("brand.accent != #f0b429 (CLAUDE.md #4).")
    if brand.get("fond", {}).get("valeur") != "#0a0a12":
        errors.append("brand.fond != #0a0a12 (CLAUDE.md #4).")

    # Invariant 12 · determinisme : deux builds successifs identiques.
    again = _build_bundle()
    if json.dumps(again, sort_keys=True, ensure_ascii=False) != json.dumps(bundle, sort_keys=True, ensure_ascii=False):
        errors.append("build non deterministe (deux passes divergent).")

    return errors


def cmd_build(args: argparse.Namespace) -> int:
    bundle = _build_bundle()
    errors = _validate_bundle(bundle)
    if errors:
        _eprint("❌ Bundle invalide — generation refusee (anti-regression) :")
        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, "workspace.json"), bundle["workspace"])
    _write_json(os.path.join(out, "MANIFEST.json"), bundle["manifest"])

    c = bundle["manifest"]["counts"]
    print(f"✅ Workspaces generes dans {out}")
    print(
        f"   workspace.json : {c['workspaces']} portails · {c['roles_couverts']} roles "
        f"restreints · {c['liens_total']} liens · {c['raccourcis_total']} raccourcis"
    )
    for m in bundle["manifest"]["workspaces"]:
        cu = f" · {len(m['doctypes_custom'])} custom" if m["doctypes_custom"] else ""
        print(
            f"     - {m['workspace']} : {m['nb_cartes']} cartes / {m['nb_liens']} liens / "
            f"{m['nb_roles']} roles{cu}"
        )
    if bundle["manifest"]["doctypes_custom_a_creer"]:
        print(
            "   DocTypes custom a creer cote VPS (hand-off) : "
            + ", ".join(bundle["manifest"]["doctypes_custom_a_creer"])
        )
    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['workspaces']} Workspaces, {c['roles_couverts']} roles "
        f"restreints, schema + 12 invariants verts."
    )
    return 0


def main(argv: list[str] | None = None) -> int:
    p = argparse.ArgumentParser(
        description="Generateur de Workspaces ERPNext (5 portails role) depuis RBAC + spec."
    )
    sub = p.add_subparsers(dest="cmd", required=True)

    pb = sub.add_parser("build", help="genere workspace.json / MANIFEST.json")
    pb.add_argument("-o", "--out", default=_DEFAULT_OUT, help="dossier de sortie (defaut: ./out)")
    pb.set_defaults(func=cmd_build)

    pv = sub.add_parser("validate", help="valide le bundle (schema + invariants) sans ecrire")
    pv.add_argument("-o", "--out", default=_DEFAULT_OUT, help="ignore (compat)")
    pv.set_defaults(func=cmd_validate)

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


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