"""Tests for the master-parity staleness gate (#420).

Covers the pure assessment logic, the mutation-block helper, the env escape
hatches, and the server-side wiring: reads are never blocked by staleness, a
mutation is refused when the on-disk master has advanced, and the read-only
gitea_assess_master_parity tool reports the stale state.
"""
import os
import sys
import unittest
from unittest.mock import patch

sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))

import master_parity_gate as mp  # noqa: E402


SHA_A = "a" * 40
SHA_B = "b" * 40


class TestAssessMasterParity(unittest.TestCase):
    def test_in_parity_when_heads_match(self):
        res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_A)
        self.assertTrue(res["in_parity"])
        self.assertFalse(res["stale"])
        self.assertFalse(res["restart_required"])
        self.assertTrue(res["determinable"])

    def test_stale_when_master_advanced(self):
        res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_B)
        self.assertFalse(res["in_parity"])
        self.assertTrue(res["stale"])
        self.assertTrue(res["restart_required"])
        self.assertTrue(res["determinable"])
        self.assertTrue(any("restart" in r for r in res["reasons"]))

    def test_missing_startup_head_not_determinable(self):
        res = mp.assess_master_parity({"startup_head": None}, SHA_B)
        self.assertFalse(res["determinable"])
        self.assertFalse(res["stale"])
        self.assertTrue(res["in_parity"])

    def test_missing_current_head_not_determinable(self):
        res = mp.assess_master_parity({"startup_head": SHA_A}, None)
        self.assertFalse(res["determinable"])
        self.assertFalse(res["stale"])
        self.assertTrue(res["in_parity"])

    def test_none_startup_baseline(self):
        res = mp.assess_master_parity(None, SHA_A)
        self.assertFalse(res["determinable"])
        self.assertFalse(res["stale"])


class TestBlockReasonsAndReport(unittest.TestCase):
    def test_stale_produces_block_reasons(self):
        res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_B)
        self.assertTrue(mp.parity_block_reasons(res))

    def test_in_parity_no_block_reasons(self):
        res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_A)
        self.assertEqual(mp.parity_block_reasons(res), [])

    def test_disable_env_suppresses_block(self):
        res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_B)
        with patch.dict(os.environ, {mp.ENV_DISABLE: "1"}):
            self.assertTrue(mp.gate_disabled())
            self.assertEqual(mp.parity_block_reasons(res), [])

    def test_report_shape_when_stale(self):
        res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_B)
        report = mp.parity_report(res)
        self.assertEqual(report["kind"], "server_stale")
        self.assertTrue(report["restart_required"])
        self.assertEqual(report["startup_head"], SHA_A)
        self.assertEqual(report["current_head"], SHA_B)
        self.assertTrue(report["recovery"])


class TestLiveRemoteParity(unittest.TestCase):
    """#610: parity must account for the live remote master, not just local.

    The daemon can be stale relative to the live remote target while the local
    checkout HEAD still matches the daemon's startup commit, so local parity
    reports green even though a mutation would run against outdated code.
    """

    SHA_C = "c" * 40

    def test_distinguishes_three_shas(self):
        res = mp.assess_master_parity(
            {"startup_head": SHA_A}, SHA_A, live_remote_head=SHA_B)
        self.assertEqual(res["daemon_start_head"], SHA_A)
        self.assertEqual(res["local_head"], SHA_A)
        self.assertEqual(res["live_remote_head"], SHA_B)

    def test_mutation_safe_only_when_all_three_match(self):
        res = mp.assess_master_parity(
            {"startup_head": SHA_A}, SHA_A, live_remote_head=SHA_A)
        self.assertTrue(res["mutation_safe"])
        self.assertTrue(res["live_known"])
        self.assertFalse(res["live_stale"])

    def test_live_stale_when_remote_advanced_past_daemon(self):
        # Local checkout still matches the daemon start (local parity green),
        # but the live remote master has advanced -> daemon is live-stale.
        res = mp.assess_master_parity(
            {"startup_head": SHA_A}, SHA_A, live_remote_head=SHA_B)
        self.assertTrue(res["in_parity"])  # local parity still green
        self.assertTrue(res["live_stale"])
        self.assertFalse(res["mutation_safe"])
        self.assertTrue(any("live" in r.lower() for r in res["reasons"]))

    def test_live_unknown_is_not_mutation_safe_but_not_stale(self):
        # Non-goal: unfetchable live remote must not be treated as stale for
        # read-only, but a mutation-safe claim fails closed.
        res = mp.assess_master_parity(
            {"startup_head": SHA_A}, SHA_A, live_remote_head=None)
        self.assertFalse(res["live_known"])
        self.assertFalse(res["mutation_safe"])
        self.assertFalse(res["live_stale"])
        self.assertTrue(res["in_parity"])

    def test_default_live_remote_preserves_legacy_shape(self):
        # Callers that do not supply a live head keep the pre-#610 behavior:
        # in-parity, not live-stale, no live-derived block.
        res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_A)
        self.assertFalse(res["live_stale"])
        self.assertEqual(mp.parity_block_reasons(res), [])


