#!/usr/bin/env python3
"""Tests du générateur de scénarios démo (run-sheet).

Couvre : assemblage réel (les artefacts `out/` amont doivent exister et être
gated), résolution de pointeurs RFC 6901, cœur anti-invention (#6), invariants,
et un lot d'INJECTIONS NÉGATIVES prouvant que chaque garde-fou rougit vraiment.
"""

from __future__ import annotations

import copy
import json
import os
import sys
import unittest

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

import demo_scenario_gen as gen           # noqa: E402
from scenlib import builder, evidence, render  # noqa: E402


def _fresh():
    """(spec, run_sheet, manifest) construits depuis le dépôt réel."""
    return gen._assemble(generated_at=None)


class TestBuildReel(unittest.TestCase):
    def setUp(self):
        self.spec, self.run_sheet, self.manifest = _fresh()

    def test_build_sans_invariant_casse(self):
        self.assertEqual(gen.check_invariants(self.spec, self.run_sheet, self.manifest), [])

    def test_deux_scenarios_p07_p05(self):
        ids = {sc["id"] for sc in self.run_sheet["scenarios"]}
        self.assertEqual(ids, {"S-P07-BANQUIER", "S-P05-CLIENT"})
        projets = {sc["projet"] for sc in self.run_sheet["scenarios"]}
        self.assertEqual(projets, {"P05", "P07"})

    def test_audiences_declarees(self):
        for sc in self.run_sheet["scenarios"]:
            self.assertIn(sc["audience"], self.spec["audiences"])

    def test_libelles_projets_sources_du_spec(self):
        for sc in self.run_sheet["scenarios"]:
            self.assertEqual(sc["projet_libelle"], self.spec["projets"][sc["projet"]])

    def test_beats_ordonnes_et_min_trois(self):
        for sc in self.run_sheet["scenarios"]:
            self.assertGreaterEqual(len(sc["beats"]), 3)
            self.assertEqual([b["ordre"] for b in sc["beats"]],
                             list(range(1, len(sc["beats"]) + 1)))

    def test_orchestration_min_deux_modules(self):
        for sc in self.run_sheet["scenarios"]:
            self.assertGreaterEqual(len(set(sc["modules_cites"])), 2)

    def test_toutes_citations_resolues(self):
        self.assertEqual(self.manifest["counts"]["citations_non_resolues"], 0)
        for sc in self.run_sheet["scenarios"]:
            for b in sc["beats"]:
                for c in b["evidence"]:
                    self.assertTrue(c["resolu"])
                    self.assertFalse(evidence.is_placeholder(c["valeur"]))
                    self.assertIsNotNone(c["valeur"])

    def test_pret_et_couverture_ci(self):
        self.assertTrue(self.manifest["pret"])
        self.assertTrue(self.manifest["couverture_ci"]["ok"])
        self.assertEqual(self.manifest["couverture_ci"]["modules_cites_non_gated"], [])

    def test_pas_auto_citation(self):
        mods = {c["module"] for sc in self.run_sheet["scenarios"]
                for b in sc["beats"] for c in b["evidence"]}
        self.assertNotIn(builder.SELF_MODULE, mods)

    def test_manifest_conforme_schema(self):
        self.assertEqual(gen.deps.validate(self.manifest, gen._load_schema()), [])

    def test_duree_est_somme_des_beats(self):
        for sc in self.run_sheet["scenarios"]:
            self.assertEqual(sc["duree_min"], sum(b["duree_min"] for b in sc["beats"]))

    def test_determinisme(self):
        _, rs2, mf2 = _fresh()
        self.assertEqual(self.run_sheet, rs2)
        self.assertEqual(self.manifest, mf2)

    def test_valeurs_verbatim_depuis_artefacts(self):
        # Une valeur du run-sheet doit être EXACTEMENT celle du fichier amont.
        wf = json.load(open(os.path.join(
            builder.deps.DELIVERABLES_ROOT, "crm/workflow_vente/out/workflow.json"),
            encoding="utf-8"))
        beat1 = self.run_sheet["scenarios"][0]["beats"][0]["evidence"][0]
        self.assertEqual(beat1["valeur"], wf[0]["workflow_name"])


