"""#780: ``status:pr-open`` must not survive a terminal PR transition.

The leak this file locks down: ``gitea_create_pr`` applied ``status:pr-open``
and no terminal path ever removed it, so a repository audit found 40 closed
issues still advertising an open PR that had long since merged or closed.

Coverage mirrors the issue's acceptance criteria: merge, close-without-merge,
supersession, already-landed reconciliation, controller closure, retry /
idempotency, unrelated-label preservation, the only-label (empty set) case,
and terminal validation of any residual label.
"""
from __future__ import annotations

import sys
import unittest
from unittest.mock import patch

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

import mcp_server
import terminal_pr_label_cleanup as tplc


FAKE_AUTH = "token test-token"
PR_OPEN = tplc.PR_OPEN_LABEL


def _lb(name: str, lid: int) -> dict:
    return {"id": lid, "name": name, "color": "000000"}


# ---------------------------------------------------------------------------
# Pure rule: planning
# ---------------------------------------------------------------------------
class TestPlanPrOpenCleanup(unittest.TestCase):

    def test_removes_only_the_pr_open_label(self):
        plan = tplc.plan_pr_open_cleanup(
            ["type:bug", PR_OPEN, "workflow-hardening"],
            terminal_reason=tplc.MERGED,
        )
        self.assertTrue(plan["cleanup_required"])
        self.assertEqual(plan["removed"], [PR_OPEN])
        self.assertEqual(plan["labels_after"], ["type:bug", "workflow-hardening"])

    def test_preserves_unrelated_labels_in_original_order(self):
        labels = ["workflow-hardening", "type:bug", PR_OPEN, "role:author", "leases"]
        plan = tplc.plan_pr_open_cleanup(labels, terminal_reason=tplc.MERGED)
        self.assertEqual(
            plan["labels_after"],
            ["workflow-hardening", "type:bug", "role:author", "leases"],
        )
        self.assertNotIn(PR_OPEN, plan["labels_after"])

    def test_only_label_yields_empty_set(self):
        plan = tplc.plan_pr_open_cleanup([PR_OPEN], terminal_reason=tplc.MERGED)
        self.assertTrue(plan["cleanup_required"])
        self.assertEqual(plan["labels_after"], [])
        self.assertTrue(plan["empty_label_set"])

    def test_absent_label_is_an_idempotent_noop(self):
        plan = tplc.plan_pr_open_cleanup(
            ["type:bug", "status:done"], terminal_reason=tplc.RETRY_RECOVERY
        )
        self.assertFalse(plan["cleanup_required"])
        self.assertTrue(plan["idempotent_noop"])
        self.assertEqual(plan["labels_after"], ["type:bug", "status:done"])

    def test_accepts_gitea_label_objects(self):
        plan = tplc.plan_pr_open_cleanup(
            {"labels": [{"name": PR_OPEN}, {"name": "type:bug"}]},
            terminal_reason=tplc.SUPERSEDED,
        )
        self.assertEqual(plan["labels_after"], ["type:bug"])

    def test_every_terminal_reason_is_planable(self):
        for reason in tplc.TERMINAL_REASONS:
            plan = tplc.plan_pr_open_cleanup([PR_OPEN], terminal_reason=reason)
            self.assertEqual(plan["terminal_reason"], reason)
            self.assertTrue(plan["terminal_reason_description"])

    def test_unknown_terminal_reason_fails_closed(self):
        with self.assertRaises(ValueError):
            tplc.plan_pr_open_cleanup([PR_OPEN], terminal_reason="whenever")

    def test_reason_aliases_normalize(self):
        self.assertEqual(tplc.canonical_terminal_reason("merge"), tplc.MERGED)
        self.assertEqual(
            tplc.canonical_terminal_reason("already-landed"), tplc.ALREADY_LANDED
        )
        self.assertEqual(
            tplc.canonical_terminal_reason("controller-closure"),
            tplc.CONTROLLER_CLOSURE,
        )


