"""Tests for the gated PR close path (Issue #216).

``gitea_edit_pr(state='closed')`` requires the distinct ``gitea.pr.close``
capability: without it the close fails closed (no auth lookup, no API call,
structured permission report). With it — the explicit operator-directed
contaminated-PR closure path — the close proceeds and is audited as a
distinct ``close_pr`` action carrying the required capability, so final
reports can prove exactly which mutation capability was exercised.

Ordinary edits (title/body/base) and reopening never require the close
capability: PR comment, PR edit, and PR close remain distinct capabilities.
"""
import sys
import unittest
from unittest.mock import patch

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

import mcp_server
from mcp_server import gitea_edit_pr

FAKE_AUTH = "token fake"

AUTHOR_NO_CLOSE = {
    "profile_name": "prgs-author",
    "allowed_operations": [
        "gitea.read", "gitea.pr.create", "gitea.pr.comment",
        "gitea.branch.push", "gitea.issue.comment",
    ],
    "forbidden_operations": ["gitea.pr.approve", "gitea.pr.merge"],
    "audit_label": "prgs-author",
}

AUTHOR_WITH_CLOSE = {
    "profile_name": "prgs-author-closer",
    "allowed_operations": AUTHOR_NO_CLOSE["allowed_operations"] + ["gitea.pr.close"],
    "forbidden_operations": ["gitea.pr.approve", "gitea.pr.merge"],
    "audit_label": "prgs-author-closer",
}


class TestEditPrCloseGate(unittest.TestCase):

    def setUp(self):
        self.mock_api = patch("mcp_server.api_request").start()
        self.mock_auth = patch("mcp_server.get_auth_header", return_value=FAKE_AUTH).start()
        # Deterministic permission reports: no real operator config, no switching.
        patch("gitea_config.load_config", return_value={}).start()
        patch("gitea_config.is_runtime_switching_enabled", return_value=False).start()
        patch("gitea_audit.audit_enabled", return_value=False).start()
        mcp_server._IDENTITY_CACHE.clear()

    def tearDown(self):
        patch.stopall()
        mcp_server._IDENTITY_CACHE.clear()

    def _set_profile(self, profile):
        patch("mcp_server.get_profile", return_value=profile).start()

    def test_close_blocked_without_close_capability(self):
        # Author profile without gitea.pr.close: the broad edit path must not
        # be usable as an untracked close fallback.
        self._set_profile(AUTHOR_NO_CLOSE)
        res = gitea_edit_pr(pr_number=205, state="closed", remote="prgs")
        self.assertFalse(res["success"])
        self.assertFalse(res["performed"])
        self.assertEqual(res["requested_state"], "closed")
        self.assertEqual(res["required_permission"], "gitea.pr.close")
        self.assertTrue(res["reasons"])
        report = res["permission_report"]
        self.assertEqual(report["required_permission"], "gitea.pr.close")
        self.assertEqual(report["active_profile"], "prgs-author")
        self.mock_api.assert_not_called()
        self.mock_auth.assert_not_called()

    def test_close_blocked_when_close_forbidden(self):
        profile = dict(AUTHOR_WITH_CLOSE)
        profile["forbidden_operations"] = ["gitea.pr.close"]
        self._set_profile(profile)
        res = gitea_edit_pr(pr_number=205, state="closed", remote="prgs")
        self.assertFalse(res["success"])
        self.assertIn("profile forbids 'gitea.pr.close'", res["reasons"])
        self.mock_api.assert_not_called()

    def test_close_blocked_when_profile_unresolvable(self):
        patch("mcp_server.get_profile", side_effect=RuntimeError("no profile")).start()
        res = gitea_edit_pr(pr_number=205, state="closed", remote="prgs")
        self.assertFalse(res["success"])
        self.assertTrue(any("fail closed" in r for r in res["reasons"]))
        self.mock_api.assert_not_called()

    def test_operator_directed_close_allowed_and_audited(self):
        # Explicit operator-directed contaminated-PR closure: the operator
        # granted gitea.pr.close, so the close proceeds and the audit trail
        # records a distinct close_pr action with the capability proof.
        self._set_profile(AUTHOR_WITH_CLOSE)
        patch("gitea_audit.audit_enabled", return_value=True).start()
        mock_write = patch("gitea_audit.write_event").start()

        def api_side_effect(method, url, auth, payload=None):
            if method == "GET" and "/user" in url:
                return {"login": "jcwalker3"}
            if method == "PATCH" and "pulls/205" in url:
                self.assertEqual(payload["state"], "closed")
                return {
                    "number": 205,
                    "title": "Contaminated PR",
                    "state": "closed",
                    "html_url": "url",
                    "body": "No issue link",
                    "head": {"ref": "feat/invalid-provenance"},
                }
            return {}
        self.mock_api.side_effect = api_side_effect

        res = gitea_edit_pr(pr_number=205, state="closed", remote="prgs")
        self.assertTrue(res["success"])
        self.assertEqual(res["state"], "closed")
        mock_write.assert_called()
        event = mock_write.call_args[0][0]
        self.assertEqual(event["action"], "close_pr")
        self.assertEqual(
            event["request_metadata"]["required_permission"], "gitea.pr.close")

    def test_title_edit_needs_no_close_capability(self):
        # PR edit and PR close are distinct capabilities: retitling stays on
        # the ordinary edit path.
        self._set_profile(AUTHOR_NO_CLOSE)
        self.mock_api.return_value = {
            "number": 7, "title": "Renamed", "state": "open",
            "body": "", "html_url": "u"}
        res = gitea_edit_pr(pr_number=7, title="Renamed", remote="prgs")
        self.assertTrue(res["success"])

    def test_reopen_needs_no_close_capability(self):
        self._set_profile(AUTHOR_NO_CLOSE)
        self.mock_api.return_value = {
            "number": 7, "title": "T", "state": "open",
            "body": "", "html_url": "u"}
        res = gitea_edit_pr(pr_number=7, state="open", remote="prgs")
        self.assertTrue(res["success"])

    def test_invalid_state_fails_closed_before_auth(self):
        # A case-variant state can neither bypass the close gate nor reach
        # the API: it is rejected as pure validation, before auth.
        self._set_profile(AUTHOR_WITH_CLOSE)
        with self.assertRaises(ValueError):
            gitea_edit_pr(pr_number=7, state="CLOSED", remote="prgs")
        self.mock_api.assert_not_called()
        self.mock_auth.assert_not_called()


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