class TestPointeurRFC6901(unittest.TestCase):
    def setUp(self):
        self.loader = evidence.Loader()

    def test_split_racine_et_echappement(self):
        self.assertEqual(evidence._split_pointer(""), [])
        self.assertEqual(evidence._split_pointer("/a/b"), ["a", "b"])
        self.assertEqual(evidence._split_pointer("/a~1b/c~0d"), ["a/b", "c~d"])

    def test_split_refuse_pointeur_sans_slash(self):
        with self.assertRaises(ValueError):
            evidence._split_pointer("a/b")

    def test_descend_cle_index_negatif(self):
        self.assertEqual(evidence._descend({"x": 1}, "x"), 1)
        self.assertEqual(evidence._descend([10, 20, 30], "0"), 10)
        self.assertEqual(evidence._descend([10, 20, 30], "-1"), 30)
        self.assertIs(evidence._descend([1], "5"), evidence._MISSING)
        self.assertIs(evidence._descend(42, "x"), evidence._MISSING)

    def test_resolve_valeur_existante(self):
        val, ok = evidence.resolve(self.loader, "crm/workflow_vente",
                                   "out/workflow.json", "/0/document_type")
        self.assertTrue(ok)
        self.assertEqual(val, "OTO Dossier Vente")

    def test_resolve_null_est_non_resolu(self):
        # `condition: null` dans une transition → placeholder, pas résolu (#6).
        val, ok = evidence.resolve(self.loader, "crm/workflow_vente",
                                   "out/workflow.json", "/0/transitions/0/condition")
        self.assertFalse(ok)
        self.assertTrue(evidence.is_placeholder(val))

    def test_resolve_artefact_absent(self):
        val, ok = evidence.resolve(self.loader, "module/inexistant",
                                   "out/rien.json", "/x")
        self.assertFalse(ok)
        self.assertEqual(val, "{{module/inexistant:out/rien.json#/x}}")

    def test_resolve_pointeur_manquant(self):
        val, ok = evidence.resolve(self.loader, "crm/workflow_vente",
                                   "out/workflow.json", "/0/champ_absent")
        self.assertFalse(ok)
        self.assertTrue(evidence.is_placeholder(val))

    def test_is_placeholder(self):
        self.assertTrue(evidence.is_placeholder("{{a:b#/c}}"))
        self.assertFalse(evidence.is_placeholder("valeur"))
        self.assertFalse(evidence.is_placeholder(258))