class TestLiveStaleBlockAndReport(unittest.TestCase):
    """#610: live-staleness must block mutations and surface a typed blocker."""

    def test_live_stale_produces_block_reasons(self):
        res = mp.assess_master_parity(
            {"startup_head": SHA_A}, SHA_A, live_remote_head=SHA_B)
        self.assertTrue(mp.parity_block_reasons(res))

    def test_disable_env_suppresses_live_stale_block(self):
        res = mp.assess_master_parity(
            {"startup_head": SHA_A}, SHA_A, live_remote_head=SHA_B)
        with patch.dict(os.environ, {mp.ENV_DISABLE: "1"}):
            self.assertEqual(mp.parity_block_reasons(res), [])

    def test_resolver_disagreement_returns_typed_blocker(self):
        # Parity says local-green, resolver says restart required -> disagreement
        # is a typed, fail-closed blocker naming the resolver as authoritative.
        res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_A)
        blocker = mp.parity_resolver_disagreement(res, resolver_restart_required=True)
        self.assertIsNotNone(blocker)
        self.assertEqual(blocker["kind"], "parity_resolver_disagreement")
        self.assertTrue(blocker["restart_required"])
        self.assertTrue(blocker["resolver_authoritative"])

    def test_no_disagreement_when_resolver_agrees(self):
        res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_A)
        self.assertIsNone(
            mp.parity_resolver_disagreement(res, resolver_restart_required=False))

    def test_live_stale_report_names_live_remote(self):
        res = mp.assess_master_parity(
            {"startup_head": SHA_A}, SHA_A, live_remote_head=SHA_B)
        report = mp.parity_report(res)
        self.assertEqual(report["live_remote_head"], SHA_B)
        self.assertTrue(report["restart_required"])


class TestReadGitHead(unittest.TestCase):
    def test_test_override_takes_precedence(self):
        with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_B}):
            self.assertEqual(mp.read_git_head("/nonexistent"), SHA_B)

    def test_blank_override_is_none(self):
        with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: "   "}):
            self.assertIsNone(mp.read_git_head("/nonexistent"))

    def test_empty_root_is_none(self):
        # No override set; empty root cannot resolve a HEAD.
        env = {k: v for k, v in os.environ.items()
               if k != mp.ENV_TEST_CURRENT_HEAD}
        with patch.dict(os.environ, env, clear=True):
            self.assertIsNone(mp.read_git_head(""))


class TestReadRemoteMasterHead(unittest.TestCase):
    """#610: live remote master head reader (env-overridable, fails to None)."""

    def test_test_override_takes_precedence(self):
        with patch.dict(os.environ, {mp.ENV_TEST_LIVE_REMOTE_HEAD: SHA_B}):
            self.assertEqual(mp.read_remote_master_head("/nonexistent"), SHA_B)

    def test_blank_override_is_none(self):
        with patch.dict(os.environ, {mp.ENV_TEST_LIVE_REMOTE_HEAD: "   "}):
            self.assertIsNone(mp.read_remote_master_head("/nonexistent"))

    def test_unfetchable_remote_is_none(self):
        # No override; a bogus root/remote must fail closed to None, never raise.
        env = {k: v for k, v in os.environ.items()
               if k != mp.ENV_TEST_LIVE_REMOTE_HEAD}
        with patch.dict(os.environ, env, clear=True):
            self.assertIsNone(
                mp.read_remote_master_head("/nonexistent", remote="nope"))