# ---------------------------------------------------------------------------
# Pure rule: read-after-write verification
# ---------------------------------------------------------------------------
class TestVerifyPrOpenCleanup(unittest.TestCase):

    def test_verified_when_observed_matches_plan(self):
        plan = tplc.plan_pr_open_cleanup(
            ["type:bug", PR_OPEN], terminal_reason=tplc.MERGED
        )
        result = tplc.verify_pr_open_cleanup(["type:bug"], plan=plan)
        self.assertTrue(result["verified"])
        self.assertFalse(result["residual"])
        self.assertEqual(result["reasons"], [])

    def test_residual_label_is_reported(self):
        plan = tplc.plan_pr_open_cleanup(
            ["type:bug", PR_OPEN], terminal_reason=tplc.MERGED
        )
        result = tplc.verify_pr_open_cleanup(["type:bug", PR_OPEN], plan=plan)
        self.assertFalse(result["verified"])
        self.assertTrue(result["residual"])
        self.assertIn(PR_OPEN, result["reasons"][0])
        self.assertTrue(result["safe_next_action"])

    def test_dropped_unrelated_label_is_reported(self):
        plan = tplc.plan_pr_open_cleanup(
            ["type:bug", "leases", PR_OPEN], terminal_reason=tplc.MERGED
        )
        result = tplc.verify_pr_open_cleanup(["type:bug"], plan=plan)
        self.assertFalse(result["verified"])
        self.assertEqual(result["unexpected_removals"], ["leases"])

    def test_unexpected_added_label_is_reported(self):
        plan = tplc.plan_pr_open_cleanup(
            ["type:bug", PR_OPEN], terminal_reason=tplc.MERGED
        )
        result = tplc.verify_pr_open_cleanup(["type:bug", "surprise"], plan=plan)
        self.assertFalse(result["verified"])
        self.assertEqual(result["unexpected_additions"], ["surprise"])

    def test_empty_observed_set_verifies_for_only_label_case(self):
        plan = tplc.plan_pr_open_cleanup([PR_OPEN], terminal_reason=tplc.MERGED)
        result = tplc.verify_pr_open_cleanup([], plan=plan)
        self.assertTrue(result["verified"])
        self.assertTrue(result["empty_label_set"])


# ---------------------------------------------------------------------------
# Terminal validation
# ---------------------------------------------------------------------------
class TestDetectResidualPrOpen(unittest.TestCase):

    def test_clean_repository(self):
        issues = [
            {"number": 1, "state": "closed", "labels": [{"name": "type:bug"}]},
            {"number": 2, "state": "open", "labels": []},
        ]
        result = tplc.detect_residual_pr_open(issues)
        self.assertTrue(result["clean"])
        self.assertEqual(result["residual_count"], 0)
        self.assertEqual(result["checked_count"], 2)

    def test_reports_each_stale_issue(self):
        issues = [
            {"number": 626, "state": "closed", "labels": [{"name": PR_OPEN}]},
            {"number": 772, "state": "closed", "labels": [{"name": PR_OPEN}]},
            {"number": 9, "state": "open", "labels": [{"name": "type:bug"}]},
        ]
        result = tplc.detect_residual_pr_open(issues)
        self.assertFalse(result["clean"])
        self.assertEqual(result["residual_count"], 2)
        self.assertEqual(
            [entry["number"] for entry in result["residual_issues"]], [626, 772]
        )
        self.assertTrue(result["safe_next_action"])

    def test_issue_with_a_live_open_pr_is_not_residual(self):
        issues = [{"number": 42, "state": "open", "labels": [{"name": PR_OPEN}]}]
        result = tplc.detect_residual_pr_open(issues, open_pr_issue_numbers=[42])
        self.assertTrue(result["clean"])
        self.assertEqual(result["exempt_open_pr_issues"], [42])


