"""Regression tests for create_issue bootstrap (#749).

TDD: these tests define the sanctioned first-mutation path for
``gitea_create_issue`` from a clean canonical control checkout, and prove
the exemption cannot widen to dirty roots, foreign clones, arbitrary
``branches/`` directories, or post-creation author mutations.
"""

from __future__ import annotations

import os
import sys
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch

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

import create_issue_bootstrap as cib  # noqa: E402
import gitea_mcp_server as srv  # noqa: E402
import workflow_scope_guard as wsg  # noqa: E402

FAKE_AUTH = {"Authorization": "token test-token"}
MASTER_SHA = "a" * 40
STALE_SHA = "b" * 40

current_file_path = Path(__file__).resolve()
if "branches" in current_file_path.parts:
    CONTROL_CHECKOUT_ROOT = str(current_file_path.parents[3])
else:
    CONTROL_CHECKOUT_ROOT = str(current_file_path.parents[1])


class TestCreateIssueBootstrapAssessor(unittest.TestCase):
    ROOT = "/repo/Gitea-Tools"

    def test_non_create_issue_task_not_applicable(self):
        res = cib.assess_create_issue_bootstrap(
            workspace_path=self.ROOT,
            canonical_repo_root=self.ROOT,
            current_branch="master",
            head_sha=MASTER_SHA,
            porcelain_status="",
            remote_master_sha=MASTER_SHA,
            task="lock_issue",
        )
        self.assertTrue(res["not_applicable"])
        self.assertFalse(res["allowed"])
        self.assertFalse(res["block"])

    def test_branches_worktree_not_applicable(self):
        res = cib.assess_create_issue_bootstrap(
            workspace_path=f"{self.ROOT}/branches/issue-1-x",
            canonical_repo_root=self.ROOT,
            current_branch="fix/issue-1-x",
            task="create_issue",
        )
        self.assertTrue(res["not_applicable"])
        self.assertFalse(res["allowed"])

    def test_clean_control_checkout_allowed(self):
        res = cib.assess_create_issue_bootstrap(
            workspace_path=self.ROOT,
            canonical_repo_root=self.ROOT,
            current_branch="master",
            head_sha=MASTER_SHA,
            porcelain_status="",
            remote_master_sha=MASTER_SHA,
            task="create_issue",
        )
        self.assertFalse(res["not_applicable"])
        self.assertTrue(res["allowed"])
        self.assertFalse(res["block"])
        self.assertEqual(res["bootstrap_path"], "clean_canonical_control_checkout")
        # Post-create next action must name issue-backed worktree after N exists.
        self.assertIn("branches/issue-<N>-*", res["exact_next_action"])

    def test_tool_alias_gitea_create_issue_allowed(self):
        res = cib.assess_create_issue_bootstrap(
            workspace_path=self.ROOT,
            canonical_repo_root=self.ROOT,
            current_branch="main",
            head_sha=MASTER_SHA,
            porcelain_status="",
            remote_master_sha=MASTER_SHA,
            task="gitea_create_issue",
        )
        self.assertTrue(res["allowed"])

    def test_dirty_control_checkout_blocked(self):
        res = cib.assess_create_issue_bootstrap(
            workspace_path=self.ROOT,
            canonical_repo_root=self.ROOT,
            current_branch="master",
            head_sha=MASTER_SHA,
            porcelain_status=" M gitea_mcp_server.py\n",
            remote_master_sha=MASTER_SHA,
            task="create_issue",
        )
        self.assertTrue(res["block"])
        self.assertFalse(res["allowed"])
        self.assertTrue(any("tracked local edits" in r for r in res["reasons"]))
        # Pre-issue phase: next action must be satisfiable without inventing <N>.
        next_a = res["exact_next_action"] or ""
        self.assertIn("clean accepted base branch", next_a)
        self.assertIn("before the issue exists", next_a)
        # Must not prescribe "bind branches/issue-<N>" as the recovery step.
        self.assertNotIn("Bind an issue-backed worktree", next_a)

    def test_stale_base_blocked(self):
        res = cib.assess_create_issue_bootstrap(
            workspace_path=self.ROOT,
            canonical_repo_root=self.ROOT,
            current_branch="master",
            head_sha=STALE_SHA,
            porcelain_status="",
            remote_master_sha=MASTER_SHA,
            task="create_issue",
        )
        self.assertTrue(res["block"])
        self.assertTrue(any("live master" in r for r in res["reasons"]))

    def test_non_base_branch_blocked(self):
        res = cib.assess_create_issue_bootstrap(
            workspace_path=self.ROOT,
            canonical_repo_root=self.ROOT,
            current_branch="feat/something",
            head_sha=MASTER_SHA,
            porcelain_status="",
            remote_master_sha=MASTER_SHA,
            task="create_issue",
        )
        self.assertTrue(res["block"])

    def test_detached_head_blocked(self):
        res = cib.assess_create_issue_bootstrap(
            workspace_path=self.ROOT,
            canonical_repo_root=self.ROOT,
            current_branch="",
            head_sha=MASTER_SHA,
            porcelain_status="",
            remote_master_sha=MASTER_SHA,
            task="create_issue",
        )
        self.assertTrue(res["block"])
        self.assertTrue(any("detached" in r for r in res["reasons"]))

    def test_foreign_workspace_blocked(self):
        res = cib.assess_create_issue_bootstrap(
            workspace_path="/other/clone",
            canonical_repo_root=self.ROOT,
            current_branch="master",
            head_sha=MASTER_SHA,
            porcelain_status="",
            remote_master_sha=MASTER_SHA,
            task="create_issue",
        )
        self.assertTrue(res["block"])
        self.assertTrue(any("canonical control checkout" in r for r in res["reasons"]))


