#!/usr/bin/env python3
"""Tests du générateur de fixtures ERPNext Workflow (pipeline vente · Sprint 4).

Stdlib pur (`unittest`) → aucune installation pip requise sur le runner Gitea.
La bibliothèque `jsonschema` est utilisée comme *oracle* quand elle est présente,
pour se prémunir d'un écart entre le validateur maison et draft-07.
"""

from __future__ import annotations

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

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

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

from wflib import builder, erpnext  # noqa: E402
from wflib.rbac import RoleResolver  # noqa: E402
from lib import validator as maison  # type: ignore  # noqa: E402
import workflow_vente_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.schema = _load(gen._SCHEMA_PATH)
        cls.resolver = RoleResolver.from_path()
        cls.bundle = builder.build_bundle(cls.spec, cls.resolver)


# --------------------------------------------------------------------------- #
# Résolution des rôles RBAC (réutilisation · zéro invention · #6)
# --------------------------------------------------------------------------- #
class TestRoleResolver(BaseFixture):
    def test_resolves_known_id(self) -> None:
        self.assertEqual(
            self.resolver.erpnext_name("ventes-conseiller"), "OTO Ventes Conseiller"
        )

    def test_unknown_id_raises(self) -> None:
        with self.assertRaises(KeyError):
            self.resolver.erpnext_name("role-inexistant")

    def test_every_spec_role_id_exists_in_rbac(self) -> None:
        ids = {s["role_id"] for s in self.spec["states"]}
        ids |= {t["role_id"] for t in self.spec["transitions"]}
        for rid in ids:
            self.assertIn(rid, self.resolver.known_ids(), rid)

    def test_all_resolved_roles_are_oto_prefixed(self) -> None:
        for entry in self.bundle["manifest"]["roles_rbac_utilises"]:
            self.assertTrue(entry["erpnext_role_name"].startswith("OTO "))


# --------------------------------------------------------------------------- #
# Structure du bundle / conformité Frappe
# --------------------------------------------------------------------------- #
class TestBundleStructure(BaseFixture):
    def test_workflow_targets_pipeline_doctype(self) -> None:
        wf = self.bundle["workflow"]
        self.assertEqual(wf["doctype"], "Workflow")
        self.assertEqual(wf["document_type"], "OTO Dossier Vente")
        self.assertEqual(wf["workflow_state_field"], "workflow_state")

    def test_pipeline_covers_the_six_roadmap_stages(self) -> None:
        # roadmap Sprint 4 : lead -> visite -> devis -> reservation -> contrat -> CONFOTUR
        states = {s["state"] for s in self.bundle["workflow"]["states"]}
        for expected in [
            "Lead",
            "Visite planifiée",
            "Devis émis",
            "Réservation confirmée",
            "Contrat signé",
            "CONFOTUR déposé",
        ]:
            self.assertIn(expected, states, expected)

    def test_masters_cover_states_and_actions_exactly(self) -> None:
        states = {s["state"] for s in self.bundle["workflow"]["states"]}
        masters = {m["name"] for m in self.bundle["workflow_state"]}
        self.assertEqual(states, masters)
        actions = {t["action"] for t in self.bundle["workflow"]["transitions"]}
        act_masters = {m["name"] for m in self.bundle["workflow_action_master"]}
        self.assertEqual(actions, act_masters)

    def test_styles_are_native_frappe(self) -> None:
        for m in self.bundle["workflow_state"]:
            self.assertIn(m["style"], erpnext.VALID_STYLES)

    def test_manifest_flags_custom_carrier_doctype(self) -> None:
        self.assertEqual(
            self.bundle["manifest"]["custom_doctypes_a_confirmer"], ["OTO Dossier Vente"]
        )


