#!/usr/bin/env python3
"""Tests du générateur d'audit 4Big qualité (QA · Sprint 7).

Stdlib pur (`unittest`) → aucune installation pip requise sur le runner Gitea
(CLAUDE.md #2). Couvre : les 5 critères (positif + injection négative sur arbre
de module synthétique en tmpdir), la renormalisation par archétype, la preuve de
couverture 100% vs CI, les invariants du générateur, le déterminisme, le schéma.

Axe central : une note ne peut pas être « fabriquée pour faire 95 » — elle est
RECALCULÉE depuis des faits du dépôt (#6). Un module dont un critère applicable
échoue tombe sous 95 → FAIL global (l'audit est un GATE).
"""

from __future__ import annotations

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

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

from q4lib import builder, criteria, registry, scoring  # noqa: E402
import audit_4big_gen as gen  # noqa: E402


def _spec():
    with open(os.path.join(_MOD, "quality_spec.json"), encoding="utf-8") as fh:
        return json.load(fh)


def _make_module(root: str, *, doc=True, doc_bytes=800, schema=True,
                 tests=12, cli=True, handoff=True) -> str:
    """Construit un arbre de module synthétique et renvoie son chemin."""
    mod = os.path.join(root, "mod")
    os.makedirs(mod, exist_ok=True)
    if doc:
        with open(os.path.join(mod, "README.md"), "w", encoding="utf-8") as fh:
            fh.write("# Module\n" + ("x" * doc_bytes))
    if schema:
        with open(os.path.join(mod, "out.schema.json"), "w", encoding="utf-8") as fh:
            json.dump({"type": "object"}, fh)
    tdir = os.path.join(mod, "tests")
    os.makedirs(tdir, exist_ok=True)
    body = "".join(f"    def test_case_{i}(self):\n        pass\n"
                   for i in range(tests))
    with open(os.path.join(tdir, "test_mod.py"), "w", encoding="utf-8") as fh:
        fh.write("import unittest\nclass T(unittest.TestCase):\n" +
                 (body or "    pass\n"))
    if cli:
        with open(os.path.join(mod, "mod_gen.py"), "w", encoding="utf-8") as fh:
            fh.write('import argparse\n'
                     'ap = argparse.ArgumentParser()\n'
                     'sub = ap.add_subparsers()\n'
                     'sub.add_parser("build")\n'
                     'if __name__ == "__main__":\n    pass\n')
    if handoff:
        odir = os.path.join(mod, "out")
        os.makedirs(odir, exist_ok=True)
        with open(os.path.join(odir, "MANIFEST.json"), "w", encoding="utf-8") as fh:
            json.dump({"a": 1}, fh)
        with open(os.path.join(odir, "artifact.json"), "w", encoding="utf-8") as fh:
            json.dump({"b": 2}, fh)
    return mod