# ---------------------------------------------------------------------------
# Executor: one authoritative rule, with read-after-write proof
# ---------------------------------------------------------------------------
class _ExecutorHarness(unittest.TestCase):
    """Drives mcp_server.clear_pr_open_label against a fake Gitea."""

    def setUp(self):
        self.issue_labels: dict[int, list[str]] = {}
        self.repo_labels = {
            PR_OPEN: 4,
            "type:bug": 1,
            "workflow-hardening": 2,
            "leases": 3,
            "status:done": 5,
        }
        self.puts: list[tuple[int, list[int]]] = []

        patch("mcp_server._resolve", return_value=("h", "o", "r")).start()
        patch("mcp_server._auth", return_value=FAKE_AUTH).start()
        patch(
            "mcp_server.repo_api_url",
            return_value="https://gitea.example/api/v1/repos/o/r",
        ).start()
        patch("gitea_audit.audit_enabled", return_value=False).start()
        patch("mcp_server.api_request", side_effect=self._api).start()
        # api_get_all resolves api_request inside gitea_auth, so patching the
        # mcp_server binding alone would let the label inventory hit the network.
        patch("mcp_server.api_get_all", side_effect=self._api_get_all).start()
        self.addCleanup(patch.stopall)

    def _api_get_all(self, url, auth, **_kwargs):
        if "/labels" in url:
            return [_lb(name, lid) for name, lid in self.repo_labels.items()]
        raise AssertionError(f"unexpected paginated GET: {url}")

    def _api(self, method, url, auth, payload=None):
        if method == "GET" and "/issues/" in url:
            num = int(url.rsplit("/issues/", 1)[1].split("?")[0])
            return {
                "number": num,
                "labels": [
                    {"name": n, "id": self.repo_labels[n]}
                    for n in self.issue_labels.get(num, [])
                ],
            }
        if method == "PUT" and url.endswith("/labels"):
            num = int(url.rsplit("/issues/", 1)[1].split("/")[0])
            ids = payload["labels"]
            by_id = {lid: name for name, lid in self.repo_labels.items()}
            names = [by_id[i] for i in ids]
            self.puts.append((num, ids))
            self.issue_labels[num] = names
            return [_lb(n, self.repo_labels[n]) for n in names]
        raise AssertionError(f"unexpected API call: {method} {url}")

    def _clear(self, numbers, reason=tplc.MERGED):
        return mcp_server.clear_pr_open_label(
            numbers, "prgs", None, None, None, terminal_reason=reason
        )


