#!/usr/bin/env python3
"""Tests du generateur du DocType `CONFOTUR Application` (Sprint 5 · ONAPI/Legal).

Stdlib pur (`unittest`) -> aucune installation pip requise sur le runner Gitea.
La bibliotheque `jsonschema` sert d'*oracle* quand elle est presente, pour se
premunir d'un ecart entre le validateur maison et draft-07.

Axe central : la CROSS-COHERENCE DocType <-> RBAC <-> workflow. Les permissions
du DocType DOIVENT etre, mot pour mot, les `permissions_cibles` du contrat RBAC ;
le caractere soumissible en decoule ; `estado` et `dossier_vente` derivent du
workflow ; aucun chiffre CONFOTUR n'est fabrique (#6).
"""

from __future__ import annotations

import copy
import json
import os
import subprocess
import sys
import unittest

_HERE = os.path.dirname(os.path.abspath(__file__))
_MODULE = os.path.normpath(os.path.join(_HERE, ".."))
_LEGAL = os.path.normpath(os.path.join(_MODULE, ".."))
_DELIVERABLES = os.path.normpath(os.path.join(_LEGAL, ".."))
_CRM = os.path.join(_DELIVERABLES, "crm")

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

from cflib import builder, frappe, rbac_scan  # noqa: E402
from workflow_vente.wflib.rbac import RoleResolver  # noqa: E402
from lib import validator as maison  # type: ignore  # noqa: E402
import confotur_application_gen as gen  # noqa: E402

try:
    import jsonschema  # type: ignore

    _HAS_JSONSCHEMA = True
except Exception:  # pragma: no cover
    _HAS_JSONSCHEMA = False


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


class BaseFixture(unittest.TestCase):
    @classmethod
    def setUpClass(cls) -> None:
        cls.spec = _load(gen._SPEC_PATH)
        cls.wf_spec = _load(gen._WF_SPEC_PATH)
        cls.schema = _load(gen._SCHEMA_PATH)
        cls.contract = rbac_scan.load_contract()
        cls.resolver = RoleResolver.from_path()
        cls.bundle = builder.build_bundle(cls.spec, cls.wf_spec, cls.contract, cls.resolver)
        cls.dt = cls.bundle["doctype"]
        cls.manifest = cls.bundle["manifest"]
        cls.fields = {f["fieldname"]: f for f in cls.dt["fields"]}


class TestBundleValid(BaseFixture):
    def test_validate_green(self) -> None:
        errors = gen._validate(self.bundle, self.spec, self.wf_spec, self.contract, self.resolver)
        self.assertEqual(errors, [], f"invariants: {errors}")

    def test_schema_maison(self) -> None:
        self.assertEqual(maison.validate(self.bundle, self.schema), [])

    @unittest.skipUnless(_HAS_JSONSCHEMA, "jsonschema absent")
    def test_schema_oracle(self) -> None:
        jsonschema.validate(self.bundle, self.schema)  # type: ignore

    def test_deterministe(self) -> None:
        again = builder.build_bundle(self.spec, self.wf_spec, self.contract, self.resolver)
        self.assertEqual(
            json.dumps(self.bundle, sort_keys=True, ensure_ascii=False),
            json.dumps(again, sort_keys=True, ensure_ascii=False),
        )

    def test_top_level_keys(self) -> None:
        self.assertEqual(set(self.bundle), {"manifest", "doctype"})


class TestDocTypeIdentity(BaseFixture):
    def test_name_is_target(self) -> None:
        self.assertEqual(self.dt["name"], "CONFOTUR Application")
        self.assertEqual(self.manifest["doctype_name"], "CONFOTUR Application")

    def test_custom(self) -> None:
        self.assertEqual(self.dt["custom"], 1)
        self.assertTrue(self.manifest["custom"])

    def test_native_envelope(self) -> None:
        self.assertEqual(self.dt["doctype"], "DocType")
        self.assertEqual(self.dt["engine"], "InnoDB")
        self.assertEqual(self.dt["autoname"], "naming_series:")

    def test_naming_series_readonly(self) -> None:
        ns = self.fields["naming_series"]
        self.assertEqual(ns["options"], self.spec["doctype"]["naming_series"])
        self.assertEqual(ns["read_only"], 1)

    def test_field_order_matches_fields(self) -> None:
        self.assertEqual(self.dt["field_order"], [f["fieldname"] for f in self.dt["fields"]])

    def test_unique_fieldnames(self) -> None:
        names = [f["fieldname"] for f in self.dt["fields"]]
        self.assertEqual(len(names), len(set(names)))

    def test_title_and_search_fields_real(self) -> None:
        self.assertIn(self.dt["title_field"], self.fields)
        for sf in self.dt["search_fields"].split(","):
            self.assertIn(sf, self.fields)


