"""Tests for merged PR cleanup reconciliation (#269)."""

import os
import sys
import tempfile
import unittest
from unittest.mock import MagicMock, Mock, patch

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

import merged_cleanup_reconcile as mcr  # noqa: E402


class TestMergedCleanupAssessment(unittest.TestCase):
    def test_extract_linked_issue_from_closes(self):
        issue = mcr.extract_linked_issue(
            "feat: cleanup (Closes #269)",
            "No other refs",
        )
        self.assertEqual(issue, 269)

    def test_merged_remote_branch_safe_when_gates_pass(self):
        result = mcr.assess_remote_branch_cleanup(
            pr_number=42,
            head_branch="feat/issue-269-merged-pr-cleanup-reconcile",
            merged=True,
            remote_branch_exists=True,
            open_pr_heads=set(),
            head_on_master=True,
            delete_capability_allowed=True,
            active_lock=False,
        )
        self.assertTrue(result["safe_to_delete_remote"])
        self.assertEqual(result["block_reasons"], [])

    def test_open_pr_blocks_remote_delete(self):
        result = mcr.assess_remote_branch_cleanup(
            pr_number=42,
            head_branch="feat/issue-9-example",
            merged=True,
            remote_branch_exists=True,
            open_pr_heads={"feat/issue-9-example"},
            head_on_master=True,
            delete_capability_allowed=True,
            active_lock=False,
        )
        self.assertFalse(result["safe_to_delete_remote"])
        self.assertIn("open PR", result["block_reasons"][0])

    def test_protected_branch_blocks_remote_delete(self):
        result = mcr.assess_remote_branch_cleanup(
            pr_number=1,
            head_branch="master",
            merged=True,
            remote_branch_exists=True,
            open_pr_heads=set(),
            head_on_master=True,
            delete_capability_allowed=True,
            active_lock=False,
        )
        self.assertFalse(result["safe_to_delete_remote"])
        self.assertIn("protected", result["block_reasons"][0])

    def test_active_lock_blocks_remote_and_worktree_cleanup(self):
        remote = mcr.assess_remote_branch_cleanup(
            pr_number=42,
            head_branch="feat/issue-9-example",
            merged=True,
            remote_branch_exists=True,
            open_pr_heads=set(),
            head_on_master=True,
            delete_capability_allowed=True,
            active_lock=True,
        )
        local = mcr.assess_local_worktree_cleanup(
            pr_number=42,
            head_branch="feat/issue-9-example",
            merged=True,
            worktree_state={
                "exists": True,
                "clean": True,
                "current_branch": "feat/issue-9-example",
                "worktree_path": "/repo/branches/feat-issue-9-example",
            },
            active_lock=True,
        )
        self.assertFalse(remote["safe_to_delete_remote"])
        self.assertFalse(local["safe_to_remove_worktree"])
        self.assertIn("active issue lock", remote["block_reasons"][0])

    def test_dirty_worktree_blocks_local_cleanup(self):
        result = mcr.assess_local_worktree_cleanup(
            pr_number=42,
            head_branch="feat/issue-9-example",
            merged=True,
            worktree_state={
                "exists": True,
                "clean": False,
                "dirty_files": ["gitea_mcp_server.py"],
                "current_branch": "feat/issue-9-example",
                "worktree_path": "/repo/branches/feat-issue-9-example",
            },
            active_lock=False,
        )
        self.assertFalse(result["safe_to_remove_worktree"])
        self.assertIn("tracked edits", result["block_reasons"][0])