class TestClearPrOpenLabel(_ExecutorHarness):

    def test_removes_label_and_preserves_the_rest(self):
        self.issue_labels[780] = ["type:bug", PR_OPEN, "workflow-hardening"]
        summary = self._clear([780])
        self.assertTrue(summary["clean"])
        self.assertEqual(summary["removed"], [780])
        self.assertEqual(
            self.issue_labels[780], ["type:bug", "workflow-hardening"]
        )

    def test_only_label_results_in_empty_set(self):
        self.issue_labels[626] = [PR_OPEN]
        summary = self._clear([626])
        self.assertTrue(summary["clean"])
        self.assertEqual(self.issue_labels[626], [])
        self.assertEqual(self.puts, [(626, [])])
        self.assertTrue(summary["results"][0]["empty_label_set"])

    def test_read_after_write_proof_is_returned(self):
        self.issue_labels[780] = ["type:bug", PR_OPEN]
        summary = self._clear([780])
        entry = summary["results"][0]
        self.assertTrue(entry["verified"])
        self.assertEqual(entry["labels_before"], ["type:bug", PR_OPEN])
        self.assertEqual(entry["labels_after"], ["type:bug"])
        self.assertEqual(entry["verification"]["observed_labels"], ["type:bug"])

    def test_repeated_cleanup_is_harmless(self):
        self.issue_labels[780] = ["type:bug", PR_OPEN]
        first = self._clear([780])
        second = self._clear([780], reason=tplc.RETRY_RECOVERY)
        third = self._clear([780], reason=tplc.RETRY_RECOVERY)
        self.assertTrue(first["clean"] and second["clean"] and third["clean"])
        self.assertEqual(second["already_absent"], [780])
        self.assertEqual(third["already_absent"], [780])
        # Exactly one mutation across three calls.
        self.assertEqual(len(self.puts), 1)
        self.assertEqual(self.issue_labels[780], ["type:bug"])

    def test_noop_path_never_reads_the_label_inventory(self):
        self.issue_labels[780] = ["type:bug"]
        with patch("mcp_server._repo_label_id_map") as mock_map:
            summary = self._clear([780])
        self.assertTrue(summary["clean"])
        mock_map.assert_not_called()

    def test_duplicate_issue_numbers_are_collapsed(self):
        self.issue_labels[780] = ["type:bug", PR_OPEN]
        summary = self._clear([780, 780, "780"])
        self.assertEqual(summary["checked"], [780])
        self.assertEqual(len(self.puts), 1)

    def test_no_issue_numbers_is_a_clean_noop(self):
        summary = self._clear([])
        self.assertTrue(summary["clean"])
        self.assertEqual(summary["checked"], [])

    def test_failed_mutation_is_reported_not_swallowed(self):
        self.issue_labels[780] = ["type:bug", PR_OPEN]

        def boom(*_a, **_kw):
            raise RuntimeError("gitea exploded")

        with patch("mcp_server._put_issue_label_names", side_effect=boom):
            summary = self._clear([780])
        self.assertFalse(summary["clean"])
        self.assertEqual(summary["failed"], [780])
        self.assertTrue(summary["safe_next_action"])
        self.assertIn(PR_OPEN, self.issue_labels[780])

    def test_residual_label_after_write_fails_verification(self):
        self.issue_labels[780] = ["type:bug", PR_OPEN]
        real_api = self._api

        # Simulate a write that reports success but leaves the label behind.
        def sticky(method, url, auth, payload=None):
            if method == "PUT" and url.endswith("/labels"):
                num = int(url.rsplit("/issues/", 1)[1].split("/")[0])
                self.puts.append((num, payload["labels"]))
                return [_lb("type:bug", 1), _lb(PR_OPEN, 4)]
            return real_api(method, url, auth, payload)

        with patch("mcp_server.api_request", side_effect=sticky):
            summary = self._clear([780])
        self.assertFalse(summary["clean"])
        self.assertEqual(summary["failed"], [780])