# --------------------------------------------------------------------------- #
# 1. Critères — positifs et injections négatives (arbre synthétique).          #
# --------------------------------------------------------------------------- #
class CriteriaTest(unittest.TestCase):
    def test_all_criteria_pass_on_complete_module(self):
        with tempfile.TemporaryDirectory() as d:
            mod = _make_module(d)
            self.assertTrue(criteria.crit_doc(mod, 400)["passed"])
            self.assertTrue(criteria.crit_contrat(mod)["passed"])
            self.assertTrue(criteria.crit_tests(mod, 8)["passed"])
            self.assertTrue(criteria.crit_cli(mod)["passed"])
            self.assertTrue(criteria.crit_handoff(mod)["passed"])

    def test_doc_fails_when_too_small(self):
        with tempfile.TemporaryDirectory() as d:
            mod = _make_module(d, doc_bytes=10)
            self.assertFalse(criteria.crit_doc(mod, 400)["passed"])

    def test_doc_fails_when_absent(self):
        with tempfile.TemporaryDirectory() as d:
            mod = _make_module(d, doc=False)
            self.assertFalse(criteria.crit_doc(mod, 400)["passed"])

    def test_contrat_fails_without_schema(self):
        with tempfile.TemporaryDirectory() as d:
            mod = _make_module(d, schema=False)
            self.assertFalse(criteria.crit_contrat(mod)["passed"])

    def test_tests_counts_methods_and_thresholds(self):
        with tempfile.TemporaryDirectory() as d:
            mod = _make_module(d, tests=5)
            res = criteria.crit_tests(mod, 8)
            self.assertFalse(res["passed"])
            self.assertIn("5 méthodes", res["evidence"])
        with tempfile.TemporaryDirectory() as d:
            mod = _make_module(d, tests=9)
            self.assertTrue(criteria.crit_tests(mod, 8)["passed"])

    def test_cli_fails_without_entrypoint(self):
        with tempfile.TemporaryDirectory() as d:
            mod = _make_module(d, cli=False)
            self.assertFalse(criteria.crit_cli(mod)["passed"])

    def test_cli_ignores_tests_dir_scripts(self):
        # Un script argparse dans tests/ ne compte pas comme entrypoint racine.
        with tempfile.TemporaryDirectory() as d:
            mod = _make_module(d, cli=False)
            self.assertFalse(criteria.crit_cli(mod)["passed"])

    def test_handoff_fails_without_manifest(self):
        with tempfile.TemporaryDirectory() as d:
            mod = _make_module(d, handoff=False)
            self.assertFalse(criteria.crit_handoff(mod)["passed"])

    def test_handoff_fails_when_manifest_only(self):
        with tempfile.TemporaryDirectory() as d:
            mod = _make_module(d)
            os.remove(os.path.join(mod, "out", "artifact.json"))
            self.assertFalse(criteria.crit_handoff(mod)["passed"])

    def test_handoff_fails_on_broken_json(self):
        with tempfile.TemporaryDirectory() as d:
            mod = _make_module(d)
            with open(os.path.join(mod, "out", "artifact.json"), "w") as fh:
                fh.write("{ not json")
            self.assertFalse(criteria.crit_handoff(mod)["passed"])

    def test_handoff_fails_on_empty_artifact(self):
        with tempfile.TemporaryDirectory() as d:
            mod = _make_module(d)
            with open(os.path.join(mod, "out", "artifact.json"), "w") as fh:
                json.dump({}, fh)
            self.assertFalse(criteria.crit_handoff(mod)["passed"])


# --------------------------------------------------------------------------- #
# 2. Scoring — renormalisation par archétype + verdict.                        #
# --------------------------------------------------------------------------- #
class ScoringTest(unittest.TestCase):
    def setUp(self):
        self.spec = _spec()
        self._orig_root = scoring.DELIVERABLES_ROOT

    def tearDown(self):
        scoring.DELIVERABLES_ROOT = self._orig_root

    def _score(self, archetype, **kw):
        d = tempfile.mkdtemp()
        self.addCleanup(lambda: __import__("shutil").rmtree(d, ignore_errors=True))
        _make_module(d, **kw)
        scoring.DELIVERABLES_ROOT = d
        return scoring.score_module(self.spec, {
            "id": "x", "path": "mod", "sprint": "S9", "archetype": archetype})

    def test_generator_full_is_100(self):
        r = self._score("generator")
        self.assertEqual(r["score"], 100)
        self.assertEqual(r["verdict"], "PASS")
        self.assertEqual(r["applicable_weight"], 100)

    def test_generator_missing_handoff_fails(self):
        r = self._score("generator", handoff=False)
        self.assertEqual(r["score"], 80)   # 80/100
        self.assertEqual(r["verdict"], "FAIL")

    def test_contract_excludes_cli_and_handoff(self):
        r = self._score("contract", cli=False, handoff=False)
        self.assertEqual(r["applicable_weight"], 65)   # DOC20+CONTRAT20+TESTS25
        self.assertEqual(r["score"], 100)
        self.assertEqual({c["criterion"] for c in r["checks"]},
                         {"DOC", "CONTRAT", "TESTS"})

    def test_parser_excludes_contrat(self):
        r = self._score("parser", schema=False, handoff=False)
        self.assertEqual({c["criterion"] for c in r["checks"]},
                         {"DOC", "TESTS", "CLI"})
        self.assertEqual(r["score"], 100)

    def test_data_room_missing_cli_drops_below_threshold(self):
        r = self._score("data_room", cli=False, handoff=False)
        # applicable DOC20+CONTRAT20+TESTS25+CLI15 = 80 ; earned 65 → 81
        self.assertEqual(r["applicable_weight"], 80)
        self.assertEqual(r["score"], 81)
        self.assertEqual(r["verdict"], "FAIL")

    def test_missing_module_dir_raises(self):
        scoring.DELIVERABLES_ROOT = "/does/not/exist"
        with self.assertRaises(FileNotFoundError):
            scoring.score_module(self.spec, {
                "id": "x", "path": "nope", "sprint": "S9",
                "archetype": "generator"})

    def test_round_half_up_is_deterministic(self):
        self.assertEqual(scoring._round_half_up(80.5), 81)
        self.assertEqual(scoring._round_half_up(81.25), 81)


