"""Tests du generateur de Workspaces (stdlib `unittest`, zero pip).

Reutilise le validateur maison Publiciste (workflow #5 · pas de duplication) et,
s'il est installe, l'oracle `jsonschema` en plus. Invariants couverts :
  1. Le bundle valide contre workspace.schema.json (maison + oracle).
  2. Exactement les 5 portails METIER du contrat (plateforme exclue), ordre spec.
  3. ANTI-INVENTION (#6) : tout lien/raccourci vise un DocType autorise (permissions_cibles).
  4. Couverture EXHAUSTIVE + sans doublon des DocTypes autorises par les cartes.
  5. Restriction de visibilite = tous les roles du portail (contrat), tries.
  6. Table Links coherente (Card Break -> link_count == liens reels).
  7. Raccourcis : couleur = accent, ordre = spec ; compteurs desk (aucun chiffre stocke).
  8. Workspace.module reste None (a_confirmer, hand-off VPS #8).
  9. `content` (JSON de blocs) coherent avec raccourcis + cartes.
 10. Flag `custom` issu du CONTRAT (source unique).
 11. Tokens de marque = CLAUDE.md #4 (jamais fabriques).
 12. Determinisme : deux builds bit-a-bit identiques.
 13. NEGATIFS : les invariants mordent (invention, oubli de couverture, mauvais portail).
"""

import copy
import importlib.util
import json
import os
import sys
import tempfile
import unittest

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

sys.path.insert(0, _GEN)
sys.path.insert(0, os.path.join(_DELIVERABLES, "publiciste"))

from wslib import builder, frappe  # noqa: E402
from lib import validator as maison  # noqa: E402

_CONTRACT = os.path.join(_RBAC, "rbac_50_roles.json")
_SPEC = os.path.join(_GEN, "portails_spec.json")
_SCHEMA = os.path.join(_GEN, "workspace.schema.json")


def _load(path):
    with open(path, encoding="utf-8") as fh:
        return json.load(fh)


def _load_cli():
    spec = importlib.util.spec_from_file_location(
        "workspaces_gen", os.path.join(_GEN, "workspaces_gen.py")
    )
    mod = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(mod)
    return mod


