"""Merger lease acquisition + capability resolution tests (#718, #723)."""

from __future__ import annotations

import os
import sys
import unittest
from datetime import datetime, timezone
from unittest.mock import MagicMock, patch

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

import gitea_mcp_server as mcp_server
import reviewer_pr_lease as leases
import task_capability_map as tcm


HEAD = "a" * 40
LIVE_HEAD = HEAD


class TestMergerLeaseAcquireTool(unittest.TestCase):
    def setUp(self):
        mcp_server._preflight_whoami_called = True
        mcp_server._preflight_capability_called = True
        mcp_server._preflight_resolved_role = "merger"
        mcp_server._preflight_resolved_task = "acquire_merger_pr_lease"
        leases.clear_session_lease()

    def tearDown(self):
        leases.clear_session_lease()

    def _merger_profile(self, **extra):
        p = {
            "username": "merger-user",
            "profile_name": "prgs-merger",
            "role": "merger",
            "allowed_operations": [
                "gitea.read",
                "gitea.pr.comment",
                "gitea.pr.merge",
            ],
            "forbidden_operations": [
                "gitea.pr.approve",
                "gitea.pr.review",
                "gitea.pr.request_changes",
            ],
        }
        p.update(extra)
        return p

    @patch("gitea_mcp_server.api_request")
    @patch("gitea_mcp_server._auth", return_value="token pass")
    @patch("gitea_mcp_server._resolve", return_value=("gitea.prgs.cc", "Scaled-Tech-Consulting", "Gitea-Tools"))
    @patch("gitea_mcp_server.get_profile")
    @patch("gitea_mcp_server._fetch_pr_comments", return_value=[])
    @patch("gitea_mcp_server._verify_role_mutation_workspace")
    def test_acquire_merger_lease_success(
        self, _verify, _comments, mock_profile, _resolve, _auth, mock_api
    ):
        mock_profile.return_value = self._merger_profile()
        mock_api.side_effect = [
            {"state": "open", "head": {"sha": LIVE_HEAD}},
            {"id": 456},
        ]
        with patch.dict(os.environ, {"GITEA_MCP_PROFILE_NAME": "prgs-merger"}):
            res = mcp_server.gitea_acquire_merger_pr_lease(
                pr_number=718,
                worktree="branches/issue-718",
                session_id="merger-session",
                candidate_head=HEAD,
                remote="prgs",
                org="Scaled-Tech-Consulting",
                repo="Gitea-Tools",
            )
        self.assertTrue(res["success"], res)
        self.assertTrue(res["acquired"])
        self.assertEqual(res["comment_id"], 456)
        self.assertEqual(res["session_id"], "merger-session")
        self.assertEqual(res.get("repo"), "Scaled-Tech-Consulting/Gitea-Tools")
        body = mock_api.call_args_list[-1][0][3]["body"]
        self.assertIn("reviewer_identity: merger-user", body)
        self.assertIn("profile: prgs-merger", body)
        session_lease = leases._SESSION_LEASE
        self.assertIsNotNone(session_lease)
        provenance = session_lease.get("lease_provenance") or {}
        self.assertEqual(provenance.get("source"), "gitea_acquire_merger_pr_lease")

    @patch("gitea_mcp_server.api_request")
    @patch("gitea_mcp_server._auth", return_value="token pass")
    @patch("gitea_mcp_server._resolve", return_value=("gitea.prgs.cc", "Scaled-Tech-Consulting", "Gitea-Tools"))
    @patch("gitea_mcp_server.get_profile")
    @patch("gitea_mcp_server._fetch_pr_comments")
    @patch("gitea_mcp_server._verify_role_mutation_workspace")
    def test_acquire_merger_lease_fails_if_active_exists(
        self, _verify, _comments, mock_profile, _resolve, _auth, mock_api
    ):
        mock_profile.return_value = self._merger_profile()
        active_body = leases.format_lease_body(
            repo="Scaled-Tech-Consulting/Gitea-Tools",
            pr_number=718,
            issue_number=None,
            reviewer_identity="other-user",
            profile="prgs-reviewer",
            session_id="other-session",
            worktree="branches/review-1",
            phase="claimed",
            candidate_head=HEAD,
            target_branch="master",
            target_branch_sha="b" * 40,
            last_activity=datetime.now(timezone.utc),
        )
        _comments.return_value = [
            {"id": 1, "body": active_body, "user": {"login": "other-user"}}
        ]
        mock_api.return_value = {"state": "open", "head": {"sha": LIVE_HEAD}}
        with patch.dict(os.environ, {"GITEA_MCP_PROFILE_NAME": "prgs-merger"}):
            res = mcp_server.gitea_acquire_merger_pr_lease(
                pr_number=718,
                worktree="branches/issue-718",
                session_id="merger-session",
                candidate_head=HEAD,
            )
        self.assertFalse(res["success"])
        self.assertFalse(res["acquired"])
        self.assertIn("already has active reviewer lease", " ".join(res["reasons"]))
        self.assertEqual(
            [c for c in mock_api.call_args_list if c[0][0] == "POST"], []
        )
        self.assertIsNone(leases._SESSION_LEASE)

    @patch("gitea_mcp_server.get_profile")
    @patch("gitea_mcp_server._verify_role_mutation_workspace")
    def test_acquire_merger_lease_fails_workspace_binding(self, _verify, mock_profile):
        mock_profile.return_value = self._merger_profile()
        _verify.side_effect = RuntimeError("Branches-only mutation guard")
        with patch.dict(os.environ, {"GITEA_MCP_PROFILE_NAME": "prgs-merger"}):
            res = mcp_server.gitea_acquire_merger_pr_lease(
                pr_number=718,
                worktree="branches/issue-718",
                session_id="merger-session",
                candidate_head=HEAD,
            )
        self.assertFalse(res["success"])
        self.assertIn("Branches-only mutation guard", res["reasons"][0])
        self.assertIsNone(leases._SESSION_LEASE)

    @patch("gitea_mcp_server.api_request")
    @patch("gitea_mcp_server._auth", return_value="token pass")
    @patch("gitea_mcp_server._resolve", return_value=("gitea.prgs.cc", "Scaled-Tech-Consulting", "Gitea-Tools"))
    @patch("gitea_mcp_server.get_profile")
    @patch("gitea_mcp_server._fetch_pr_comments", return_value=[])
    @patch("gitea_mcp_server._verify_role_mutation_workspace")
    def test_acquire_merger_forwards_explicit_org_repo_to_workspace_verify(
        self, mock_verify, _comments, mock_profile, _resolve, _auth, mock_api
    ):
        """Explicit org/repo must reach anti-stomp (prgs bare default is Timesheet)."""
        mock_profile.return_value = self._merger_profile()
        mock_api.side_effect = [
            {"state": "open", "head": {"sha": LIVE_HEAD}},
            {"id": 789},
        ]
        with patch.dict(os.environ, {"GITEA_MCP_PROFILE_NAME": "prgs-merger"}):
            res = mcp_server.gitea_acquire_merger_pr_lease(
                pr_number=718,
                worktree="branches/issue-718",
                session_id="merger-session",
                candidate_head=HEAD,
                remote="prgs",
                org="Scaled-Tech-Consulting",
                repo="Gitea-Tools",
            )
        self.assertTrue(res["success"], res)
        kwargs = mock_verify.call_args.kwargs
        self.assertEqual(kwargs.get("org"), "Scaled-Tech-Consulting")
        self.assertEqual(kwargs.get("repo"), "Gitea-Tools")
        self.assertEqual(kwargs.get("task"), "acquire_merger_pr_lease")

    @patch("gitea_mcp_server.get_profile")
    def test_acquire_merger_requires_candidate_head(self, mock_profile):
        mock_profile.return_value = self._merger_profile()
        res = mcp_server.gitea_acquire_merger_pr_lease(
            pr_number=718,
            worktree="branches/issue-718",
            candidate_head=None,
        )
        self.assertFalse(res["success"])
        self.assertTrue(any("candidate_head is required" in r for r in res["reasons"]))

    @patch("gitea_mcp_server.api_request")
    @patch("gitea_mcp_server._auth", return_value="token pass")
    @patch("gitea_mcp_server._resolve", return_value=("gitea.prgs.cc", "Scaled-Tech-Consulting", "Gitea-Tools"))
    @patch("gitea_mcp_server.get_profile")
    @patch("gitea_mcp_server._fetch_pr_comments", return_value=[])
    @patch("gitea_mcp_server._verify_role_mutation_workspace")
    def test_acquire_merger_head_mismatch(
        self, _verify, _comments, mock_profile, _resolve, _auth, mock_api
    ):
        mock_profile.return_value = self._merger_profile()
        mock_api.return_value = {"state": "open", "head": {"sha": "b" * 40}}
        res = mcp_server.gitea_acquire_merger_pr_lease(
            pr_number=718,
            worktree="branches/issue-718",
            candidate_head=HEAD,
        )
        self.assertFalse(res["success"])
        self.assertTrue(any("does not match live PR head" in r for r in res["reasons"]))
        self.assertIsNone(leases._SESSION_LEASE)

    @patch("gitea_mcp_server.api_request")
    @patch("gitea_mcp_server._auth", return_value="token pass")
    @patch("gitea_mcp_server._resolve", return_value=("gitea.prgs.cc", "Scaled-Tech-Consulting", "Gitea-Tools"))
    @patch("gitea_mcp_server.get_profile")
    @patch("gitea_mcp_server._fetch_pr_comments", return_value=[])
    @patch("gitea_mcp_server._verify_role_mutation_workspace")
    def test_acquire_merger_fails_closed_when_live_head_unresolvable(
        self, _verify, _comments, mock_profile, _resolve, _auth, mock_api
    ):
        """Empty/missing live head must not silently proceed past exact-head gate."""
        mock_profile.return_value = self._merger_profile()
        # PR payload present but head.sha missing / empty / non-dict.
        mock_api.return_value = {"state": "open", "head": {}}
        res = mcp_server.gitea_acquire_merger_pr_lease(
            pr_number=718,
            worktree="branches/issue-718",
            session_id="merger-session",
            candidate_head=HEAD,
        )
        self.assertFalse(res["success"], res)
        self.assertFalse(res.get("acquired"), res)
        self.assertTrue(
            any("live PR head could not be resolved" in r for r in res["reasons"]),
            res,
        )
        self.assertIsNone(res.get("live_head"))
        self.assertEqual(res.get("candidate_head"), HEAD)
        self.assertEqual(
            [c for c in mock_api.call_args_list if c[0][0] == "POST"], []
        )
        self.assertIsNone(leases._SESSION_LEASE)

        # Completely empty GET /pulls body also fails closed.
        mock_api.return_value = {}
        res2 = mcp_server.gitea_acquire_merger_pr_lease(
            pr_number=718,
            worktree="branches/issue-718",
            candidate_head=HEAD,
        )
        self.assertFalse(res2["success"], res2)
        self.assertTrue(
            any("live PR head could not be resolved" in r for r in res2["reasons"]),
            res2,
        )
        self.assertIsNone(leases._SESSION_LEASE)

    @patch("gitea_mcp_server._profile_operation_gate")
    def test_author_denied_merge_permission(self, mock_gate):
        """Author profile lacks gitea.pr.merge → fail closed before mutation."""
        def gate(op):
            if op == "gitea.pr.merge":
                return [f"profile lacks {op}"]
            return None
        mock_gate.side_effect = gate
        res = mcp_server.gitea_acquire_merger_pr_lease(
            pr_number=718,
            worktree="branches/issue-718",
            candidate_head=HEAD,
        )
        self.assertFalse(res["success"])
        self.assertTrue(any("gitea.pr.merge" in r for r in res["reasons"]))

    @patch("gitea_mcp_server._profile_operation_gate")
    def test_reviewer_denied_merge_permission(self, mock_gate):
        def gate(op):
            if op == "gitea.pr.merge":
                return [f"profile lacks {op}"]
            return None
        mock_gate.side_effect = gate
        res = mcp_server.gitea_acquire_merger_pr_lease(
            pr_number=718,
            worktree="branches/issue-718",
            candidate_head=HEAD,
        )
        self.assertFalse(res["success"])
        self.assertTrue(any("gitea.pr.merge" in r for r in res["reasons"]))