class TestMergedCleanupReport(unittest.TestCase):
    def test_build_report_skips_unmerged_closed_prs(self):
        with tempfile.TemporaryDirectory() as tmp:
            report = mcr.build_reconciliation_report(
                project_root=tmp,
                closed_prs=[
                    {
                        "number": 10,
                        "title": "closed not merged",
                        "body": "Closes #10",
                        "head": {"ref": "feat/issue-10-x"},
                        "merged_at": None,
                    },
                    {
                        "number": 11,
                        "title": "merged",
                        "body": "Closes #11",
                        "head": {"ref": "feat/issue-11-x"},
                        "merged_at": "2026-07-06T12:00:00Z",
                        "merge_commit_sha": "abc123",
                    },
                ],
                open_prs=[],
                remote_branch_exists={"feat/issue-11-x": True},
                head_on_master={11: True},
                delete_capability_allowed=False,
            )
        self.assertEqual(report["merged_pr_count"], 1)
        entry = report["entries"][0]
        self.assertEqual(entry["pr_number"], 11)
        self.assertEqual(entry["issue_number"], 11)
        self.assertFalse(entry["remote_branch"]["safe_to_delete_remote"])
        self.assertIn("delete_branch capability", entry["remote_branch"]["block_reasons"][0])

    def test_has_active_issue_lock_reads_lock_file(self):
        with tempfile.NamedTemporaryFile("w", encoding="utf-8", delete=False) as handle:
            handle.write('{"branch_name": "feat/issue-9-example"}')
            lock_path = handle.name
        try:
            self.assertTrue(
                mcr.has_active_issue_lock("feat/issue-9-example", lock_path=lock_path)
            )
            self.assertFalse(
                mcr.has_active_issue_lock("feat/other-branch", lock_path=lock_path)
            )
        finally:
            os.remove(lock_path)

    @patch("subprocess.run")
    def test_discover_local_worktrees(self, mock_run):
        mock_output = (
            "worktree /repo/Gitea-Tools\n"
            "bare\n\n"
            "worktree /repo/Gitea-Tools/branches/custom-name\n"
            "branch refs/heads/feat/issue-11-x\n"
            "HEAD cafebabe\n\n"
        )
        mock_run.return_value = Mock(returncode=0, stdout=mock_output)
        res = mcr.discover_local_worktrees("/repo/Gitea-Tools")
        self.assertEqual(res, {"feat/issue-11-x": "/repo/Gitea-Tools/branches/custom-name"})

    @patch("subprocess.run")
    @patch("merged_cleanup_reconcile.read_local_worktree_state")
    def test_build_report_discovers_non_canonical_worktree_by_branch(self, mock_state, mock_run):
        mock_output = (
            "worktree /repo/Gitea-Tools\n"
            "bare\n\n"
            "worktree /repo/Gitea-Tools/branches/custom-name\n"
            "branch refs/heads/feat/issue-11-x\n"
            "HEAD cafebabe\n\n"
        )
        mock_run.return_value = Mock(returncode=0, stdout=mock_output)
        mock_state.side_effect = [
            {
                "exists": False,
                "clean": None,
                "current_branch": None,
                "head_sha": None,
                "dirty_files": [],
            },
            {
                "exists": True,
                "clean": True,
                "current_branch": "feat/issue-11-x",
                "head_sha": "cafebabe",
                "dirty_files": [],
            },
        ]

        report = mcr.build_reconciliation_report(
            project_root="/repo/Gitea-Tools",
            closed_prs=[
                {
                    "number": 11,
                    "title": "merged",
                    "body": "Closes #11",
                    "head": {"ref": "feat/issue-11-x"},
                    "merged_at": "2026-07-06T12:00:00Z",
                    "merge_commit_sha": "abc123",
                }
            ],
            open_prs=[],
            remote_branch_exists={"feat/issue-11-x": True},
            head_on_master={11: True},
            delete_capability_allowed=True,
        )
        self.assertEqual(report["merged_pr_count"], 1)
        entry = report["entries"][0]
        local = entry["local_worktree"]
        self.assertEqual(local["worktree_path"], "/repo/Gitea-Tools/branches/custom-name")
        self.assertEqual(local["match_type"], "branch")

    @patch("merged_cleanup_reconcile.read_local_worktree_state")
    @patch("subprocess.run")
    def test_build_report_discovers_issue_alias_worktree(self, mock_run, mock_state):
        mock_run.return_value = Mock(
            returncode=0,
            stdout=(
                "worktree /repo/Gitea-Tools\n"
                "HEAD aaaa\n"
                "branch refs/heads/master\n\n"
                "worktree /repo/Gitea-Tools/branches/issue-510-workspace-binding-isolation\n"
                "HEAD cafebabe\n"
                "branch refs/heads/feat/issue-510-workspace-binding-isolation\n\n"
            ),
        )
        mock_state.side_effect = [
            {
                "exists": False,
                "clean": None,
                "current_branch": None,
                "head_sha": None,
                "dirty_files": [],
            },
            {
                "exists": True,
                "clean": True,
                "current_branch": "feat/issue-510-workspace-binding-isolation",
                "head_sha": "cafebabe",
                "dirty_files": [],
            },
        ]

        report = mcr.build_reconciliation_report(
            project_root="/repo/Gitea-Tools",
            closed_prs=[
                {
                    "number": 512,
                    "title": "merged cleanup",
                    "body": "Closes #510",
                    "head": {
                        "ref": "feat/issue-510-workspace-binding-isolation",
                        "sha": "cafebabe",
                    },
                    "merged_at": "2026-07-06T12:00:00Z",
                    "merge_commit_sha": "abc123",
                }
            ],
            open_prs=[],
            remote_branch_exists={"feat/issue-510-workspace-binding-isolation": True},
            head_on_master={512: True},
            delete_capability_allowed=True,
            target_ref="prgs/master",
        )

        local = report["entries"][0]["local_worktree"]
        self.assertTrue(local["safe_to_remove_worktree"])
        self.assertEqual(local["match_type"], "branch")
        self.assertEqual(
            local["expected_worktree_path"],
            "/repo/Gitea-Tools/branches/feat-issue-510-workspace-binding-isolation",
        )
        self.assertEqual(
            local["discovered_worktree_path"],
            "/repo/Gitea-Tools/branches/issue-510-workspace-binding-isolation",
        )

    @patch("merged_cleanup_reconcile.read_local_worktree_state")
    @patch("subprocess.run")
    def test_ambiguous_issue_alias_worktrees_fail_closed(self, mock_run, mock_state):
        mock_run.return_value = Mock(
            returncode=0,
            stdout=(
                "worktree /repo/Gitea-Tools/branches/issue-510-one\n"
                "HEAD cafebabe\n"
                "branch refs/heads/feat/issue-510-workspace-binding-isolation\n\n"
                "worktree /repo/Gitea-Tools/branches/issue-510-two\n"
                "HEAD cafebabe\n"
                "branch refs/heads/feat/issue-510-workspace-binding-isolation\n\n"
            ),
        )
        mock_state.return_value = {
            "exists": False,
            "clean": None,
            "current_branch": None,
            "head_sha": None,
            "dirty_files": [],
        }

        report = mcr.build_reconciliation_report(
            project_root="/repo/Gitea-Tools",
            closed_prs=[
                {
                    "number": 512,
                    "title": "merged cleanup",
                    "body": "Closes #510",
                    "head": {
                        "ref": "feat/issue-510-workspace-binding-isolation",
                        "sha": "cafebabe",
                    },
                    "merged_at": "2026-07-06T12:00:00Z",
                    "merge_commit_sha": "abc123",
                }
            ],
            open_prs=[],
            remote_branch_exists={"feat/issue-510-workspace-binding-isolation": True},
            head_on_master={512: True},
            delete_capability_allowed=True,
            target_ref="prgs/master",
        )

        local = report["entries"][0]["local_worktree"]
        self.assertFalse(local["safe_to_remove_worktree"])
        self.assertEqual(local["match_type"], "ambiguous_alias")
        self.assertIn("ambiguous local worktree aliases", local["block_reasons"][0])

    @patch("merged_cleanup_reconcile.is_head_ancestor_of_ref", return_value=True)
    @patch("merged_cleanup_reconcile.read_local_worktree_state")
    @patch("subprocess.run")
    def test_alias_head_on_target_branch_is_safe_cleanup_commit(
        self, mock_run, mock_state, _ancestor
    ):
        mock_run.return_value = Mock(
            returncode=0,
            stdout=(
                "worktree /repo/Gitea-Tools/branches/issue-510-workspace-binding-isolation\n"
                "HEAD deadbeef\n"
                "branch refs/heads/feat/issue-510-workspace-binding-isolation\n\n"
            ),
        )
        mock_state.side_effect = [
            {
                "exists": False,
                "clean": None,
                "current_branch": None,
                "head_sha": None,
                "dirty_files": [],
            },
            {
                "exists": True,
                "clean": True,
                "current_branch": "feat/issue-510-workspace-binding-isolation",
                "head_sha": "deadbeef",
                "dirty_files": [],
            },
        ]

        state = mcr.resolve_cleanup_worktree_state(
            project_root="/repo/Gitea-Tools",
            head_branch="feat/issue-510-workspace-binding-isolation",
            issue_number=510,
            pr_head_sha="cafebabe",
            target_ref="prgs/master",
        )

        self.assertTrue(state["exists"])
        self.assertEqual(state["match_type"], "branch")
        self.assertEqual(
            state["discovered_worktree_path"],
            "/repo/Gitea-Tools/branches/issue-510-workspace-binding-isolation",
        )

    @patch("os.path.isdir", return_value=True)
    @patch("subprocess.run")
    def test_remove_local_worktree_uses_assessed_path(self, mock_run, _isdir):
        mock_run.return_value = Mock(returncode=0, stdout="", stderr="")
        result = mcr.remove_local_worktree(
            "/repo/Gitea-Tools",
            "feat/issue-510-workspace-binding-isolation",
            worktree_path="/repo/Gitea-Tools/branches/issue-510-workspace-binding-isolation",
        )

        self.assertTrue(result["success"])
        self.assertEqual(
            result["worktree_path"],
            "/repo/Gitea-Tools/branches/issue-510-workspace-binding-isolation",
        )
        mock_run.assert_called_once_with(
            [
                "git",
                "-C",
                "/repo/Gitea-Tools",
                "worktree",
                "remove",
                "/repo/Gitea-Tools/branches/issue-510-workspace-binding-isolation",
            ],
            capture_output=True,
            text=True,
            check=False,
        )


