"""Tests for structured permission failure reports on gated tools (#142).

A blocked gated call must explain itself: which operation was requested,
what permission is missing, which profile/identity is active, whether any
configured profile could perform it, whether runtime switching is
supported, whether a different MCP namespace/session is required, and the
exact safe next action. Fail-closed behavior is unchanged — the report
adds information to denials, never capability. All synthetic; no network.
"""
import json
import os
import sys
import tempfile
import unittest
from unittest.mock import patch

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

import gitea_config
import mcp_server

CONFIG = {
    "version": 2,
    "contexts": {
        "ctx": {
            "enabled": True,
            "gitea": {
                "enabled": True,
                "base_url": "https://gitea.example.com"
            }
        }
    },
    "profiles": {
        "author-profile": {
            "enabled": True,
            "context": "ctx",
            "role": "author",
            "username": "author-user",
            "auth": {"type": "env", "name": "GITEA_TOKEN_AUTHOR"},
            "allowed_operations": [
                "gitea.read", "gitea.pr.create", "gitea.branch.push"],
            "forbidden_operations": ["gitea.pr.approve", "gitea.pr.merge"],
            "execution_profile": "author-profile"
        },
        "reviewer-profile": {
            "enabled": True,
            "context": "ctx",
            "role": "reviewer",
            "username": "reviewer-user",
            "auth": {"type": "env", "name": "GITEA_TOKEN_REVIEWER"},
            "allowed_operations": [
                "gitea.read", "gitea.pr.review", "gitea.pr.approve",
                "gitea.pr.merge", "gitea.issue.comment"],
            "forbidden_operations": ["gitea.pr.create", "gitea.branch.push"],
            "execution_profile": "reviewer-profile"
        }
    },
    "rules": {
        "allow_runtime_switching": False
    }
}

CONFIG_SWITCHING = {**CONFIG, "rules": {"allow_runtime_switching": True}}

PR_PAYLOAD = {
    "user": {"login": "someone-else"},
    "state": "open",
    "head": {"sha": "abc123"},
    "mergeable": True,
}

REPORT_FIELDS = (
    "requested_operation",
    "missing_permission",
    "required_permission",
    "active_profile",
    "active_identity",
    "active_allowed_operations",
    "matching_configured_profiles",
    "runtime_switching_supported",
    "different_mcp_namespace_required",
    "exact_safe_next_action",
)


class PermissionReportBase(unittest.TestCase):
    def setUp(self):
        self._remotes = patch.dict(mcp_server.REMOTES, {
            "prgs": {"host": "gitea.example.com", "org": "Example-Org",
                     "repo": "Example-Repo"},
        })
        self._remotes.start()
        mcp_server._IDENTITY_CACHE.clear()
        gitea_config._active_profile_override = None
        self._dir = tempfile.TemporaryDirectory()
        self.config_path = os.path.join(self._dir.name, "profiles.json")
        self._write_config(CONFIG)
        import review_workflow_load
        review_workflow_load.record_review_workflow_load(mcp_server.PROJECT_ROOT)

    def tearDown(self):
        import review_workflow_load
        review_workflow_load.clear_review_workflow_load()
        self._remotes.stop()
        mcp_server._IDENTITY_CACHE.clear()
        mcp_server.review_workflow_load.clear_review_workflow_load()
        gitea_config._active_profile_override = None
        self._dir.cleanup()

    def _write_config(self, obj):
        with open(self.config_path, "w", encoding="utf-8") as fh:
            fh.write(json.dumps(obj))

    def _env(self, profile="author-profile"):
        return {
            "GITEA_MCP_CONFIG": self.config_path,
            "GITEA_MCP_PROFILE": profile,
            "GITEA_TOKEN_AUTHOR": "author-pass",
            "GITEA_TOKEN_REVIEWER": "reviewer-pass",
        }

    def _assert_report_shape(self, report):
        for field in REPORT_FIELDS:
            self.assertIn(field, report, f"report missing {field!r}")

    def _assert_report_safe(self, report):
        blob = json.dumps(report)
        for banned in ("author-pass", "reviewer-pass", "https://", "http://",
                       "keychain", "Authorization"):
            self.assertNotIn(banned, blob, f"report leaks {banned!r}")