class TestCreateIssueBootstrapIntegration(unittest.TestCase):
    def setUp(self):
        srv._preflight_whoami_called = True
        srv._preflight_capability_called = True
        srv._preflight_resolved_role = "author"
        srv._preflight_resolved_task = "create_issue"
        srv._preflight_whoami_violation = False
        srv._preflight_capability_violation = False
        self._orig_in_test = srv._preflight_in_test_mode
        srv._preflight_in_test_mode = lambda: False

    def tearDown(self):
        srv._preflight_in_test_mode = self._orig_in_test
        srv._preflight_resolved_task = None

    def _git_state(self, branch="master", head=MASTER_SHA, porcelain=""):
        return {
            "current_branch": branch,
            "head_sha": head,
            "porcelain_status": porcelain,
        }

    @patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
    @patch("gitea_mcp_server._profile_permission_block", return_value=None)
    @patch("gitea_mcp_server._namespace_mutation_block", return_value=None)
    @patch(
        "gitea_mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
        return_value=(True, []),
    )
    @patch("gitea_mcp_server.api_request")
    @patch("gitea_mcp_server.api_get_all", return_value=[])
    @patch(
        "gitea_mcp_server.root_checkout_guard.resolve_remote_master_sha",
        return_value=MASTER_SHA,
    )
    def test_clean_control_checkout_create_issue_succeeds(
        self, _remote_sha, _get_all, mock_api, _role, _ns, _prof, _auth
    ):
        mock_api.return_value = {
            "number": 99,
            "html_url": "https://gitea.example.com/issues/99",
        }
        with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
            with patch(
                "gitea_mcp_server.issue_lock_worktree.read_worktree_git_state",
                return_value=self._git_state(),
            ):
                with patch(
                    "gitea_mcp_server._get_workspace_porcelain", return_value=""
                ):
                    with patch(
                        "gitea_mcp_server._enforce_root_checkout_guard"
                    ):
                        # Anti-stomp / master parity: keep gates green.
                        with patch.object(
                            srv,
                            "_run_anti_stomp_preflight",
                            return_value=None,
                        ):
                            res = srv.gitea_create_issue(
                                title="Bootstrap issue from clean control",
                                body="Body text for content gate.",
                            )
        self.assertEqual(res.get("number"), 99)
        mock_api.assert_called_once()

    @patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
    @patch("gitea_mcp_server._profile_permission_block", return_value=None)
    @patch("gitea_mcp_server._namespace_mutation_block", return_value=None)
    @patch(
        "gitea_mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
        return_value=(True, []),
    )
    @patch("gitea_mcp_server.api_request")
    @patch("gitea_mcp_server.api_get_all", return_value=[])
    def test_dirty_control_checkout_create_issue_fails_closed(
        self, _get_all, mock_api, _role, _ns, _prof, _auth
    ):
        with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
            with patch(
                "gitea_mcp_server.issue_lock_worktree.read_worktree_git_state",
                return_value=self._git_state(
                    porcelain=" M author_mutation_worktree.py\n"
                ),
            ):
                with patch(
                    "gitea_mcp_server._get_workspace_porcelain",
                    return_value=" M author_mutation_worktree.py\n",
                ):
                    res = srv.gitea_create_issue(
                        title="Should fail on dirty root",
                        body="Body text for content gate.",
                    )
        self.assertFalse(res.get("success", True) and res.get("number"))
        if isinstance(res, dict) and res.get("success") is False:
            blob = " ".join(res.get("reasons") or [])
            self.assertTrue(
                "tracked local edits" in blob
                or "dirty" in blob.lower()
                or "control checkout" in blob.lower()
                or res.get("blocker_kind")
            )
            # Pre-issue phase must not demand issue-<N> worktree.
            next_a = res.get("exact_next_action") or ""
            if next_a:
                self.assertNotIn("issue-<N>-*", next_a)
        mock_api.assert_not_called()

    @patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
    @patch("gitea_mcp_server._profile_permission_block", return_value=None)
    @patch("gitea_mcp_server._namespace_mutation_block", return_value=None)
    @patch(
        "gitea_mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
        return_value=(True, []),
    )
    def test_lock_issue_still_requires_branches_worktree(self, _role, _ns, _prof, _auth):
        """Existing issue-backed mutations receive no exemption (#749 AC3/AC7)."""
        srv._preflight_resolved_task = "lock_issue"
        with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
            with patch(
                "gitea_mcp_server.issue_lock_worktree.read_worktree_git_state",
                return_value=self._git_state(),
            ):
                with patch(
                    "gitea_mcp_server._get_workspace_porcelain", return_value=""
                ):
                    with patch(
                        "gitea_mcp_server.root_checkout_guard.resolve_remote_master_sha",
                        return_value=MASTER_SHA,
                    ):
                        with patch.object(
                            srv, "_enforce_root_checkout_guard"
                        ):
                            with self.assertRaises(RuntimeError) as ctx:
                                srv.verify_preflight_purity(
                                    remote="prgs",
                                    task="lock_issue",
                                )
        msg = str(ctx.exception)
        self.assertTrue(
            "Branches-only mutation guard" in msg
            or "stable control checkout" in msg,
            msg,
        )
        self.assertIn("control checkout", msg)

    def test_workflow_scope_skips_missing_worktree_for_create_issue_clean_root(self):
        """#683 root assessor must not block clean-root create_issue bootstrap."""
        res = wsg.assess_root_source_mutation(
            workspace_path=CONTROL_CHECKOUT_ROOT,
            canonical_repo_root=CONTROL_CHECKOUT_ROOT,
            porcelain_status="",
            role_kind="author",
            mutation_task="create_issue",
        )
        self.assertFalse(res["block"], res)
        self.assertTrue(res.get("create_issue_bootstrap") or res["proven"])

    def test_workflow_scope_still_blocks_clean_root_for_lock_issue(self):
        res = wsg.assess_root_source_mutation(
            workspace_path=CONTROL_CHECKOUT_ROOT,
            canonical_repo_root=CONTROL_CHECKOUT_ROOT,
            porcelain_status="",
            role_kind="author",
            mutation_task="lock_issue",
        )
        self.assertTrue(res["block"])
        self.assertEqual(res["blocker_kind"], wsg.BLOCKER_MISSING_WORKTREE)

    def test_arbitrary_branches_directory_not_bootstrap(self):
        """#713: mkdir fake under branches/ is not the bootstrap path."""
        fake = os.path.join(CONTROL_CHECKOUT_ROOT, "branches", "fake-mkdir-only")
        res = cib.assess_create_issue_bootstrap(
            workspace_path=fake,
            canonical_repo_root=CONTROL_CHECKOUT_ROOT,
            current_branch="master",
            head_sha=MASTER_SHA,
            porcelain_status="",
            remote_master_sha=MASTER_SHA,
            task="create_issue",
        )
        # Under branches/ → not bootstrap; ordinary membership/registration applies.
        self.assertTrue(res["not_applicable"])
        self.assertFalse(res["allowed"])


class TestCreateIssueCapabilityAgreement(unittest.TestCase):
    def test_map_and_alias_agree_on_create_issue(self):
        import task_capability_map as tcm

        self.assertEqual(
            tcm.required_permission("create_issue"),
            "gitea.issue.create",
        )
        # Tool alias must resolve to the same task contract.
        alias = getattr(tcm, "TOOL_TASK_ALIASES", None) or getattr(
            tcm, "TASK_ALIASES", None
        )
        if alias is not None:
            mapped = alias.get("gitea_create_issue")
            if mapped is not None:
                self.assertEqual(mapped, "create_issue")


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