"""Tests du générateur de fixtures ERPNext (stdlib `unittest`, zéro pip).

Réutilise le validateur maison Publiciste (workflow #5 · pas de duplication) et,
s'il est installé, l'oracle `jsonschema` en plus. Invariants couverts :
  1. Le bundle valide contre fixtures.schema.json (maison + oracle).
  2. Exactement 50 fixtures Role, noms Frappe uniques préfixés « OTO ».
  3. Chaque DocPerm ne lève que des flags déclarés dans le contrat ; les verbes
     non pilotés (select/amend/import) restent à 0 (défaut sûr).
  4. `if_owner` ⇔ scope_donnees == "own" (fidélité au contrat).
  5. Séparation des pouvoirs : set_user_permissions réservé au RBAC Admin.
  6. Round-trip : chaque DocPerm renvoie vers une permission réelle du contrat
     (aucune permission inventée, aucune perdue).
  7. Déterminisme : deux builds successifs sont bit-à-bit identiques.
  8. Les DocTypes `custom` du contrat sont tous signalés dans le manifeste.
"""

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

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

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

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

_CONTRACT = os.path.join(_RBAC, "rbac_50_roles.json")
_SCHEMA = os.path.join(_GEN, "fixtures.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(
        "rbac_fixtures_gen", os.path.join(_GEN, "rbac_fixtures_gen.py")
    )
    mod = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(mod)
    return mod


class FixturesGenTest(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        cls.contract = _load(_CONTRACT)
        cls.schema = _load(_SCHEMA)
        cls.bundle = builder.build_bundle(cls.contract)

    # 1 · conformité au schéma (validateur maison)
    def test_bundle_valide_schema_maison(self):
        errors = maison.validate(self.bundle, self.schema)
        self.assertEqual(errors, [], f"Erreurs de schéma (maison) : {errors}")

    # 1bis · oracle jsonschema si disponible
    def test_bundle_valide_oracle_jsonschema(self):
        try:
            import jsonschema  # noqa: PLC0415
        except ImportError:
            self.skipTest("jsonschema non installé — oracle ignoré")
        jsonschema.validate(self.bundle, self.schema)

    # 2 · exactement 50 rôles, noms uniques préfixés OTO
    def test_50_roles_uniques(self):
        roles = self.bundle["role"]
        self.assertEqual(len(roles), 50)
        names = [r["role_name"] for r in roles]
        self.assertEqual(len(names), len(set(names)), "Noms de rôle dupliqués")
        for r in roles:
            self.assertTrue(r["role_name"].startswith("OTO "))
            self.assertEqual(r["name"], r["role_name"])
            self.assertEqual(r["desk_access"], 1)

    # 3 · verbes non pilotés toujours à 0 ; flags levés ⊆ RBAC_ACTIONS
    def test_flags_non_pilotes_a_zero(self):
        for d in self.bundle["custom_docperm"]:
            for flag in frappe.NON_PILOTES:
                self.assertEqual(d[flag], 0, f"{flag} levé indûment sur {d['parent']}")
            leves = {f for f in frappe.DOCPERM_FLAGS if d[f] == 1}
            self.assertTrue(
                leves <= frappe.RBAC_ACTIONS,
                f"Flag hors contrat levé : {leves - frappe.RBAC_ACTIONS}",
            )

    # 4 · if_owner ⇔ scope "own"
    def test_if_owner_reflete_scope_own(self):
        scope_by_role = {r["erpnext_role_name"]: r["scope_donnees"] for r in self.contract["roles"]}
        for d in self.bundle["custom_docperm"]:
            expected = 1 if scope_by_role[d["role"]] == "own" else 0
            self.assertEqual(d["if_owner"], expected, f"if_owner incohérent pour {d['role']}")

    # 5 · séparation des pouvoirs
    def test_set_user_permissions_reserve_admin(self):
        admin = {
            r["erpnext_role_name"]
            for r in self.contract["roles"]
            if r.get("famille") == "plateforme" and r.get("scope_donnees") == "groupe"
        }
        for d in self.bundle["custom_docperm"]:
            if d["set_user_permissions"] == 1:
                self.assertIn(d["role"], admin, f"Élévation de privilège : {d['role']}")

    # 6 · round-trip : chaque DocPerm ↔ une permission réelle du contrat
    def test_roundtrip_fidele_au_contrat(self):
        # (role, doctype) → set(actions) attendu depuis le contrat
        expected = {}
        for r in self.contract["roles"]:
            for p in r["permissions_cibles"]:
                expected[(r["erpnext_role_name"], p["doctype"])] = set(p["actions"])
        seen = set()
        for d in self.bundle["custom_docperm"]:
            key = (d["role"], d["parent"])
            self.assertIn(key, expected, f"DocPerm inventé : {key}")
            leves = {f for f in frappe.DOCPERM_FLAGS if d[f] == 1}
            self.assertEqual(leves, expected[key], f"Actions divergentes pour {key}")
            seen.add(key)
        self.assertEqual(seen, set(expected), "Des permissions du contrat sont perdues")

    # 7 · déterminisme bit-à-bit
    def test_deterministe(self):
        a = json.dumps(builder.build_bundle(_load(_CONTRACT)), sort_keys=False)
        b = json.dumps(builder.build_bundle(_load(_CONTRACT)), sort_keys=False)
        self.assertEqual(a, b, "Génération non déterministe")

    # 8 · DocTypes custom tous signalés dans le manifeste
    def test_manifeste_signale_doctypes_custom(self):
        contract_custom = sorted(
            {
                p["doctype"]
                for r in self.contract["roles"]
                for p in r["permissions_cibles"]
                if p.get("custom") is True
            }
        )
        self.assertEqual(
            self.bundle["manifest"]["custom_doctypes_a_confirmer"], contract_custom
        )

    # 9 · verbe inconnu → erreur explicite (garde-fou anti-contrat corrompu)
    def test_verbe_inconnu_rejete(self):
        with self.assertRaises(ValueError):
            frappe.docperm_fixture("Lead", "OTO X", ["read", "teleport"], if_owner=False)

    # 10 · le CLI validate/build fonctionne de bout en bout
    def test_cli_validate_ok(self):
        cli = _load_cli()
        self.assertEqual(cli.main(["validate"]), 0)


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