class TestPermissionsFromRBAC(BaseFixture):
    """Coeur du livrable : les DocPerm SONT les permissions_cibles RBAC."""

    def setUp(self) -> None:
        self.targeting = rbac_scan.roles_targeting("CONFOTUR Application", self.contract)
        self.by_name = {self.resolver.erpnext_name(r["role_id"]): r for r in self.targeting}

    def test_three_roles(self) -> None:
        self.assertEqual(len(self.targeting), 3)
        self.assertEqual(
            {r["role_id"] for r in self.targeting},
            {"ventes-confotur", "legal-onapi", "legal-directeur"},
        )

    def test_exhaustive_and_no_extra(self) -> None:
        perm_roles = {p["role"] for p in self.dt["permissions"]}
        self.assertEqual(perm_roles, set(self.by_name))

    def test_actions_word_for_word(self) -> None:
        for row in self.dt["permissions"]:
            granted = {p for p in frappe.VALID_PERMS if row.get(p) == 1}
            self.assertEqual(granted, set(self.by_name[row["role"]]["actions"]))

    def test_permlevel_zero(self) -> None:
        for row in self.dt["permissions"]:
            self.assertEqual(row["permlevel"], 0)

    def test_role_names_prefixed(self) -> None:
        for row in self.dt["permissions"]:
            self.assertTrue(row["role"].startswith("OTO "))

    def test_report_permission_supported(self) -> None:
        # legal-directeur porte `report` (specifique a ce module vs dossier_vente).
        directeur = next(p for p in self.dt["permissions"] if p["role"] == "OTO Legal Directeur")
        self.assertEqual(directeur.get("report"), 1)


class TestSubmittable(BaseFixture):
    def test_submittable_because_directeur_submits(self) -> None:
        self.assertEqual(self.dt["is_submittable"], 1)
        self.assertTrue(self.manifest["is_submittable"])

    def test_confotur_states_are_submitted(self) -> None:
        ds = {s["update_value"]: s["doc_status"] for s in self.wf_spec["states"]}
        for uv in builder.confotur_state_values(self.wf_spec):
            self.assertEqual(ds[uv], "1")


class TestDerivedOptions(BaseFixture):
    def test_dossier_vente_link_from_workflow(self) -> None:
        self.assertEqual(self.fields["dossier_vente"]["options"], self.wf_spec["document_type"])

    def test_projet_and_client_links(self) -> None:
        self.assertEqual(self.fields["projet"]["options"], "Project")
        self.assertEqual(self.fields["cliente"]["options"], "Customer")

    def test_entite_options_from_spec(self) -> None:
        self.assertEqual(
            self.fields["entite_porteuse"]["options"].split("\n"), self.spec["entites"]
        )
        self.assertEqual(self.fields["entite_porteuse"]["reqd"], 1)

    def test_estado_options_from_workflow(self) -> None:
        opts = self.fields["estado"]["options"].split("\n")
        self.assertEqual(opts[0], "")  # facultatif a la creation
        self.assertEqual([o for o in opts if o], builder.confotur_state_values(self.wf_spec))

    def test_linked_doctypes_manifest(self) -> None:
        linked = sorted({f.get("options") for f in self.dt["fields"]
                         if f["fieldtype"] == "Link" and f.get("options")})
        self.assertEqual(self.manifest["doctypes_lies_a_confirmer"], linked)


class TestDepotEvents(BaseFixture):
    def test_two_events_deposer_approuver(self) -> None:
        de = self.manifest["depot_events"]
        self.assertEqual(len(de), 2)
        self.assertEqual({e["update_value"] for e in de},
                         {"confotur_depose", "confotur_approuve"})

    def test_events_roles_have_doctype_access(self) -> None:
        role_ids = {r["role_id"] for r in
                    rbac_scan.roles_targeting("CONFOTUR Application", self.contract)}
        for e in self.manifest["depot_events"]:
            self.assertIn(e["role_id"], role_ids)

    def test_events_resolved_correctly(self) -> None:
        for e in self.manifest["depot_events"]:
            self.assertEqual(e["erpnext_role_name"], self.resolver.erpnext_name(e["role_id"]))
            self.assertEqual(e["portail"], self.resolver.portail(e["role_id"]))


class TestAntiInvention(BaseFixture):
    def test_no_money_fieldtype(self) -> None:
        money = {"Currency", "Float", "Int", "Percent"}
        for f in self.dt["fields"]:
            self.assertNotIn(f["fieldtype"], money)

    def test_no_default_except_naming_series(self) -> None:
        for f in self.dt["fields"]:
            if f["fieldname"] != "naming_series":
                self.assertNotIn("default", f)

    def test_note_present(self) -> None:
        self.assertIn("data_room", self.manifest["note_anti_invention"])
        self.assertRegex(self.manifest["note_anti_invention"], r"P05/P07")