class TestInjectionsNegatives(unittest.TestCase):
    """Chaque garde-fou DOIT rougir sur une dégradation ciblée."""

    def setUp(self):
        self.spec, self.run_sheet, self.manifest = _fresh()

    def _motifs(self, rs, mf):
        return gen.check_invariants(self.spec, rs, mf)

    def test_anti_invention_valeur_falsifiee(self):
        rs = copy.deepcopy(self.run_sheet)
        rs["scenarios"][0]["beats"][0]["evidence"][0]["valeur"] = "CHIFFRE INVENTÉ"
        mf = builder.build_manifest(self.spec, rs)
        self.assertTrue(any("invention/dérive" in m for m in self._motifs(rs, mf)))

    def test_compteur_falsifie(self):
        mf = copy.deepcopy(self.manifest)
        mf["counts"]["citations_total"] += 1
        motifs = self._motifs(self.run_sheet, mf)
        self.assertTrue(any("counts.citations_total" in m for m in motifs))

    def test_placeholder_incoherent(self):
        rs = copy.deepcopy(self.run_sheet)
        c = rs["scenarios"][0]["beats"][0]["evidence"][0]
        c["resolu"] = False   # résolu marqué faux mais valeur reste réelle
        mf = builder.build_manifest(self.spec, rs)
        self.assertTrue(any("non résolu sans placeholder" in m
                            or "invention/dérive" in m for m in self._motifs(rs, mf)))

    def test_module_non_gated_detecte(self):
        # On force couverture_ci en KO → invariant (g) doit rougir.
        mf = copy.deepcopy(self.manifest)
        mf["couverture_ci"]["ok"] = False
        mf["couverture_ci"]["modules_cites_non_gated"] = ["module/hors-ci"]
        self.assertTrue(any("NON gated" in m for m in self._motifs(self.run_sheet, mf)))

    def test_non_pret_bloque(self):
        mf = copy.deepcopy(self.manifest)
        mf["pret"] = False
        self.assertTrue(any("non prêt" in m for m in self._motifs(self.run_sheet, mf)))

    def test_auto_citation_detectee(self):
        rs = copy.deepcopy(self.run_sheet)
        rs["scenarios"][0]["beats"][0]["evidence"][0]["module"] = builder.SELF_MODULE
        mf = builder.build_manifest(self.spec, rs)
        self.assertTrue(any("auto-citation" in m or "invention/dérive" in m
                            for m in self._motifs(rs, mf)))

    def test_ordres_non_contigus_detectes(self):
        rs = copy.deepcopy(self.run_sheet)
        rs["scenarios"][0]["beats"][0]["ordre"] = 99
        mf = builder.build_manifest(self.spec, rs)
        self.assertTrue(any("non contigus" in m for m in self._motifs(rs, mf)))

    def test_schema_rejette_beats_insuffisants(self):
        mf = copy.deepcopy(self.manifest)
        mf["scenarios"][0]["beats"] = 1
        self.assertTrue(gen.deps.validate(mf, gen._load_schema()))

    def test_schema_rejette_id_hors_pattern(self):
        mf = copy.deepcopy(self.manifest)
        mf["scenarios"][0]["id"] = "mauvais-id"
        self.assertTrue(gen.deps.validate(mf, gen._load_schema()))


class TestRenderMarkdown(unittest.TestCase):
    """Le prompteur Markdown : déterministe et sans donnée nouvelle (#6)."""

    def setUp(self):
        _, self.run_sheet, _ = _fresh()
        self.md = render.render_markdown(self.run_sheet)

    def test_deterministe(self):
        self.assertEqual(self.md, render.render_markdown(self.run_sheet))

    def test_termine_par_un_saut_de_ligne(self):
        self.assertTrue(self.md.endswith("\n"))
        self.assertFalse(self.md.endswith("\n\n"))

    def test_titres_scenarios_presents(self):
        for sc in self.run_sheet["scenarios"]:
            self.assertIn(sc["titre"], self.md)
            self.assertIn(sc["id"], self.md)

    def test_chaque_beat_rendu(self):
        for sc in self.run_sheet["scenarios"]:
            for b in sc["beats"]:
                self.assertIn(b["titre"], self.md)
                self.assertIn(b["role_narratif"], self.md)

    def test_valeurs_proviennent_du_run_sheet(self):
        # Anti-invention : chaque valeur affichée est celle, formatée, d'une
        # citation — jamais un chiffre nouveau. On vérifie la présence verbatim.
        for sc in self.run_sheet["scenarios"]:
            for b in sc["beats"]:
                for c in b["evidence"]:
                    self.assertIn(render._fmt(c["valeur"]), self.md)

    def test_fmt_bool_liste_none(self):
        self.assertEqual(render._fmt(True), "oui")
        self.assertEqual(render._fmt(False), "non")
        self.assertEqual(render._fmt(["fr", "en"]), "fr, en")
        self.assertEqual(render._fmt(50), "50")
        self.assertEqual(render._fmt(None), "`—`")

    def test_placeholder_non_promu_en_chiffre(self):
        # Un placeholder survivant est rendu entre back-ticks, jamais nu.
        self.assertEqual(render._fmt("{{m:f#/p}}"), "`{{m:f#/p}}`")


class TestCLI(unittest.TestCase):
    def test_validate_retourne_zero(self):
        self.assertEqual(gen.main(["validate"]), 0)

    def test_build_ecrit_out(self):
        import tempfile
        with tempfile.TemporaryDirectory() as d:
            self.assertEqual(gen.main(["build", "-o", d]), 0)
            for name in ("run_sheet.json", "MANIFEST.json", "run_sheet.md"):
                self.assertTrue(os.path.isfile(os.path.join(d, name)))


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