class TestIssueCommentDenialReport(PermissionReportBase):
    def test_missing_issue_comment_permission_gets_report(self):
        with patch.dict(os.environ, self._env("author-profile")):
            res = mcp_server.gitea_create_issue_comment(
                issue_number=7, body="hello", remote="prgs")
        self.assertFalse(res["success"])
        self.assertFalse(res["performed"])
        self.assertTrue(res["reasons"])  # fail-closed reasons preserved
        report = res["permission_report"]
        self._assert_report_shape(report)
        self._assert_report_safe(report)
        self.assertEqual(report["missing_permission"], "gitea.issue.comment")
        self.assertEqual(report["active_profile"], "author-profile")
        self.assertIn("gitea.read", report["active_allowed_operations"])
        self.assertEqual(
            report["matching_configured_profiles"], ["reviewer-profile"])
        self.assertFalse(report["runtime_switching_supported"])
        self.assertTrue(report["different_mcp_namespace_required"])
        self.assertTrue(report["exact_safe_next_action"])

    def test_empty_body_alone_is_not_a_permission_failure(self):
        # reviewer-profile HAS gitea.issue.comment; an empty body must not
        # produce a permission report (it is an input error, not a denial).
        with patch.dict(os.environ, self._env("reviewer-profile")):
            res = mcp_server.gitea_create_issue_comment(
                issue_number=7, body="   ", remote="prgs")
        self.assertFalse(res["success"])
        self.assertNotIn("permission_report", res)

    def test_switching_enabled_changes_guidance(self):
        self._write_config(CONFIG_SWITCHING)
        with patch.dict(os.environ, self._env("author-profile")):
            res = mcp_server.gitea_create_issue_comment(
                issue_number=7, body="hello", remote="prgs")
        report = res["permission_report"]
        self.assertTrue(report["runtime_switching_supported"])
        self.assertFalse(report["different_mcp_namespace_required"])
        self.assertIn("gitea_activate_profile",
                      report["exact_safe_next_action"])


class TestEligibilityDenialReport(PermissionReportBase):
    @patch("mcp_server.api_request")
    @patch("mcp_server.get_auth_header", return_value="token author-pass")
    def test_author_attempting_review_gets_report(self, _auth, mock_api):
        def fake_api(method, url, auth, payload=None):
            if url.endswith("/user"):
                return {"login": "author-user"}
            return PR_PAYLOAD
        mock_api.side_effect = fake_api
        with patch.dict(os.environ, self._env("author-profile")):
            res = mcp_server.gitea_check_pr_eligibility(
                pr_number=42, action="approve", remote="prgs")
        self.assertFalse(res["eligible"])
        report = res["permission_report"]
        self._assert_report_shape(report)
        self._assert_report_safe(report)
        self.assertEqual(report["missing_permission"], "gitea.pr.approve")
        self.assertEqual(report["active_profile"], "author-profile")
        # "active identity if resolved": the #164 fail-fast path performs no
        # identity lookup on a pure permission block, so None is acceptable.
        self.assertIn(report["active_identity"], (None, "author-user"))
        self.assertEqual(
            report["matching_configured_profiles"], ["reviewer-profile"])
        self.assertTrue(report["different_mcp_namespace_required"])

    @patch("mcp_server.api_request")
    @patch("mcp_server.get_auth_header", return_value="token author-pass")
    def test_author_attempting_merge_gets_report(self, _auth, mock_api):
        def fake_api(method, url, auth, payload=None):
            if url.endswith("/user"):
                return {"login": "author-user"}
            return PR_PAYLOAD
        mock_api.side_effect = fake_api
        with patch.dict(os.environ, self._env("author-profile")):
            res = mcp_server.gitea_check_pr_eligibility(
                pr_number=42, action="merge", remote="prgs")
        self.assertFalse(res["eligible"])
        report = res["permission_report"]
        self.assertEqual(report["missing_permission"], "gitea.pr.merge")
        self._assert_report_safe(report)

    @patch("mcp_server.api_request")
    @patch("mcp_server.get_auth_header", return_value="token author-pass")
    def test_eligible_call_has_no_report(self, _auth, mock_api):
        def fake_api(method, url, auth, payload=None):
            if url.endswith("/user"):
                return {"login": "reviewer-user"}
            return PR_PAYLOAD
        mock_api.side_effect = fake_api
        with patch.dict(os.environ, self._env("reviewer-profile")):
            res = mcp_server.gitea_check_pr_eligibility(
                pr_number=42, action="approve", remote="prgs")
        self.assertTrue(res["eligible"])
        self.assertNotIn("permission_report", res)

    @patch("mcp_server.api_request")
    @patch("mcp_server.get_auth_header", return_value="token author-pass")
    def test_review_tool_denial_propagates_report(self, _auth, mock_api):
        def fake_api(method, url, auth, payload=None):
            if url.endswith("/user"):
                return {"login": "author-user"}
            return PR_PAYLOAD
        mock_api.side_effect = fake_api
        from tests.test_mcp_server import _seed_ready_review_decision
        with patch.dict(os.environ, self._env("author-profile")):
            mcp_server.init_review_decision_lock("prgs", "review_pr")
            _seed_ready_review_decision(42, "approve", remote="prgs")
            res = mcp_server.gitea_submit_pr_review(
                pr_number=42, action="approve", body="lgtm", remote="prgs",
                final_review_decision_ready=True)
        self.assertFalse(res["performed"])
        self.assertIn("permission_report", res)
        self.assertEqual(res["permission_report"]["missing_permission"],
                         "gitea.pr.approve")

    @patch("mcp_server.api_request")
    @patch("mcp_server.get_auth_header", return_value="token author-pass")
    def test_merge_tool_denial_propagates_report(self, _auth, mock_api):
        def fake_api(method, url, auth, payload=None):
            if url.endswith("/user"):
                return {"login": "author-user"}
            return PR_PAYLOAD
        mock_api.side_effect = fake_api
        with patch.dict(os.environ, self._env("author-profile")):
            mcp_server.init_review_decision_lock("prgs", "review_pr")
            mcp_server.gitea_load_review_workflow()
            res = mcp_server.gitea_merge_pr(
                pr_number=42, confirmation="MERGE PR 42",
                expected_head_sha="abc123", remote="prgs")
        self.assertFalse(res["performed"])
        self.assertIn("permission_report", res)
        self.assertEqual(res["permission_report"]["missing_permission"],
                         "gitea.pr.merge")


