"""Tests du générateur Chat OTOIA embarqué par portail (Sprint 6 · ERPNext Backend).

Stdlib pur (unittest) — aucune dépendance pip. Couvre :
  - conformité au schéma + les 14 invariants du CLI (chemin nominal) ;
  - la cross-cohérence RBAC ↔ portails ↔ Workspaces ↔ seo ;
  - l'anti-invention (#6) par injections négatives (endpoint fabriqué, portée
    élargie, persona altérée, capability ajoutée, langue inventée, chiffre/URL
    dans le HTML de montage, rôles désynchronisés des Workspaces).
"""

from __future__ import annotations

import copy
import json
import os
import sys
import unittest

_HERE = os.path.dirname(os.path.abspath(__file__))
_ROOT = os.path.normpath(os.path.join(_HERE, ".."))
sys.path.insert(0, _ROOT)

import chat_otoia_gen as gen  # noqa: E402
from chatlib import builder, deps, frappe, knowledge  # noqa: E402


def build():
    return gen._build_bundle()


class TestNominal(unittest.TestCase):
    def setUp(self):
        self.bundle = build()

    def test_build_valide(self):
        self.assertEqual(gen._validate_bundle(self.bundle), [])

    def test_schema_seul(self):
        schema = deps.load_json(deps.SCHEMA_PATH)
        self.assertEqual(deps.validate(self.bundle, schema), [])

    def test_cinq_portails_metier(self):
        contract = deps.load_contract()
        keys = [m["portail"] for m in self.bundle["chat_mount"]]
        self.assertEqual(set(keys), set(contract["portails_business"]))
        self.assertEqual(len(keys), 5)
        self.assertNotIn("plateforme", keys)  # console technique exclue

    def test_un_block_un_mount_par_portail(self):
        self.assertEqual(len(self.bundle["custom_block"]), 5)
        self.assertEqual(len(self.bundle["chat_mount"]), 5)
        names = [cb["block_name"] for cb in self.bundle["custom_block"]]
        self.assertEqual(len(names), len(set(names)))

    def test_carrier_natif_custom_block(self):
        for cb in self.bundle["custom_block"]:
            self.assertEqual(cb["doctype"], "Custom Block")

    def test_ordre_deterministe(self):
        contract = deps.load_contract()
        spec = deps.load_spec()
        pspec = deps.load_portails_spec()
        langs, dflt = deps.load_langs()
        b2 = builder.build_bundle(contract, spec, pspec, langs, dflt)
        self.assertEqual(
            json.dumps(self.bundle, sort_keys=True, ensure_ascii=False),
            json.dumps(b2, sort_keys=True, ensure_ascii=False),
        )


class TestAncrageRBAC(unittest.TestCase):
    def setUp(self):
        self.bundle = build()
        self.contract = deps.load_contract()

    def test_knowledge_scope_egale_surface_rbac(self):
        for m in self.bundle["chat_mount"]:
            attendu = knowledge.knowledge_scope_for(self.contract, m["portail"])
            self.assertEqual(m["knowledge_scope"], attendu)
            self.assertGreater(len(attendu), 0)

    def test_roles_allowed_egale_roles_portail(self):
        for m in self.bundle["chat_mount"]:
            attendu = knowledge.roles_for(self.contract, m["portail"])
            self.assertEqual(m["roles_allowed"], attendu)

    def test_roles_synchronises_avec_workspaces(self):
        ws_roles = gen._workspace_roles(deps.load_workspaces())
        for m in self.bundle["chat_mount"]:
            self.assertIn(m["workspace"], ws_roles)
            self.assertEqual(m["roles_allowed"], ws_roles[m["workspace"]])

    def test_44_roles_couverts(self):
        # même total restreint que les Workspaces (hand-off portails).
        total = sum(len(m["roles_allowed"]) for m in self.bundle["chat_mount"])
        self.assertEqual(total, self.bundle["manifest"]["counts"]["roles_couverts"])
        self.assertEqual(total, 44)


class TestPersonaCapacitesLangues(unittest.TestCase):
    def setUp(self):
        self.bundle = build()

    def test_persona_amelie_sourcee(self):
        spec = deps.load_spec()
        self.assertEqual(spec["persona"]["nom"], "Amélie")
        self.assertEqual(spec["persona"]["voix"], "multilingual_v2")
        self.assertIn("CLAUDE.md", spec["persona"]["source"])
        for m in self.bundle["chat_mount"]:
            self.assertEqual(m["persona"], {"nom": "Amélie", "voix": "multilingual_v2"})

    def test_capabilities_sourcees_sans_ajout(self):
        spec = deps.load_spec()
        caps = [c["module"] for c in spec["capabilities"]]
        self.assertEqual(caps, ["aec.py", "knowledge.py", "prompt_engine.py", "chat.py"])
        for c in spec["capabilities"]:
            self.assertIn("CLAUDE.md", c["source"])
        for m in self.bundle["chat_mount"]:
            self.assertEqual(m["capabilities"], caps)

    def test_langues_derivees_du_seo(self):
        langs, dflt = deps.load_langs()
        self.assertEqual(langs, ["fr", "en", "es"])
        for m in self.bundle["chat_mount"]:
            self.assertEqual(m["langues"], langs)
            self.assertEqual(m["lang_defaut"], dflt)