# --------------------------------------------------------------------------- #
# 3. Couverture 100% vs CI (preuve, pas déclaration).                          #
# --------------------------------------------------------------------------- #
class CoverageTest(unittest.TestCase):
    def test_real_coverage_is_bijective(self):
        cov = registry.coverage_report(_spec())
        self.assertTrue(cov["ok"], cov)
        self.assertEqual(cov["missing_in_registry"], [])
        self.assertEqual(cov["missing_in_ci"], [])
        self.assertEqual(cov["not_in_gate"], [])
        self.assertEqual(cov["ci_modules_count"], cov["registry_modules_count"])

    def test_auditor_excluded_from_scope(self):
        cov = registry.coverage_report(_spec())
        self.assertEqual(cov["self_module_excluded"], "qa/audit_4big")
        self.assertNotIn("qa/audit_4big",
                         {m["path"] for m in _spec()["modules"]})

    def test_extra_registry_module_flags_missing_in_ci(self):
        spec = _spec()
        spec["modules"].append({"id": "ghost", "path": "ghost/dir",
                                "sprint": "S9", "archetype": "generator",
                                "source": "test"})
        cov = registry.coverage_report(spec)
        self.assertFalse(cov["ok"])
        self.assertIn("ghost/dir", cov["missing_in_ci"])

    def test_dropped_registry_module_flags_missing_in_registry(self):
        spec = _spec()
        spec["modules"] = spec["modules"][:-1]   # retire un module gated
        cov = registry.coverage_report(spec)
        self.assertFalse(cov["ok"])
        self.assertTrue(cov["missing_in_registry"])

    def test_ci_parsing_finds_gate_needs(self):
        ci = registry.parse_ci()
        # le job d'agrégat `gate` liste ses dépendances (needs) — un job de
        # test réel doit y figurer, preuve que le parsing du needs fonctionne.
        self.assertIn("seo-tests", ci["gate_needs"])
        self.assertGreaterEqual(len(ci["gate_needs"]), 17)
        self.assertGreaterEqual(len(ci["job_to_path"]), 17)