class TestRemoteHeadCache(unittest.TestCase):
    """#610: live remote reads are cached with a TTL to stay off the network.

    The parity gate runs on every mutation and every runtime-context read, so an
    unbounded ``git ls-remote`` per call would be a latency/flakiness regression.
    """

    def setUp(self):
        # These cases intentionally exercise the subprocess/cache path, so they
        # opt out of suite-wide hermetic mode (PR #788 F1).
        self._saved_hermetic = mp.hermetic_test_mode()
        mp.set_hermetic_test_mode(False)
        mp._clear_remote_head_cache()
        env = {
            k: v for k, v in os.environ.items()
            if k not in (mp.ENV_TEST_LIVE_REMOTE_HEAD,
                         mp.ENV_TEST_ALLOW_LIVE_REMOTE_PROBE,
                         "PYTEST_CURRENT_TEST")
        }
        # Allow the probe path under hermetic defenses while still mocking
        # subprocess so no real network call runs.
        env[mp.ENV_TEST_ALLOW_LIVE_REMOTE_PROBE] = "1"
        self._env = patch.dict(os.environ, env, clear=True)
        self._env.start()
        self.addCleanup(self._env.stop)
        self.addCleanup(mp._clear_remote_head_cache)
        self.addCleanup(
            lambda: mp.set_hermetic_test_mode(self._saved_hermetic)
        )

    def _fake_run(self, sha):
        class _R:
            returncode = 0
            stdout = f"{sha}\trefs/heads/master\n"
        calls = {"n": 0}

        def run(*args, **kwargs):
            calls["n"] += 1
            return _R()
        return run, calls

    def test_second_call_within_ttl_uses_cache(self):
        run, calls = self._fake_run(SHA_B)
        with patch.object(mp.subprocess, "run", run):
            a = mp.read_remote_master_head("/repo", remote="prgs", ttl=100)
            b = mp.read_remote_master_head("/repo", remote="prgs", ttl=100)
        self.assertEqual(a, SHA_B)
        self.assertEqual(b, SHA_B)
        self.assertEqual(calls["n"], 1)

    def test_zero_ttl_bypasses_cache(self):
        run, calls = self._fake_run(SHA_B)
        with patch.object(mp.subprocess, "run", run):
            mp.read_remote_master_head("/repo", remote="prgs", ttl=0)
            mp.read_remote_master_head("/repo", remote="prgs", ttl=0)
        self.assertEqual(calls["n"], 2)

    def test_env_override_never_touches_subprocess(self):
        run, calls = self._fake_run(SHA_B)
        with patch.dict(os.environ, {mp.ENV_TEST_LIVE_REMOTE_HEAD: SHA_A}):
            with patch.object(mp.subprocess, "run", run):
                self.assertEqual(
                    mp.read_remote_master_head("/repo", remote="prgs"), SHA_A)
        self.assertEqual(calls["n"], 0)


class TestHermeticLiveRemoteReads(unittest.TestCase):
    """#610 / PR #788 F1/F2: suite hermetic mode never hits the network."""

    def setUp(self):
        self._saved = mp.hermetic_test_mode()
        mp.set_hermetic_test_mode(True)
        mp._clear_remote_head_cache()
        self.addCleanup(lambda: mp.set_hermetic_test_mode(self._saved))
        self.addCleanup(mp._clear_remote_head_cache)

    def test_hermetic_mode_returns_none_without_subprocess(self):
        run_calls = {"n": 0}

        def boom(*args, **kwargs):
            run_calls["n"] += 1
            raise AssertionError("ls-remote must not run under hermetic mode")

        env = {
            k: v for k, v in os.environ.items()
            if k not in (mp.ENV_TEST_LIVE_REMOTE_HEAD,
                         mp.ENV_TEST_ALLOW_LIVE_REMOTE_PROBE)
        }
        with patch.dict(os.environ, env, clear=True):
            with patch.object(mp.subprocess, "run", boom):
                self.assertIsNone(
                    mp.read_remote_master_head("/repo", remote="prgs")
                )
        self.assertEqual(run_calls["n"], 0)

    def test_hermetic_mode_survives_clear_true_env(self):
        """Module flag, not env pin: clear=True cannot re-enable the probe."""
        run_calls = {"n": 0}

        def boom(*args, **kwargs):
            run_calls["n"] += 1
            raise AssertionError("ls-remote must not run after clear=True")

        with patch.dict(os.environ, {}, clear=True):
            with patch.object(mp.subprocess, "run", boom):
                self.assertIsNone(mp.read_remote_master_head("/repo"))
        self.assertEqual(run_calls["n"], 0)

    def test_explicit_override_still_wins_under_hermetic(self):
        run_calls = {"n": 0}

        def boom(*args, **kwargs):
            run_calls["n"] += 1
            raise AssertionError("override must bypass subprocess")

        with patch.dict(os.environ, {mp.ENV_TEST_LIVE_REMOTE_HEAD: SHA_B}):
            with patch.object(mp.subprocess, "run", boom):
                self.assertEqual(
                    mp.read_remote_master_head("/repo"), SHA_B
                )
        self.assertEqual(run_calls["n"], 0)