class TestAntiInvention(unittest.TestCase):
    """#6 : endpoint jamais fabriqué, aucun chiffre/URL, aucune portée élargie."""

    def setUp(self):
        self.bundle = build()

    def test_endpoint_null_partout(self):
        for m in self.bundle["chat_mount"]:
            self.assertIsNone(m["endpoint"])
        self.assertEqual(self.bundle["manifest"]["endpoint_statut"], "a_confirmer")
        self.assertIsNone(deps.load_spec()["endpoint"]["valeur"])

    def test_html_sans_url_ni_chiffre(self):
        for cb in self.bundle["custom_block"]:
            html = cb["html"]
            self.assertNotIn("http://", html)
            self.assertNotIn("https://", html)
            self.assertFalse(any(ch.isdigit() for ch in html))

    def test_html_id_et_block_name_deterministes(self):
        labels = {p["key"]: p["label"] for p in deps.load_portails_spec()["portails"]}
        for m in self.bundle["chat_mount"]:
            self.assertEqual(m["html_id"], f"otoia-chat-{m['portail']}")
            self.assertEqual(m["custom_block"], f"OTOIA Chat · {labels[m['portail']]}")

    def test_brand_verbatim(self):
        b = self.bundle["manifest"]["brand"]
        self.assertEqual(b["fond"]["valeur"], "#0a0a12")
        self.assertEqual(b["accent"]["valeur"], "#f0b429")
        polices = [p["valeur"] for p in b["polices"]]
        self.assertIn("Fraunces", polices)
        self.assertIn("Cormorant Garamond", polices)

    # --- injections négatives : chaque altération DOIT être rejetée ---

    def _expect_error(self, mutate):
        bundle = copy.deepcopy(self.bundle)
        mutate(bundle)
        errors = gen._validate_bundle(bundle)
        self.assertNotEqual(errors, [], "une altération n'a pas été détectée")

    def test_rejet_endpoint_fabrique(self):
        self._expect_error(lambda b: b["chat_mount"][0].__setitem__("endpoint", "https://otoia.local"))

    def test_rejet_portee_elargie(self):
        # ajoute un DocType hors surface RBAC du portail.
        self._expect_error(lambda b: b["chat_mount"][0]["knowledge_scope"].append("Salary Slip"))

    def test_rejet_portee_reduite(self):
        self._expect_error(lambda b: b["chat_mount"][0]["knowledge_scope"].pop())

    def test_rejet_role_desynchronise(self):
        self._expect_error(lambda b: b["chat_mount"][0]["roles_allowed"].append("OTO Fantome"))

    def test_rejet_persona_alteree(self):
        self._expect_error(lambda b: b["chat_mount"][0].__setitem__("persona", {"nom": "Bob", "voix": "x"}))

    def test_rejet_capability_ajoutee(self):
        self._expect_error(lambda b: b["chat_mount"][0]["capabilities"].append("exfil.py"))

    def test_rejet_langue_inventee(self):
        self._expect_error(lambda b: b["chat_mount"][0]["langues"].append("de"))

    def test_rejet_html_avec_chiffre(self):
        def m(b):
            cb = b["custom_block"][0]
            cb["html"] = cb["html"].replace("></div>", " data-x=\"9\"></div>")
        self._expect_error(m)

    def test_rejet_html_avec_url(self):
        def m(b):
            cb = b["custom_block"][0]
            cb["html"] = cb["html"].replace("></div>", " data-src=\"https://x\"></div>")
        self._expect_error(m)

    def test_rejet_plateforme_incluse(self):
        def m(b):
            extra = copy.deepcopy(b["chat_mount"][0])
            extra["portail"] = "plateforme"
            b["chat_mount"].append(extra)
        self._expect_error(m)

    def test_rejet_count_incoherent(self):
        self._expect_error(lambda b: b["manifest"]["counts"].__setitem__("roles_couverts", 999))


class TestManifest(unittest.TestCase):
    def setUp(self):
        self.bundle = build()

    def test_counts_coherents(self):
        c = self.bundle["manifest"]["counts"]
        self.assertEqual(c["portails"], 5)
        self.assertEqual(c["custom_blocks"], 5)
        self.assertEqual(c["mounts"], 5)
        union = sorted({dt for m in self.bundle["chat_mount"] for dt in m["knowledge_scope"]})
        self.assertEqual(c["knowledge_doctypes_uniques"], len(union))

    def test_carrier_natif_documente(self):
        car = self.bundle["manifest"]["carrier"]
        self.assertEqual(car["doctype"], "Custom Block")
        self.assertEqual(car["workspace_content_block_type"], "custom_block")

    def test_handoff_non_vide(self):
        self.assertTrue(self.bundle["manifest"]["handoff_vps"])


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