"""Tests du générateur Faisabilité 4 volets (stdlib `unittest`, zéro pip).

Couvre : scoring 4Big, rétrogradation défensive (#6), rendu conforme au template,
paramètres canoniques (#9/#10), et surtout le ROUND-TRIP end-to-end
generator → parser Publiciste → projets_master.json (validé contre le schéma).

Un oracle `jsonschema` est utilisé EN PLUS s'il est installé.
"""

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

_HERE = os.path.dirname(os.path.abspath(__file__))
_GEN = os.path.normpath(os.path.join(_HERE, ".."))            # generator/
_PUB = os.path.normpath(os.path.join(_GEN, "..", "..", "publiciste"))
_FAIS = os.path.normpath(os.path.join(_GEN, ".."))            # faisabilite/ (schémas)

sys.path.insert(0, _PUB)
sys.path.insert(0, _GEN)

from genlib import model, renderer, scorer  # noqa: E402,F401  (generator/genlib)


def _load_gen_cli():
    """Charge faisabilite_gen.py comme module isolé (évite collision `lib`)."""
    spec = importlib.util.spec_from_file_location(
        "faisabilite_gen", os.path.join(_GEN, "faisabilite_gen.py")
    )
    mod = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(mod)
    return mod


def _pub():
    """(parser, validator) du Publiciste, importés depuis publiciste/lib."""
    import lib.parser as pub_parser  # noqa: PLC0415
    import lib.validator as pub_validator  # noqa: PLC0415
    return pub_parser, pub_validator


def _brief(name):
    with open(os.path.join(_GEN, "fixtures", name), encoding="utf-8") as fh:
        return json.load(fh)


def _schema(name):
    with open(os.path.join(_FAIS, name), encoding="utf-8") as fh:
        return json.load(fh)


_FIXED_TS = "2026-07-30T00:00:00Z"


class ScorerTest(unittest.TestCase):
    def test_complete_brief_scores_publiable(self):
        bilan = scorer.score(_brief("brief_complete.json"))
        self.assertEqual(bilan["statut_faisabilite"], "complete")
        self.assertGreaterEqual(bilan["score_4big"], 95)
        self.assertEqual(bilan["champs_manquants"], [])
        self.assertEqual(len(bilan["volets_complets"]), 4)
        self.assertEqual(bilan["axes"]["anti_gap_prix"], 20)

    def test_incomplete_brief_is_downgraded(self):
        bilan = scorer.score(_brief("brief_incomplete.json"))
        self.assertNotEqual(bilan["statut_faisabilite"], "complete")
        self.assertLess(bilan["score_4big"], 95)
        # Les prix manquants doivent figurer explicitement (anti-gap).
        joined = " ".join(bilan["champs_manquants"])
        self.assertIn("prix_usd", joined)
        self.assertIn("prix_dop", joined)

    def test_missing_required_field_listed(self):
        b = _brief("brief_complete.json")
        b["masterplan"]["phasage"] = None
        bilan = scorer.score(b)
        self.assertIn("masterplan.phasage", bilan["champs_manquants"])
        self.assertNotIn("masterplan", bilan["volets_complets"])
        self.assertNotEqual(bilan["statut_faisabilite"], "complete")

    def test_no_typologies_zeroes_anti_gap(self):
        b = _brief("brief_complete.json")
        b["architecture"]["typologies"] = []
        bilan = scorer.score(b)
        self.assertEqual(bilan["axes"]["anti_gap_prix"], 0)
        self.assertNotIn("architecture", bilan["volets_complets"])

    def test_empty_brief_is_incomplete(self):
        bilan = scorer.score({"projet": "P03"})
        self.assertEqual(bilan["statut_faisabilite"], "incomplete")
        self.assertEqual(bilan["volets_complets"], [])


class RendererTest(unittest.TestCase):
    def test_canonical_params_always_present(self):
        # Même un brief quasi vide émet les 6 paramètres canoniques (#9/#10).
        md = renderer.render_ingenierie({"projet": "P03"})
        self.assertEqual(renderer.check_canonical(md), [])
        for marker in ["3 %", "8.5 %", "52 %", "USD + DOP", "Cardnet", "Letter US"]:
            self.assertIn(marker, md)

    def test_missing_price_is_placeholder_not_invented(self):
        md = renderer.render_architecture(_brief("brief_incomplete.json"))
        # Aucun prix inventé : les cellules USD/DOP restent des placeholders.
        self.assertIn("{{typo_1_prix_usd}}", md)
        self.assertIn("{{typo_2_prix_dop}}", md)

    def test_money_formatting(self):
        self.assertEqual(renderer._money(150000, "USD", "x"), "USD 150,000")
        self.assertEqual(renderer._money(None, "USD", "x"), "{{x}}")
        self.assertEqual(renderer._money(0, "DOP", "x"), "DOP 0")

    def test_synthetic_banner(self):
        md = renderer.render_brief(_brief("brief_complete.json"))
        self.assertIn("SYNTHÉTIQUE", md)