class WorkspacesGenTest(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        cls.contract = _load(_CONTRACT)
        cls.spec = _load(_SPEC)
        cls.schema = _load(_SCHEMA)
        cls.bundle = builder.build_bundle(cls.contract, cls.spec)
        cls.workspaces = cls.bundle["workspace"]
        cls.dt_map = builder.doctypes_by_portail(cls.contract)
        cls.roles_map = builder.roles_by_portail(cls.contract)
        cls.flags = builder.custom_flags(cls.contract)
        cls.cli = _load_cli()

    # 1 --------------------------------------------------------------------
    def test_valide_schema_maison(self):
        self.assertEqual(maison.validate(self.bundle, self.schema), [])

    def test_valide_schema_oracle(self):
        try:
            import jsonschema
        except ImportError:
            self.skipTest("jsonschema absent (oracle facultatif)")
        jsonschema.validate(self.bundle, self.schema)

    def test_validate_bundle_reel_ok(self):
        # schema + 12 invariants du CLI, sur le bundle reel (disque).
        self.assertEqual(self.cli._validate_bundle(self.bundle), [])

    # 2 --------------------------------------------------------------------
    def test_cinq_portails_metier_ordonnes(self):
        keys = [w["label"] for w in self.workspaces]
        self.assertEqual(len(keys), 5)
        spec_keys = [p["key"] for p in self.spec["portails"]]
        self.assertEqual(set(spec_keys), set(self.contract["portails_business"]))
        self.assertNotIn("plateforme", spec_keys)  # console technique exclue

    # 3 --------------------------------------------------------------------
    def test_anti_invention_liens_autorises(self):
        for p, w in zip(self.spec["portails"], self.workspaces):
            authorized = self.dt_map[p["key"]]
            for row in w["links"]:
                if row["type"] == "Link":
                    self.assertIn(row["link_to"], authorized)
            for s in w["shortcuts"]:
                self.assertIn(s["link_to"], authorized)

    # 4 --------------------------------------------------------------------
    def test_couverture_exhaustive(self):
        for p in self.spec["portails"]:
            dts = [dt for c in p["cartes"] for dt in c["doctypes"]]
            self.assertEqual(len(dts), len(set(dts)), f"{p['key']}: doublon")
            self.assertEqual(set(dts), self.dt_map[p["key"]], f"{p['key']}: couverture")

    # 5 --------------------------------------------------------------------
    def test_roles_restriction(self):
        total = 0
        for p, w in zip(self.spec["portails"], self.workspaces):
            roles = [r["role"] for r in w["roles"]]
            self.assertEqual(roles, sorted(roles))
            self.assertEqual(set(roles), set(self.roles_map[p["key"]]))
            self.assertTrue(all(r["parenttype"] == "Workspace" for r in w["roles"]))
            total += len(roles)
        self.assertEqual(total, self.bundle["manifest"]["counts"]["roles_couverts"])

    # 6 --------------------------------------------------------------------
    def test_links_card_break_coherent(self):
        for w in self.workspaces:
            rows = w["links"]
            self.assertEqual(rows[0]["type"], "Card Break")
            i = 0
            while i < len(rows):
                self.assertEqual(rows[i]["type"], "Card Break")
                declared = rows[i]["link_count"]
                j = i + 1
                seen = 0
                while j < len(rows) and rows[j]["type"] == "Link":
                    seen += 1
                    j += 1
                self.assertEqual(seen, declared)
                i = j

    # 7 --------------------------------------------------------------------
    def test_shortcuts_accent_et_ordre(self):
        for p, w in zip(self.spec["portails"], self.workspaces):
            self.assertEqual([s["link_to"] for s in w["shortcuts"]], p["raccourcis"])
            for s in w["shortcuts"]:
                self.assertEqual(s["color"], p["accent_desk"])
                self.assertEqual(s["type"], "DocType")
                self.assertEqual(s["doc_view"], "List")
                # Aucun chiffre stocke (compteur calcule LIVE par le desk).
                self.assertNotIn("stats_filter", s)

    # 8 --------------------------------------------------------------------
    def test_module_none(self):
        for w in self.workspaces:
            self.assertIsNone(w["module"])
            self.assertEqual(w["public"], 1)

    # 9 --------------------------------------------------------------------
    def test_content_coherent(self):
        for p, w in zip(self.spec["portails"], self.workspaces):
            blocks = json.loads(w["content"])
            headers = [b for b in blocks if b["type"] == "header"]
            self.assertEqual(len(headers), 1)
            self.assertEqual(headers[0]["data"]["text"], p["label"])
            self.assertEqual(
                [b["data"]["shortcut_name"] for b in blocks if b["type"] == "shortcut"],
                p["raccourcis"],
            )
            self.assertEqual(
                [b["data"]["card_name"] for b in blocks if b["type"] == "card"],
                [c["titre"] for c in p["cartes"]],
            )

    # 10 -------------------------------------------------------------------
    def test_flag_custom_du_contrat(self):
        for m in self.bundle["manifest"]["workspaces"]:
            p = next(x for x in self.spec["portails"] if x["key"] == m["portail"])
            expected = sorted({
                dt for c in p["cartes"] for dt in c["doctypes"] if self.flags.get(dt)
            })
            self.assertEqual(m["doctypes_custom"], expected)
        self.assertEqual(
            self.bundle["manifest"]["doctypes_custom_a_creer"],
            sorted({"CONFOTUR Application", "Faisabilité", "Publiciste Log"}),
        )

    # 11 -------------------------------------------------------------------
    def test_marque_claude_md(self):
        brand = self.bundle["manifest"]["brand"]
        self.assertEqual(brand["accent"]["valeur"], "#f0b429")
        self.assertEqual(brand["fond"]["valeur"], "#0a0a12")
        self.assertTrue(brand["accent"]["source"])

    # 12 -------------------------------------------------------------------
    def test_determinisme(self):
        b1 = builder.build_bundle(_load(_CONTRACT), _load(_SPEC))
        b2 = builder.build_bundle(_load(_CONTRACT), _load(_SPEC))
        self.assertEqual(json.dumps(b1, sort_keys=True), json.dumps(b2, sort_keys=True))

    # 13 · NEGATIFS ---------------------------------------------------------
    def _validate_with_spec(self, mutated_spec):
        """Ecrit une spec mutee dans un tmp, y pointe le CLI, revalide."""
        with tempfile.TemporaryDirectory() as td:
            path = os.path.join(td, "spec.json")
            with open(path, "w", encoding="utf-8") as fh:
                json.dump(mutated_spec, fh, ensure_ascii=False)
            orig = self.cli._SPEC_PATH
            self.cli._SPEC_PATH = path
            try:
                bundle = self.cli._build_bundle()
                return self.cli._validate_bundle(bundle)
            finally:
                self.cli._SPEC_PATH = orig

    def test_negatif_doctype_invente(self):
        bad = copy.deepcopy(self.spec)
        bad["portails"][0]["cartes"][0]["doctypes"].append("DocType Bidon Invente")
        errs = self._validate_with_spec(bad)
        self.assertTrue(any("non autorise" in e for e in errs), errs)

    def test_negatif_couverture_incomplete(self):
        bad = copy.deepcopy(self.spec)
        # retire un DocType d'une carte -> couverture non exhaustive.
        bad["portails"][0]["cartes"][0]["doctypes"].pop()
        errs = self._validate_with_spec(bad)
        self.assertTrue(any("couverture" in e for e in errs), errs)

    def test_negatif_mauvais_ensemble_portails(self):
        bad = copy.deepcopy(self.spec)
        bad["portails"] = bad["portails"][:4]  # 4 portails != 5 metier
        errs = self._validate_with_spec(bad)
        self.assertTrue(any("portails_business" in e or "portails metier" in e for e in errs), errs)

    def test_negatif_raccourci_hors_permissions(self):
        bad = copy.deepcopy(self.spec)
        bad["portails"][1]["raccourcis"].append("Sales Invoice")  # non autorise en construction
        errs = self._validate_with_spec(bad)
        self.assertTrue(any("raccourci vise DocType non autorise" in e for e in errs), errs)

    # Garde-fous frappe.py --------------------------------------------------
    def test_content_deterministe_frappe(self):
        c1 = frappe.build_content("x", "X", ["A"], [{"titre": "T", "doctypes": ["A"]}])
        c2 = frappe.build_content("x", "X", ["A"], [{"titre": "T", "doctypes": ["A"]}])
        self.assertEqual(c1, c2)
        self.assertTrue(json.loads(c1))  # JSON valide


if __name__ == "__main__":
    unittest.main(verbosity=2)