# --------------------------------------------------------------------------- #
# Invariants métier du graphe (le CLI refuse d'écrire s'ils cassent)
# --------------------------------------------------------------------------- #
class TestGraphInvariants(BaseFixture):
    def test_clean_bundle_has_no_errors(self) -> None:
        self.assertEqual(gen._validate_bundle(self.spec, self.bundle), [])

    def test_docstatus_monotonic_no_illegal_jump(self) -> None:
        docstatus = {s["state"]: s["doc_status"] for s in self.bundle["workflow"]["states"]}
        for t in self.bundle["workflow"]["transitions"]:
            pair = (docstatus[t["state"]], docstatus[t["next_state"]])
            self.assertIn(pair, gen._ALLOWED_DOCSTATUS_STEPS, f"{t['state']}→{t['next_state']}")

    def test_detects_unreachable_state(self) -> None:
        spec = copy.deepcopy(self.spec)
        spec["states"].append(
            {
                "state": "Orphelin",
                "doc_status": "0",
                "role_id": "ventes-conseiller",
                "style": "Info",
                "update_value": "orphelin",
                "is_terminal": True,
                "message": "État jamais atteint.",
            }
        )
        bundle = builder.build_bundle(spec, self.resolver)
        errors = gen._validate_bundle(spec, bundle)
        self.assertTrue(any("inatteignable" in e for e in errors), errors)

    def test_detects_illegal_docstatus_jump(self) -> None:
        # Lead(0) -> Perdu(2) : saut 0→2 interdit.
        spec = copy.deepcopy(self.spec)
        spec["transitions"].append(
            {
                "state": "Lead",
                "action": "Sauter à perdu",
                "next_state": "Perdu",
                "role_id": "ventes-directeur",
                "allow_self_approval": 1,
            }
        )
        bundle = builder.build_bundle(spec, self.resolver)
        errors = gen._validate_bundle(spec, bundle)
        self.assertTrue(any("doc_status illégal" in e for e in errors), errors)

    def test_detects_duplicate_action_from_same_state(self) -> None:
        spec = copy.deepcopy(self.spec)
        spec["transitions"].append(
            {
                "state": "Lead",
                "action": "Planifier visite",  # doublon (état, action)
                "next_state": "Abandonné",
                "role_id": "ventes-conseiller",
                "allow_self_approval": 1,
            }
        )
        bundle = builder.build_bundle(spec, self.resolver)
        errors = gen._validate_bundle(spec, bundle)
        self.assertTrue(any("dupliqué" in e for e in errors), errors)

    def test_detects_separation_of_duties_violation(self) -> None:
        spec = copy.deepcopy(self.spec)
        for t in spec["transitions"]:
            if t.get("separation_of_duties"):
                t["allow_self_approval"] = 1  # viole la règle
                break
        bundle = builder.build_bundle(spec, self.resolver)
        errors = gen._validate_bundle(spec, bundle)
        self.assertTrue(any("Séparation des pouvoirs" in e for e in errors), errors)

    def test_reservation_confirm_forbids_self_approval(self) -> None:
        # Étape argent (devis → réservation) : quatre-yeux obligatoire.
        tr = next(
            t for t in self.bundle["workflow"]["transitions"]
            if t["action"] == "Confirmer réservation"
        )
        self.assertEqual(tr["allow_self_approval"], 0)

    def test_confotur_approval_forbids_self_approval(self) -> None:
        tr = next(
            t for t in self.bundle["workflow"]["transitions"]
            if t["action"] == "Approuver CONFOTUR"
        )
        self.assertEqual(tr["allow_self_approval"], 0)

    def test_has_submitted_terminal_success_state(self) -> None:
        outgoing = {t["state"] for t in self.bundle["workflow"]["transitions"]}
        terminal_success = [
            s["state"] for s in self.bundle["workflow"]["states"]
            if s["doc_status"] == "1" and s["state"] not in outgoing
        ]
        self.assertIn("CONFOTUR approuvé", terminal_success)


# --------------------------------------------------------------------------- #
# Schéma de sortie (validateur maison + oracle jsonschema)
# --------------------------------------------------------------------------- #
class TestSchema(BaseFixture):
    def test_bundle_matches_schema_maison(self) -> None:
        self.assertEqual(list(maison.validate(self.bundle, self.schema)), [])

    @unittest.skipUnless(_HAS_JSONSCHEMA, "jsonschema non installé (oracle facultatif)")
    def test_bundle_matches_schema_oracle(self) -> None:
        jsonschema.validate(self.bundle, self.schema)  # type: ignore


# --------------------------------------------------------------------------- #
# Déterminisme + CLI (build/validate)
# --------------------------------------------------------------------------- #
class TestDeterminismAndCLI(BaseFixture):
    def test_build_is_deterministic(self) -> None:
        a = builder.build_bundle(self.spec, self.resolver)
        b = builder.build_bundle(self.spec, self.resolver)
        self.assertEqual(
            json.dumps(a, ensure_ascii=False, sort_keys=True),
            json.dumps(b, ensure_ascii=False, sort_keys=True),
        )

    def test_transitions_sorted_stable(self) -> None:
        trans = self.bundle["workflow"]["transitions"]
        keys = [(t["state"], t["action"]) for t in trans]
        self.assertEqual(keys, sorted(keys))

    def test_cli_validate_exit_zero(self) -> None:
        rc = gen.main(["validate"])
        self.assertEqual(rc, 0)

    def test_cli_build_writes_four_files(self) -> None:
        with tempfile.TemporaryDirectory() as tmp:
            rc = gen.main(["build", "-o", tmp])
            self.assertEqual(rc, 0)
            for f in [
                "workflow.json",
                "workflow_state.json",
                "workflow_action_master.json",
                "MANIFEST.json",
            ]:
                self.assertTrue(os.path.exists(os.path.join(tmp, f)), f)
            wf = _load(os.path.join(tmp, "workflow.json"))
            self.assertEqual(wf[0]["doctype"], "Workflow")

    def test_committed_out_matches_regeneration(self) -> None:
        # La sortie commitée dans out/ doit correspondre bit-à-bit à une
        # régénération (garantie « diffable + re-générable » · anti-dérive).
        with tempfile.TemporaryDirectory() as tmp:
            gen.main(["build", "-o", tmp])
            for f in ["workflow.json", "workflow_state.json", "workflow_action_master.json", "MANIFEST.json"]:
                committed = os.path.join(_MODULE, "out", f)
                if os.path.exists(committed):
                    self.assertEqual(_load(committed), _load(os.path.join(tmp, f)), f)


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