class RoundTripTest(unittest.TestCase):
    """generator → data_room/PXX/ → parser Publiciste → schéma."""

    def setUp(self):
        self.tmp = tempfile.mkdtemp(prefix="fais_gen_")
        self.cli = _load_gen_cli()

    def tearDown(self):
        shutil.rmtree(self.tmp, ignore_errors=True)

    def _gen(self, brief_name):
        brief = _brief(brief_name)
        projet_dir, bilan = self.cli._generate_tree(brief, self.tmp, _FIXED_TS)
        return brief, projet_dir, bilan

    def test_complete_roundtrips_to_disponible(self):
        brief, projet_dir, bilan = self._gen("brief_complete.json")
        self.assertNotIn("_machine_lisible_motifs", bilan)  # preuve axe 5 OK

        pub_parser, pub_validator = _pub()
        projet = pub_parser.parse_projet(projet_dir)
        # complete → le parser NE rétrograde PAS (prix tous présents).
        self.assertEqual(projet["statut"], "disponible")
        self.assertEqual(projet["code"], "P01")
        self.assertEqual(len(projet["typologies"]), 3)
        self.assertTrue(all(isinstance(t["prix_depuis_usd"], (int, float))
                            for t in projet["typologies"]))
        self.assertEqual(projet["typologies"][0]["prix_depuis_usd"], 150000.0)
        # Services + localisation + positionnement remontent bien.
        self.assertIn("Conciergerie 24/7", projet["inclus"])
        self.assertTrue(projet["localisation"])
        self.assertIn("fr", projet.get("positionnement", {}))
        # Rendus (manifeste) lus.
        self.assertTrue(projet["rendus"][0]["hero"])

    def test_incomplete_roundtrips_to_en_developpement(self):
        brief, projet_dir, bilan = self._gen("brief_incomplete.json")
        self.assertNotIn("_machine_lisible_motifs", bilan)

        pub_parser, _ = _pub()
        projet = pub_parser.parse_projet(projet_dir)
        # Prix absents → parser publie « en développement » (jamais de prix inventé).
        self.assertEqual(projet["statut"], "en_developpement")
        self.assertEqual(len(projet["typologies"]), 2)
        self.assertTrue(all(t["prix_depuis_usd"] is None
                            for t in projet["typologies"]))

    def test_version_json_validates_against_schema(self):
        _, projet_dir, _ = self._gen("brief_complete.json")
        with open(os.path.join(projet_dir, "_META", "version.json"),
                  encoding="utf-8") as fh:
            version = json.load(fh)
        _, pub_validator = _pub()
        errs = pub_validator.validate(version, _schema("version.schema.json"))
        self.assertEqual(errs, [], f"version.json non conforme : {errs}")

    def test_full_pipeline_master_validates(self):
        # Génère 2 projets → parcours build_master → projets_master.json conforme.
        for name in ("brief_complete.json", "brief_incomplete.json"):
            self._gen(name)
        pub_parser, pub_validator = _pub()
        master = pub_parser.build_master(self.tmp, generated_at=_FIXED_TS)
        self.assertEqual(len(master["projets"]), 2)
        errs = pub_validator.validate(
            master, _schema("projets_master.schema.json")
        )
        self.assertEqual(errs, [], f"projets_master non conforme : {errs}")

    def test_generation_is_deterministic(self):
        self._gen("brief_complete.json")
        snap1 = _read_tree(os.path.join(self.tmp, "P01"))
        shutil.rmtree(os.path.join(self.tmp, "P01"))
        self._gen("brief_complete.json")
        snap2 = _read_tree(os.path.join(self.tmp, "P01"))
        self.assertEqual(snap1, snap2)

    def test_scaffold_is_not_publiable(self):
        # Un scaffold vide ne doit jamais être « complete ».
        projet_dir, bilan = self.cli._generate_tree(
            {"projet": "P09"}, self.tmp, _FIXED_TS
        )
        self.assertNotEqual(bilan["statut_faisabilite"], "complete")
        self.assertNotIn("_machine_lisible_motifs", bilan)  # structure quand même valide
        pub_parser, _ = _pub()
        projet = pub_parser.parse_projet(projet_dir)
        self.assertEqual(projet["statut"], "en_developpement")


class OracleTest(unittest.TestCase):
    """Concordance validateur maison ↔ `jsonschema` (si installé)."""

    def test_version_json_matches_jsonschema_oracle(self):
        try:
            import jsonschema  # noqa: PLC0415
        except ImportError:
            self.skipTest("jsonschema non installé")
        tmp = tempfile.mkdtemp(prefix="fais_oracle_")
        try:
            cli = _load_gen_cli()
            projet_dir, _ = cli._generate_tree(
                _brief("brief_complete.json"), tmp, _FIXED_TS
            )
            with open(os.path.join(projet_dir, "_META", "version.json"),
                      encoding="utf-8") as fh:
                version = json.load(fh)
            jsonschema.validate(version, _schema("version.schema.json"))
        finally:
            shutil.rmtree(tmp, ignore_errors=True)


def _read_tree(root):
    out = {}
    for dirpath, _dirs, files in os.walk(root):
        for f in sorted(files):
            p = os.path.join(dirpath, f)
            with open(p, encoding="utf-8") as fh:
                out[os.path.relpath(p, root)] = fh.read()
    return out


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