# ---------------------------------------------------------------------------
# Terminal workflow paths
# ---------------------------------------------------------------------------
class TestTerminalPathsUseTheSharedRule(_ExecutorHarness):
    """Merge, close-without-merge, supersession and already-landed."""

    def test_merge_path_clears_the_label_for_linked_issues(self):
        self.issue_labels[780] = ["type:bug", PR_OPEN]
        merged_pr = {
            "title": "fix: terminal label cleanup",
            "body": "Closes #780",
            "head": {"ref": "fix/issue-780-terminal-pr-open-label-cleanup"},
        }
        with patch(
            "mcp_server.release_in_progress_label", return_value={780: "released"}
        ):
            result = mcp_server.cleanup_in_progress_for_pr(
                merged_pr, "prgs", None, None, None, terminal_reason=tplc.MERGED
            )
        cleanup = result["pr_open_label_cleanup"]
        self.assertTrue(cleanup["clean"])
        self.assertEqual(cleanup["terminal_reason"], tplc.MERGED)
        self.assertEqual(self.issue_labels[780], ["type:bug"])

    def test_close_without_merge_clears_the_label(self):
        self.issue_labels[781] = ["type:bug", PR_OPEN, "leases"]
        closed_pr = {
            "title": "chore: abandoned",
            "body": "Closes #781",
            "head": {"ref": "chore/issue-781-abandoned"},
        }
        with patch(
            "mcp_server.release_in_progress_label", return_value={781: "released"}
        ):
            result = mcp_server.cleanup_in_progress_for_pr(
                closed_pr,
                "prgs",
                None,
                None,
                None,
                terminal_reason=tplc.CLOSED_WITHOUT_MERGE,
            )
        cleanup = result["pr_open_label_cleanup"]
        self.assertTrue(cleanup["clean"])
        self.assertEqual(cleanup["terminal_reason"], tplc.CLOSED_WITHOUT_MERGE)
        self.assertEqual(self.issue_labels[781], ["type:bug", "leases"])

    def test_pr_without_linked_issue_reports_an_empty_cleanup(self):
        pr = {"title": "chore: no link", "body": "", "head": {"ref": "chore/none"}}
        result = mcp_server.cleanup_in_progress_for_pr(
            pr, "prgs", None, None, None, terminal_reason=tplc.MERGED
        )
        self.assertEqual(result["cleanup_status"], "no linked issue found")
        self.assertTrue(result["pr_open_label_cleanup"]["clean"])
        self.assertEqual(result["pr_open_label_cleanup"]["checked"], [])

    def test_supersession_reason_is_recorded(self):
        self.issue_labels[600] = [PR_OPEN, "type:bug"]
        summary = self._clear([600], reason=tplc.SUPERSEDED)
        self.assertTrue(summary["clean"])
        self.assertEqual(summary["terminal_reason"], tplc.SUPERSEDED)
        self.assertEqual(self.issue_labels[600], ["type:bug"])

    def test_already_landed_reconciliation_reason_is_recorded(self):
        self.issue_labels[601] = [PR_OPEN]
        summary = self._clear([601], reason=tplc.ALREADY_LANDED)
        self.assertTrue(summary["clean"])
        self.assertEqual(summary["terminal_reason"], tplc.ALREADY_LANDED)
        self.assertEqual(self.issue_labels[601], [])

    def test_issue_780_regression_stale_label_survived_every_terminal_path(self):
        """Regression for the observed leak.

        Before the fix each terminal path finished without touching
        ``status:pr-open``, so the audit found closed issues still carrying it.
        Every path now routes through the one shared rule and leaves nothing
        behind — while preserving each issue's other labels.
        """
        stale = {
            626: (["type:bug", PR_OPEN], tplc.CONTROLLER_CLOSURE),
            772: (["workflow-hardening", PR_OPEN], tplc.MERGED),
            768: ([PR_OPEN], tplc.CLOSED_WITHOUT_MERGE),
            758: (["leases", PR_OPEN, "type:bug"], tplc.SUPERSEDED),
            755: (["status:done", PR_OPEN], tplc.ALREADY_LANDED),
        }
        for number, (labels, _reason) in stale.items():
            self.issue_labels[number] = list(labels)

        for number, (_labels, reason) in stale.items():
            summary = self._clear([number], reason=reason)
            self.assertTrue(summary["clean"], msg=f"issue #{number}")

        audit = tplc.detect_residual_pr_open(
            [
                {"number": num, "state": "closed", "labels": names}
                for num, names in self.issue_labels.items()
            ]
        )
        self.assertTrue(audit["clean"])
        self.assertEqual(audit["residual_count"], 0)
        # Unrelated labels survived every path.
        self.assertEqual(self.issue_labels[626], ["type:bug"])
        self.assertEqual(self.issue_labels[772], ["workflow-hardening"])
        self.assertEqual(self.issue_labels[768], [])
        self.assertEqual(self.issue_labels[758], ["leases", "type:bug"])
        self.assertEqual(self.issue_labels[755], ["status:done"])