class TestReviewerScratchCleanup(unittest.TestCase):
    """#534: authorized cleanup path for reviewer scratch worktrees."""

    def test_parse_reviewer_scratch_folder(self):
        self.assertEqual(mcr.parse_reviewer_scratch_folder("review-pr512-submit"), 512)
        self.assertEqual(mcr.parse_reviewer_scratch_folder("review-pr99"), 99)
        self.assertIsNone(mcr.parse_reviewer_scratch_folder("feat-issue-11-x"))
        self.assertIsNone(mcr.parse_reviewer_scratch_folder("review-feat-issue-11"))
        self.assertIsNone(mcr.parse_reviewer_scratch_folder("review-pr512-extra-tail"))

    def test_assess_safe_clean_detached_after_merge(self):
        assessment = mcr.assess_reviewer_scratch_cleanup(
            pr_number=512,
            worktree_path="/repo/Gitea-Tools/branches/review-pr512-submit",
            folder_name="review-pr512-submit",
            pr_merged=True,
            pr_closed=True,
            worktree_state={
                "exists": True,
                "clean": True,
                "current_branch": None,
                "head_sha": "abc",
                "dirty_files": [],
            },
            active_reviewer_lease=False,
        )
        self.assertTrue(assessment["safe_to_remove_worktree"])
        self.assertFalse(assessment["author_worktree_cleanup"])
        self.assertEqual(
            assessment["recommended_action"], "remove_reviewer_scratch_worktree"
        )
        self.assertEqual(assessment["worktree_kind"], "reviewer_scratch")

    def test_assess_blocks_dirty_scratch(self):
        assessment = mcr.assess_reviewer_scratch_cleanup(
            pr_number=512,
            worktree_path="/repo/Gitea-Tools/branches/review-pr512-submit",
            folder_name="review-pr512-submit",
            pr_merged=True,
            pr_closed=True,
            worktree_state={
                "exists": True,
                "clean": False,
                "current_branch": None,
                "head_sha": "abc",
                "dirty_files": ["foo.py"],
            },
            active_reviewer_lease=False,
        )
        self.assertFalse(assessment["safe_to_remove_worktree"])
        self.assertTrue(
            any("tracked edits" in r for r in assessment["block_reasons"])
        )

    def test_assess_blocks_active_lease(self):
        assessment = mcr.assess_reviewer_scratch_cleanup(
            pr_number=512,
            worktree_path="/repo/Gitea-Tools/branches/review-pr512-submit",
            folder_name="review-pr512-submit",
            pr_merged=True,
            pr_closed=True,
            worktree_state={
                "exists": True,
                "clean": True,
                "current_branch": None,
                "head_sha": "abc",
                "dirty_files": [],
            },
            active_reviewer_lease=True,
        )
        self.assertFalse(assessment["safe_to_remove_worktree"])
        self.assertTrue(
            any("active reviewer lease" in r for r in assessment["block_reasons"])
        )

    def test_assess_blocks_open_pr(self):
        assessment = mcr.assess_reviewer_scratch_cleanup(
            pr_number=512,
            worktree_path="/repo/Gitea-Tools/branches/review-pr512-submit",
            folder_name="review-pr512-submit",
            pr_merged=False,
            pr_closed=False,
            worktree_state={
                "exists": True,
                "clean": True,
                "current_branch": None,
                "head_sha": "abc",
                "dirty_files": [],
            },
            active_reviewer_lease=False,
        )
        self.assertFalse(assessment["safe_to_remove_worktree"])
        self.assertTrue(any("still open" in r for r in assessment["block_reasons"]))

    @patch("subprocess.run")
    @patch("merged_cleanup_reconcile.read_local_worktree_state")
    def test_report_lists_scratch_separately_from_author(self, mock_state, mock_run):
        mock_output = (
            "worktree /repo/Gitea-Tools\n"
            "bare\n\n"
            "worktree /repo/Gitea-Tools/branches/feat-issue-11-x\n"
            "branch refs/heads/feat/issue-11-x\n"
            "HEAD deadbeef\n\n"
            "worktree /repo/Gitea-Tools/branches/review-pr512-submit\n"
            "HEAD abcdef0\n"
            "detached\n\n"
        )
        mock_run.return_value = MagicMock(returncode=0, stdout=mock_output)

        def _state(path):
            if "review-pr512" in path:
                return {
                    "exists": True,
                    "clean": True,
                    "current_branch": None,
                    "head_sha": "abcdef0",
                    "dirty_files": [],
                }
            return {
                "exists": True,
                "clean": True,
                "current_branch": "feat/issue-11-x",
                "head_sha": "deadbeef",
                "dirty_files": [],
            }

        mock_state.side_effect = _state
        report = mcr.build_reconciliation_report(
            project_root="/repo/Gitea-Tools",
            closed_prs=[
                {
                    "number": 11,
                    "title": "merged author",
                    "body": "Closes #11",
                    "head": {"ref": "feat/issue-11-x"},
                    "merged_at": "2026-07-06T12:00:00Z",
                },
                {
                    "number": 512,
                    "title": "merged reviewed",
                    "body": "Closes #512",
                    "head": {"ref": "feat/issue-512-x"},
                    "merged_at": "2026-07-06T12:00:00Z",
                },
            ],
            open_prs=[],
            remote_branch_exists={
                "feat/issue-11-x": False,
                "feat/issue-512-x": False,
            },
            head_on_master={11: True, 512: True},
            delete_capability_allowed=True,
            active_reviewer_leases={512: False},
        )
        self.assertEqual(report["merged_pr_count"], 2)
        self.assertEqual(report["reviewer_scratch_count"], 1)
        scratch = report["reviewer_scratch_entries"][0]
        self.assertEqual(scratch["pr_number"], 512)
        self.assertEqual(scratch["worktree_kind"], "reviewer_scratch")
        self.assertFalse(scratch["author_worktree_cleanup"])
        self.assertTrue(scratch["safe_to_remove_worktree"])
        # Author entries must not swallow the scratch path as local_worktree.
        author_paths = {
            (e.get("local_worktree") or {}).get("worktree_path")
            for e in report["entries"]
        }
        self.assertNotIn(
            "/repo/Gitea-Tools/branches/review-pr512-submit", author_paths
        )

    @patch("subprocess.run")
    def test_remove_reviewer_scratch_worktree_path_guard(self, mock_run):
        mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
        with patch("os.path.isdir", return_value=True):
            bad = mcr.remove_reviewer_scratch_worktree(
                "/repo/Gitea-Tools",
                "/repo/Gitea-Tools/branches/feat-issue-11-x",
            )
            self.assertFalse(bad["performed"])
            good = mcr.remove_reviewer_scratch_worktree(
                "/repo/Gitea-Tools",
                "/repo/Gitea-Tools/branches/review-pr512-submit",
            )
        self.assertTrue(good["success"])
        self.assertTrue(good["performed"])
        self.assertFalse(good["author_worktree_cleanup"])
        mock_run.assert_any_call(
            [
                "git",
                "-C",
                "/repo/Gitea-Tools",
                "worktree",
                "remove",
                "/repo/Gitea-Tools/branches/review-pr512-submit",
            ],
            capture_output=True,
            text=True,
            check=False,
        )


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