class TestServerWiring(unittest.TestCase):
    """Integration with the gate choke point in the server namespace."""

    def setUp(self):
        import mcp_server
        self.srv = mcp_server
        # Pin a known startup baseline so the current-HEAD override can diverge.
        self._saved = self.srv._STARTUP_PARITY
        self.srv._STARTUP_PARITY = {"root": self.srv.PROJECT_ROOT,
                                    "startup_head": SHA_A}
        # Keep the live-remote read hermetic (no real ls-remote network call):
        # default the live master to the daemon start so parity is fully green
        # unless a test overrides the live head explicitly (#610).
        self._live_patch = patch.dict(
            os.environ, {mp.ENV_TEST_LIVE_REMOTE_HEAD: SHA_A})
        self._live_patch.start()
        self.addCleanup(self._live_patch.stop)

    def tearDown(self):
        self.srv._STARTUP_PARITY = self._saved

    def test_reads_not_blocked_when_stale(self):
        with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_B}):
            self.assertEqual(self.srv._master_parity_block("gitea.read"), [])

    def test_mutation_blocked_when_stale(self):
        with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_B}):
            reasons = self.srv._master_parity_block("gitea.pr.create")
            self.assertTrue(reasons)
            # The profile-operation choke point surfaces the same block.
            self.assertTrue(self.srv._profile_operation_gate("gitea.pr.create"))

    def test_mutation_allowed_when_in_parity(self):
        with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_A}):
            self.assertEqual(
                self.srv._master_parity_block("gitea.pr.create"), [])

    def test_disable_env_lets_mutation_through(self):
        with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_B,
                                     mp.ENV_DISABLE: "1"}):
            self.assertEqual(
                self.srv._master_parity_block("gitea.pr.create"), [])

    def test_assess_tool_reports_stale(self):
        with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_B}):
            out = self.srv.gitea_assess_master_parity(remote="prgs")
            self.assertTrue(out["stale"])
            self.assertTrue(out["restart_required"])
            self.assertEqual(out["startup_head"], SHA_A)
            self.assertEqual(out["current_head"], SHA_B)
            self.assertIn("report", out)

    def test_assess_tool_reports_in_parity(self):
        with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_A}):
            out = self.srv.gitea_assess_master_parity(remote="prgs")
            self.assertFalse(out["stale"])
            self.assertTrue(out["in_parity"])
            self.assertNotIn("report", out)

    # --- #610: live-remote wiring -------------------------------------------

    def test_live_stale_blocks_mutation_though_local_green(self):
        # Local checkout matches the daemon start (local parity green) but the
        # live remote master has advanced -> mutations must fail closed.
        with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_A,
                                     mp.ENV_TEST_LIVE_REMOTE_HEAD: SHA_B}):
            self.assertEqual(self.srv._master_parity_block("gitea.read"), [])
            self.assertTrue(
                self.srv._master_parity_block("gitea.pr.create"))

    def test_assess_tool_exposes_three_distinct_shas(self):
        with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_A,
                                     mp.ENV_TEST_LIVE_REMOTE_HEAD: SHA_B}):
            out = self.srv.gitea_assess_master_parity(remote="prgs")
            self.assertEqual(out["daemon_start_head"], SHA_A)
            self.assertEqual(out["local_head"], SHA_A)
            self.assertEqual(out["live_remote_head"], SHA_B)
            self.assertTrue(out["live_stale"])
            self.assertFalse(out["mutation_safe"])
            self.assertIn("report", out)

    def test_assess_tool_mutation_safe_when_all_three_match(self):
        with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_A,
                                     mp.ENV_TEST_LIVE_REMOTE_HEAD: SHA_A}):
            out = self.srv.gitea_assess_master_parity(remote="prgs")
            self.assertTrue(out["mutation_safe"])
            self.assertFalse(out["live_stale"])
            self.assertNotIn("report", out)


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