# ---------------------------------------------------------------------------
# Controller closure
# ---------------------------------------------------------------------------
class TestControllerClosure(unittest.TestCase):

    def test_close_issue_clears_label_before_closing_and_validates(self):
        calls: list[str] = []

        def fake_clear(numbers, *_a, **kwargs):
            calls.append(f"clear:{kwargs['terminal_reason']}")
            return {
                "label": PR_OPEN,
                "clean": True,
                "checked": list(numbers),
                "removed": list(numbers),
                "already_absent": [],
                "failed": [],
                "results": [],
                "reasons": [],
                "safe_next_action": "",
                "terminal_reason": kwargs["terminal_reason"],
            }

        def fake_api(method, url, auth, payload=None):
            if method == "PATCH":
                calls.append("patch:closed")
                return {"state": "closed"}
            return {"labels": [{"name": "type:bug"}]}

        with patch("mcp_server.clear_pr_open_label", side_effect=fake_clear), \
             patch("mcp_server.api_request", side_effect=fake_api), \
             patch("mcp_server._profile_permission_block", return_value=None), \
             patch("mcp_server.verify_preflight_purity", return_value=None), \
             patch("mcp_server.release_in_progress_label", return_value={}), \
             patch("mcp_server._resolve", return_value=("h", "o", "r")), \
             patch("mcp_server._auth", return_value=FAKE_AUTH), \
             patch("gitea_audit.audit_enabled", return_value=False):
            result = mcp_server.gitea_close_issue(issue_number=780, remote="prgs")

        self.assertTrue(result["success"])
        # Cleanup precedes the state change: closing first would bake in the leak.
        self.assertEqual(calls[0], f"clear:{tplc.CONTROLLER_CLOSURE}")
        self.assertIn("patch:closed", calls)
        self.assertTrue(result["terminal_label_validation"]["clean"])

    def test_close_issue_fails_closed_when_cleanup_cannot_complete(self):
        def fake_clear(numbers, *_a, **kwargs):
            return {
                "label": PR_OPEN,
                "clean": False,
                "checked": list(numbers),
                "removed": [],
                "already_absent": [],
                "failed": list(numbers),
                "results": [],
                "reasons": ["label replacement failed: boom"],
                "safe_next_action": "retry",
                "terminal_reason": kwargs["terminal_reason"],
            }

        def fail_on_patch(method, url, auth, payload=None):
            if method == "PATCH":
                raise AssertionError("issue must not be closed when cleanup failed")
            return {}

        with patch("mcp_server.clear_pr_open_label", side_effect=fake_clear), \
             patch("mcp_server.api_request", side_effect=fail_on_patch), \
             patch("mcp_server._profile_permission_block", return_value=None), \
             patch("mcp_server.verify_preflight_purity", return_value=None), \
             patch("mcp_server._resolve", return_value=("h", "o", "r")), \
             patch("mcp_server._auth", return_value=FAKE_AUTH), \
             patch("gitea_audit.audit_enabled", return_value=False):
            result = mcp_server.gitea_close_issue(issue_number=780, remote="prgs")

        self.assertFalse(result["success"])
        self.assertTrue(result["blocked"])
        self.assertFalse(result["performed"])
        self.assertIn("#780", result["message"])
        self.assertTrue(result["safe_next_action"])


# ---------------------------------------------------------------------------
# Capability wiring
# ---------------------------------------------------------------------------
class TestCapabilityWiring(unittest.TestCase):

    def test_task_is_registered_with_label_authority(self):
        import task_capability_map

        self.assertEqual(
            task_capability_map.required_permission("cleanup_terminal_pr_labels"),
            "gitea.issue.comment",
        )
        self.assertEqual(
            task_capability_map.required_role("cleanup_terminal_pr_labels"),
            "author",
        )
        self.assertEqual(
            task_capability_map.tool_required_permission(
                "gitea_cleanup_terminal_pr_labels"
            ),
            "gitea.issue.comment",
        )

    def test_recovery_tool_rejects_an_unknown_reason_without_mutating(self):
        with patch("mcp_server._profile_permission_block", return_value=None), \
             patch("mcp_server.verify_preflight_purity", return_value=None), \
             patch("mcp_server.clear_pr_open_label") as mock_clear:
            result = mcp_server.gitea_cleanup_terminal_pr_labels(
                issue_numbers=[780], terminal_reason="sometime", remote="prgs"
            )
        self.assertFalse(result["success"])
        self.assertFalse(result["clean"])
        mock_clear.assert_not_called()


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