class TestReviewCommentPathUsesCanonicalOp(PermissionReportBase):
    @patch("mcp_server.api_request")
    @patch("mcp_server.get_auth_header", return_value="token author-pass")
    def test_review_comment_denial_names_pr_review_op(self, _auth, mock_api):
        # gitea_submit_pr_review(action="comment") maps to the 'review'
        # eligibility action; the report must name the canonical
        # gitea.pr.review op (never a phantom 'gitea.review'), classify the
        # role as reviewer, and find the configured reviewer profile.
        def fake_api(method, url, auth, payload=None):
            if url.endswith("/user"):
                return {"login": "author-user"}
            return PR_PAYLOAD
        mock_api.side_effect = fake_api
        from tests.test_mcp_server import _seed_ready_review_decision
        with patch.dict(os.environ, self._env("author-profile")):
            mcp_server.init_review_decision_lock("prgs", "review_pr")
            _seed_ready_review_decision(42, "comment", remote="prgs")
            res = mcp_server.gitea_submit_pr_review(
                pr_number=42, action="comment", body="finding", remote="prgs",
                final_review_decision_ready=True)
        self.assertFalse(res["performed"])
        report = res["permission_report"]
        self._assert_report_safe(report)
        self.assertEqual(report["missing_permission"], "gitea.pr.review")
        self.assertEqual(
            report["matching_configured_profiles"], ["reviewer-profile"])
        self.assertIn("reviewer", report["exact_safe_next_action"].lower())


class TestMatchingScanFailsClosed(PermissionReportBase):
    def test_profile_with_bad_forbidden_entry_is_not_reported_matching(self):
        # A profile whose forbidden list cannot be normalized would be
        # refused by the real gate (invalid-forbidden-entry, fail closed);
        # the report must not advertise it as a match.
        config = json.loads(json.dumps(CONFIG))
        config["profiles"]["reviewer-profile"]["forbidden_operations"] = [
            "definitely.not.a.real.op.entry"]
        self._write_config(config)
        with patch.dict(os.environ, self._env("author-profile")):
            report = mcp_server._permission_block_report("gitea.pr.approve")
        self.assertEqual(report["matching_configured_profiles"], [])


class TestReviewerAttemptingAuthoring(PermissionReportBase):
    def test_reviewer_blocked_from_authoring_op_names_author_namespace(self):
        with patch.dict(os.environ, self._env("reviewer-profile")):
            report = mcp_server._permission_block_report("gitea.branch.push")
        self._assert_report_shape(report)
        self._assert_report_safe(report)
        self.assertEqual(report["missing_permission"], "gitea.branch.push")
        self.assertEqual(report["active_profile"], "reviewer-profile")
        self.assertEqual(
            report["matching_configured_profiles"], ["author-profile"])
        self.assertTrue(report["different_mcp_namespace_required"])
        self.assertIn("author", report["exact_safe_next_action"].lower())


class TestUnknownProfileFailsClosed(PermissionReportBase):
    def test_unknown_profile_denial_still_fails_closed_with_safe_report(self):
        with patch.dict(os.environ, self._env("no-such-profile")):
            res = mcp_server.gitea_create_issue_comment(
                issue_number=7, body="hello", remote="prgs")
        self.assertFalse(res["success"])
        self.assertFalse(res["performed"])
        self.assertTrue(res["reasons"])
        report = res["permission_report"]
        self._assert_report_shape(report)
        self._assert_report_safe(report)
        self.assertIsNone(report["active_profile"])
        self.assertTrue(report["different_mcp_namespace_required"])
        self.assertIn("operator", report["exact_safe_next_action"].lower())


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