# --------------------------------------------------------------------------- #
# 4. Build réel + invariants + schéma + déterminisme.                          #
# --------------------------------------------------------------------------- #
class BuildTest(unittest.TestCase):
    def setUp(self):
        self.spec = _spec()
        self.report = builder.build(self.spec)

    def test_real_build_passes_gate(self):
        self.assertEqual(self.report["verdict"], "PASS")
        self.assertEqual(self.report["totals"]["fail"], 0)
        self.assertEqual(self.report["totals"]["modules"], len(self.spec["modules"]))
        self.assertGreaterEqual(self.report["totals"]["min_score"], 95)

    def test_invariants_clean_on_real_report(self):
        errs = gen.check_invariants(self.report, self.spec)
        self.assertEqual(errs, [], errs)

    def test_schema_validates(self):
        with open(os.path.join(_MOD, "quality.schema.json"), encoding="utf-8") as fh:
            schema = json.load(fh)
        from q4lib.deps import validate
        self.assertEqual(validate(self.report, schema), [])

    def test_determinism(self):
        again = builder.build(_spec())
        self.assertEqual(json.dumps(self.report, sort_keys=True),
                         json.dumps(again, sort_keys=True))

    def test_pass_score_is_95(self):
        self.assertEqual(self.report["pass_score"], 95)
        self.assertEqual(self.spec["thresholds"]["pass_score"], 95)

    def test_every_module_path_exists(self):
        from q4lib.deps import DELIVERABLES_ROOT
        for m in self.report["modules"]:
            self.assertTrue(
                os.path.isdir(os.path.join(DELIVERABLES_ROOT, m["path"])),
                m["path"])


# --------------------------------------------------------------------------- #
# 5. Invariants — détection d'une note fabriquée / d'un module en échec.       #
# --------------------------------------------------------------------------- #
class InvariantGuardTest(unittest.TestCase):
    def setUp(self):
        self.spec = _spec()
        self.report = builder.build(self.spec)

    def test_fabricated_score_is_caught(self):
        bad = copy.deepcopy(self.report)
        # On force une note sans toucher aux checks → note non recomputable.
        bad["modules"][0]["score"] = 60
        errs = gen.check_invariants(bad, self.spec)
        self.assertTrue(any("INV6" in e or "INV7" in e for e in errs), errs)

    def test_failing_module_forces_global_fail(self):
        bad = copy.deepcopy(self.report)
        m = bad["modules"][0]
        # On retire un check passant → earned baisse, note recomputée < 95.
        for c in m["checks"]:
            if c["passed"]:
                c["passed"] = False
                break
        m["earned_weight"] = sum(c["weight"] for c in m["checks"] if c["passed"])
        m["score"] = scoring._round_half_up(
            100.0 * m["earned_weight"] / m["applicable_weight"])
        m["verdict"] = "PASS" if m["score"] >= 95 else "FAIL"
        bad["totals"]["fail"] = sum(1 for x in bad["modules"]
                                    if x["verdict"] != "PASS")
        bad["totals"]["pass"] = 18 - bad["totals"]["fail"]
        bad["verdict"] = "FAIL"
        errs = gen.check_invariants(bad, self.spec)
        # Le rapport est cohérent en interne mais porte un FAIL → INV7 le signale.
        self.assertTrue(any("< seuil 4Big" in e for e in errs), errs)

    def test_self_module_in_scope_is_caught(self):
        bad = copy.deepcopy(self.report)
        bad["modules"].append(copy.deepcopy(bad["modules"][0]))
        bad["modules"][-1]["path"] = self.spec["self_module"]
        errs = gen.check_invariants(bad, self.spec)
        self.assertTrue(any("INV4" in e for e in errs), errs)

    def test_weights_sum_invariant(self):
        bad_spec = copy.deepcopy(self.spec)
        bad_spec["criteria"][0]["weight"] = 999
        errs = gen.check_invariants(self.report, bad_spec)
        self.assertTrue(any("INV2" in e for e in errs), errs)

    def test_cli_build_and_validate_exit_zero(self):
        self.assertEqual(gen.main(["validate"]), 0)
        with tempfile.TemporaryDirectory() as d:
            self.assertEqual(gen.main(["build", "-o", d]), 0)
            self.assertTrue(os.path.isfile(os.path.join(d, "quality_report.json")))
            self.assertTrue(os.path.isfile(os.path.join(d, "MANIFEST.json")))


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