class TestManifestCounts(BaseFixture):
    def test_counts(self) -> None:
        c = self.manifest["counts"]
        fields = self.dt["fields"]
        data = [f for f in fields if f["fieldtype"] not in frappe.LAYOUT_FIELDTYPES]
        sections = [f for f in fields if f["fieldtype"] == "Section Break"]
        self.assertEqual(c["fields"], len(fields))
        self.assertEqual(c["data_fields"], len(data))
        self.assertEqual(c["sections"], len(sections))
        self.assertEqual(c["permissions"], len(self.dt["permissions"]))
        self.assertEqual(c["roles"], 3)
        self.assertEqual(c["depot_events"], 2)


class TestNegative(BaseFixture):
    """Le validateur DOIT rejeter toute derive introduite volontairement."""

    def _val(self, bundle) -> list[str]:
        return gen._validate(bundle, self.spec, self.wf_spec, self.contract, self.resolver)

    def test_reject_tampered_permission(self) -> None:
        b = copy.deepcopy(self.bundle)
        # On ajoute `delete` a un role -> ne correspond plus au contrat RBAC.
        b["doctype"]["permissions"][0]["delete"] = 1
        self.assertTrue(self._val(b))

    def test_reject_extra_role(self) -> None:
        b = copy.deepcopy(self.bundle)
        b["doctype"]["permissions"].append({"role": "OTO Faux Role", "permlevel": 0, "read": 1})
        self.assertTrue(self._val(b))

    def test_reject_wrong_submittable(self) -> None:
        b = copy.deepcopy(self.bundle)
        b["doctype"]["is_submittable"] = 0
        self.assertTrue(self._val(b))

    def test_reject_hardcoded_dossier_link(self) -> None:
        b = copy.deepcopy(self.bundle)
        for f in b["doctype"]["fields"]:
            if f["fieldname"] == "dossier_vente":
                f["options"] = "Some Other DocType"
        self.assertTrue(self._val(b))

    def test_reject_money_field(self) -> None:
        b = copy.deepcopy(self.bundle)
        b["doctype"]["fields"].append({"fieldname": "montant", "fieldtype": "Currency"})
        b["doctype"]["field_order"].append("montant")
        self.assertTrue(self._val(b))

    def test_reject_smuggled_default(self) -> None:
        b = copy.deepcopy(self.bundle)
        for f in b["doctype"]["fields"]:
            if f["fieldname"] == "estado":
                f["default"] = "confotur_approuve"
        self.assertTrue(self._val(b))

    def test_reject_estado_extra_option(self) -> None:
        b = copy.deepcopy(self.bundle)
        for f in b["doctype"]["fields"]:
            if f["fieldname"] == "estado":
                f["options"] = f["options"] + "\netat_invente"
        self.assertTrue(self._val(b))

    def test_reject_bad_counts(self) -> None:
        b = copy.deepcopy(self.bundle)
        b["manifest"]["counts"]["roles"] = 99
        self.assertTrue(self._val(b))


class TestBuilderGuards(BaseFixture):
    def test_missing_target_role_raises(self) -> None:
        contract = copy.deepcopy(self.contract)
        for role in contract["roles"]:
            role["permissions_cibles"] = [
                pc for pc in role.get("permissions_cibles", [])
                if pc.get("doctype") != "CONFOTUR Application"
            ]
        with self.assertRaises(ValueError):
            builder.build_bundle(self.spec, self.wf_spec, contract, self.resolver)

    def test_duplicate_doctype_in_role_raises(self) -> None:
        contract = copy.deepcopy(self.contract)
        for role in contract["roles"]:
            if role["id"] == "legal-onapi":
                role["permissions_cibles"].append(
                    {"doctype": "CONFOTUR Application", "actions": ["read"]}
                )
        with self.assertRaises(ValueError):
            rbac_scan.roles_targeting("CONFOTUR Application", contract)

    def test_unknown_options_source_raises(self) -> None:
        spec = copy.deepcopy(self.spec)
        spec["field_groups"][0]["fields"][0]["options_source"] = "inexistant"
        with self.assertRaises(ValueError):
            builder.build_bundle(spec, self.wf_spec, self.contract, self.resolver)


class TestCLI(unittest.TestCase):
    def test_build_and_validate_exit_zero(self) -> None:
        for cmd in ("validate", "build"):
            r = subprocess.run(
                [sys.executable, os.path.join(_MODULE, "confotur_application_gen.py"), cmd],
                capture_output=True, text=True,
            )
            self.assertEqual(r.returncode, 0, r.stderr)


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