class TestCapabilityMapLeaseTasks(unittest.TestCase):
    def test_merger_acquire_map_entries(self):
        for task in ("acquire_merger_pr_lease", "gitea_acquire_merger_pr_lease"):
            self.assertEqual(tcm.required_permission(task), "gitea.pr.comment")
            self.assertEqual(tcm.required_role(task), "merger")

    def test_reviewer_acquire_map_entries(self):
        for task in ("acquire_reviewer_pr_lease", "gitea_acquire_reviewer_pr_lease"):
            self.assertEqual(tcm.required_permission(task), "gitea.pr.comment")
            self.assertEqual(tcm.required_role(task), "reviewer")

    def test_adopt_merger_aliases(self):
        for task in ("adopt_merger_pr_lease", "gitea_adopt_merger_pr_lease"):
            self.assertEqual(tcm.required_role(task), "merger")


class TestResolveTaskCapabilityLeaseAndUnknown(unittest.TestCase):
    def setUp(self):
        mcp_server._process_boot_head_sha = None
        if hasattr(mcp_server, "capability_stop_terminal"):
            mcp_server.capability_stop_terminal.clear()

    @patch.object(mcp_server, "record_mutation_authority", return_value=None)
    @patch.object(mcp_server, "init_review_decision_lock", return_value=None)
    @patch.object(mcp_server, "record_preflight_check", return_value=None)
    @patch.object(mcp_server, "_ensure_matching_profile", return_value=None)
    @patch.object(mcp_server, "_authenticated_username", return_value="sysadmin")
    @patch.object(mcp_server, "get_profile")
    @patch.object(mcp_server.gitea_config, "load_config")
    def test_resolve_acquire_reviewer_authorized(
        self, mock_cfg, mock_profile, *_mocks
    ):
        mock_profile.return_value = {
            "profile_name": "prgs-reviewer",
            "role": "reviewer",
            "allowed_operations": ["gitea.pr.comment", "gitea.read", "gitea.pr.review"],
            "forbidden_operations": [],
        }
        mock_cfg.return_value = {
            "profiles": {
                "prgs-reviewer": {
                    "role": "reviewer",
                    "allowed_operations": [
                        "gitea.pr.comment",
                        "gitea.read",
                        "gitea.pr.review",
                    ],
                    "forbidden_operations": [],
                }
            }
        }
        with patch.dict(os.environ, {"GITEA_MCP_PROFILE": "prgs-reviewer"}, clear=False):
            for task in ("acquire_reviewer_pr_lease", "gitea_acquire_reviewer_pr_lease"):
                res = mcp_server.gitea_resolve_task_capability(task=task, remote="prgs")
                self.assertEqual(res["required_role_kind"], "reviewer", res)
                self.assertEqual(
                    res["required_operation_permission"], "gitea.pr.comment", res
                )
                self.assertTrue(res.get("allowed_in_current_session"), res)

    @patch.object(mcp_server, "record_mutation_authority", return_value=None)
    @patch.object(mcp_server, "init_review_decision_lock", return_value=None)
    @patch.object(mcp_server, "record_preflight_check", return_value=None)
    @patch.object(mcp_server, "_ensure_matching_profile", return_value=None)
    @patch.object(mcp_server, "_authenticated_username", return_value="jcwalker3")
    @patch.object(mcp_server, "get_profile")
    @patch.object(mcp_server.gitea_config, "load_config")
    def test_resolve_acquire_reviewer_author_denied(
        self, mock_cfg, mock_profile, *_mocks
    ):
        mock_profile.return_value = {
            "profile_name": "prgs-author",
            "role": "author",
            "allowed_operations": ["gitea.pr.comment", "gitea.branch.push", "gitea.read"],
            "forbidden_operations": [],
        }
        mock_cfg.return_value = {
            "profiles": {
                "prgs-author": {
                    "role": "author",
                    "allowed_operations": [
                        "gitea.pr.comment",
                        "gitea.branch.push",
                        "gitea.read",
                    ],
                    "forbidden_operations": [],
                },
                "prgs-reviewer": {
                    "role": "reviewer",
                    "allowed_operations": ["gitea.pr.comment", "gitea.pr.review"],
                    "forbidden_operations": [],
                },
            }
        }
        with patch.dict(os.environ, {"GITEA_MCP_PROFILE": "prgs-author"}, clear=False):
            res = mcp_server.gitea_resolve_task_capability(
                task="acquire_reviewer_pr_lease", remote="prgs"
            )
        self.assertFalse(res.get("allowed_in_current_session"), res)
        self.assertEqual(res["required_role_kind"], "reviewer")
        guidance = " ".join(res.get("task_role_guidance") or [])
        self.assertTrue(
            "cannot perform reviewer" in guidance.lower()
            or "reviewer" in guidance.lower()
            or res.get("stop_required"),
            res,
        )

    @patch.object(mcp_server, "record_mutation_authority", return_value=None)
    @patch.object(mcp_server, "init_review_decision_lock", return_value=None)
    @patch.object(mcp_server, "record_preflight_check", return_value=None)
    @patch.object(mcp_server, "_ensure_matching_profile", return_value=None)
    @patch.object(mcp_server, "_authenticated_username", return_value="sysadmin")
    @patch.object(mcp_server, "get_profile")
    def test_resolve_unknown_task_structured_no_raise(self, mock_profile, *_mocks):
        mock_profile.return_value = {
            "profile_name": "prgs-reviewer",
            "role": "reviewer",
            "allowed_operations": ["gitea.pr.comment"],
            "forbidden_operations": [],
        }
        # Must not raise ValueError into the tool boundary.
        res = mcp_server.gitea_resolve_task_capability(
            task="some_made_up_task", remote="prgs"
        )
        self.assertIsInstance(res, dict)
        self.assertEqual(res.get("reason_code"), "unknown_task", res)
        self.assertFalse(res.get("allowed_in_current_session"))
        self.assertTrue(res.get("stop_required"))
        self.assertIs(res.get("mutation_performed"), False)
        self.assertNotIn("token", str(res).lower())
        self.assertNotIn("password", str(res).lower())
        guidance = " ".join(res.get("task_role_guidance") or [])
        self.assertIn("Unknown task/action", guidance)

    @patch.object(mcp_server, "record_mutation_authority", return_value=None)
    @patch.object(mcp_server, "init_review_decision_lock", return_value=None)
    @patch.object(mcp_server, "record_preflight_check", return_value=None)
    @patch.object(mcp_server, "_ensure_matching_profile", return_value=None)
    @patch.object(mcp_server, "_authenticated_username", return_value="sysadmin")
    @patch.object(mcp_server, "get_profile")
    @patch.object(mcp_server.gitea_config, "load_config")
    def test_resolve_merger_acquire_aliases(
        self, mock_cfg, mock_profile, *_mocks
    ):
        mock_profile.return_value = {
            "profile_name": "prgs-merger",
            "role": "merger",
            "allowed_operations": [
                "gitea.read",
                "gitea.pr.comment",
                "gitea.pr.merge",
            ],
            "forbidden_operations": [],
        }
        mock_cfg.return_value = {
            "profiles": {
                "prgs-merger": {
                    "role": "merger",
                    "allowed_operations": [
                        "gitea.read",
                        "gitea.pr.comment",
                        "gitea.pr.merge",
                    ],
                    "forbidden_operations": [],
                }
            }
        }
        with patch.dict(os.environ, {"GITEA_MCP_PROFILE": "prgs-merger"}, clear=False):
            for task in ("acquire_merger_pr_lease", "gitea_acquire_merger_pr_lease"):
                res = mcp_server.gitea_resolve_task_capability(task=task, remote="prgs")
                self.assertEqual(res["required_role_kind"], "merger", res)
                self.assertEqual(
                    res["required_operation_permission"], "gitea.pr.comment", res
                )


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