import sys as _sys
from pathlib import Path as _Path
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
from mutation_profile_fixture import shared_mutation_env  # noqa: E402
"""Dead-session lock recovery when the issue already owns an open PR (#755).

#753 added the recovery *assessor*, but the production ``gitea_lock_issue``
path still rejected every sanctioned recovery: a dead-session lock is by
construction a lock for work that already has an open PR, and the #400
duplicate-work gate blocked unconditionally on any linked open PR. These tests
drive the real MCP handler, not just the pure assessor, so that gap cannot
reopen.
"""
import os
import subprocess
import sys
import tempfile
import unittest
from datetime import datetime, timedelta, timezone
from pathlib import Path
from unittest.mock import patch

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

import issue_lock_provenance  # noqa: E402
import issue_lock_recovery  # noqa: E402
import issue_lock_store  # noqa: E402
import mcp_server  # noqa: E402
from issue_work_duplicate_gate import (  # noqa: E402
    OUTCOME_DUPLICATE_PR_PREVENTED,
    OUTCOME_DUPLICATE_WORK_NOT_PREVENTED,
    PHASE_LOCK,
    assess_work_issue_duplicate_gate,
)

ISSUE = 4755
BRANCH = f"fix/issue-{ISSUE}-owning-pr"
OTHER_BRANCH = f"fix/issue-{ISSUE}-competing"
HEAD = "c" * 40
OTHER_HEAD = "d" * 40
OWNING_PR = 4756
OTHER_PR = 4757
IDENTITY = "example-user"
PROFILE = "test-author-prgs"
ORG = "Scaled-Tech-Consulting"
REPO = "Gitea-Tools"


def dead_pid() -> int:
    """A PID that has certainly exited (spawned, then reaped)."""
    proc = subprocess.Popen([sys.executable, "-c", "pass"])
    proc.wait()
    return proc.pid


def shifted_ts(hours: int = 4) -> str:
    return (
        (datetime.now(timezone.utc) + timedelta(hours=hours))
        .isoformat()
        .replace("+00:00", "Z")
    )


def owning_pr(number=OWNING_PR, ref=BRANCH, sha=HEAD, issue=ISSUE):
    return {
        "number": number,
        "title": f"fix: something (Closes #{issue})",
        "body": f"Closes #{issue}.",
        "head": {"ref": ref, "sha": sha},
    }


def sanctioned_token(
    issue_number=ISSUE, pr_number=OWNING_PR, branch=BRANCH, head=HEAD
):
    """The evidence shape the server derives from a granted recovery.

    #768 extends the token with recorded/accepted heads and the head relation
    so a strict-descendant recovery can still exempt the owning PR after the
    remediation commit lands. Exact-head recovery (#753/#755) reports equal
    heads under the same shape.
    """
    return {
        "issue_number": issue_number,
        "pr_number": pr_number,
        "branch_name": branch,
        "head_sha": head,
        "recorded_head": head,
        "accepted_head": head,
        "head_relation": issue_lock_recovery.HEAD_RELATION_EQUAL,
    }


# ───────────────────────── duplicate gate: exemption ─────────────────────────


class TestOwningPrExemptionGranted(unittest.TestCase):
    def test_exact_owning_pr_is_not_duplicate_work(self):
        result = assess_work_issue_duplicate_gate(
            ISSUE,
            open_prs=[owning_pr()],
            branch_names=[BRANCH],
            claim_entry={"status": "not_claimed"},
            locked_branch=BRANCH,
            phase=PHASE_LOCK,
            recovered_owning_pr=sanctioned_token(),
        )
        self.assertFalse(result["block"])
        self.assertTrue(result["owning_pr_recovery_exempted"])
        self.assertEqual(result["outcome"], OUTCOME_DUPLICATE_WORK_NOT_PREVENTED)
        self.assertEqual(result["linked_open_pr"], OWNING_PR)
        self.assertEqual(result["linked_open_pr_count"], 1)

    def test_unrelated_open_pr_alongside_owning_pr_is_ignored(self):
        unrelated = {
            "number": 999,
            "title": "chore: unrelated",
            "body": "no linkage",
            "head": {"ref": "chore/unrelated", "sha": OTHER_HEAD},
        }
        result = assess_work_issue_duplicate_gate(
            ISSUE,
            open_prs=[unrelated, owning_pr()],
            branch_names=[BRANCH],
            claim_entry={"status": "not_claimed"},
            locked_branch=BRANCH,
            phase=PHASE_LOCK,
            recovered_owning_pr=sanctioned_token(),
        )
        self.assertFalse(result["block"])
        self.assertTrue(result["owning_pr_recovery_exempted"])
        self.assertEqual(result["linked_open_pr_count"], 1)


class TestOwningPrExemptionRefused(unittest.TestCase):
    def assert_blocked(self, result):
        self.assertTrue(result["block"])
        self.assertFalse(result["owning_pr_recovery_exempted"])
        self.assertEqual(result["outcome"], OUTCOME_DUPLICATE_PR_PREVENTED)

    def test_no_recovery_evidence_keeps_ordinary_blocker(self):
        self.assert_blocked(
            assess_work_issue_duplicate_gate(
                ISSUE,
                open_prs=[owning_pr()],
                branch_names=[BRANCH],
                claim_entry={"status": "not_claimed"},
                locked_branch=BRANCH,
                phase=PHASE_LOCK,
            )
        )

    def test_competing_pr_number_refused(self):
        result = assess_work_issue_duplicate_gate(
            ISSUE,
            open_prs=[owning_pr(number=OTHER_PR)],
            branch_names=[BRANCH],
            claim_entry={"status": "not_claimed"},
            locked_branch=BRANCH,
            phase=PHASE_LOCK,
            recovered_owning_pr=sanctioned_token(),
        )
        self.assert_blocked(result)

    def test_multiple_linked_open_prs_refused(self):
        result = assess_work_issue_duplicate_gate(
            ISSUE,
            open_prs=[owning_pr(), owning_pr(number=OTHER_PR, ref=OTHER_BRANCH)],
            branch_names=[BRANCH],
            claim_entry={"status": "not_claimed"},
            locked_branch=BRANCH,
            phase=PHASE_LOCK,
            recovered_owning_pr=sanctioned_token(),
        )
        self.assert_blocked(result)
        self.assertEqual(result["linked_open_pr_count"], 2)

    def test_different_branch_refused(self):
        result = assess_work_issue_duplicate_gate(
            ISSUE,
            open_prs=[owning_pr(ref=OTHER_BRANCH)],
            branch_names=[BRANCH],
            claim_entry={"status": "not_claimed"},
            locked_branch=BRANCH,
            phase=PHASE_LOCK,
            recovered_owning_pr=sanctioned_token(),
        )
        self.assert_blocked(result)

    def test_locked_branch_differing_from_evidence_refused(self):
        result = assess_work_issue_duplicate_gate(
            ISSUE,
            open_prs=[owning_pr()],
            branch_names=[BRANCH],
            claim_entry={"status": "not_claimed"},
            locked_branch=OTHER_BRANCH,
            phase=PHASE_LOCK,
            recovered_owning_pr=sanctioned_token(),
        )
        self.assert_blocked(result)

    def test_different_head_refused(self):
        result = assess_work_issue_duplicate_gate(
            ISSUE,
            open_prs=[owning_pr(sha=OTHER_HEAD)],
            branch_names=[BRANCH],
            claim_entry={"status": "not_claimed"},
            locked_branch=BRANCH,
            phase=PHASE_LOCK,
            recovered_owning_pr=sanctioned_token(),
        )
        self.assert_blocked(result)

    def test_evidence_for_another_issue_refused(self):
        result = assess_work_issue_duplicate_gate(
            ISSUE,
            open_prs=[owning_pr()],
            branch_names=[BRANCH],
            claim_entry={"status": "not_claimed"},
            locked_branch=BRANCH,
            phase=PHASE_LOCK,
            recovered_owning_pr=sanctioned_token(issue_number=ISSUE + 1),
        )
        self.assert_blocked(result)

    def test_missing_head_in_live_pr_refused(self):
        pr = owning_pr()
        pr["head"] = {"ref": BRANCH}
        result = assess_work_issue_duplicate_gate(
            ISSUE,
            open_prs=[pr],
            branch_names=[BRANCH],
            claim_entry={"status": "not_claimed"},
            locked_branch=BRANCH,
            phase=PHASE_LOCK,
            recovered_owning_pr=sanctioned_token(),
        )
        self.assert_blocked(result)


class TestOrdinaryDuplicateBehaviorUnchanged(unittest.TestCase):
    def test_clean_issue_still_passes(self):
        result = assess_work_issue_duplicate_gate(
            ISSUE,
            open_prs=[],
            branch_names=["feat/other-issue-99"],
            claim_entry={"status": "not_claimed"},
            locked_branch=BRANCH,
            phase=PHASE_LOCK,
        )
        self.assertFalse(result["block"])
        self.assertFalse(result["owning_pr_recovery_exempted"])

    def test_competing_branch_still_blocks_even_with_owning_pr_evidence(self):
        result = assess_work_issue_duplicate_gate(
            ISSUE,
            open_prs=[owning_pr()],
            branch_names=[BRANCH, OTHER_BRANCH],
            claim_entry={"status": "not_claimed"},
            locked_branch=BRANCH,
            phase=PHASE_LOCK,
            recovered_owning_pr=sanctioned_token(),
        )
        # The owning PR is exempt, but the competing branch is not.
        self.assertTrue(result["block"])
        self.assertTrue(result["owning_pr_recovery_exempted"])
        self.assertIn(OTHER_BRANCH, result["conflicting_branches"])


# ─────────────────── server-derived evidence cannot be forged ───────────────────


class TestOwningPrEvidenceDerivation(unittest.TestCase):
    def granted(self, **evidence_overrides):
        evidence = {
            "issue_number": ISSUE,
            "locked_branch": BRANCH,
            "local_head": HEAD,
            "remote_head": HEAD,
            "pr_head": HEAD,
            "pr_number": OWNING_PR,
        }
        evidence.update(evidence_overrides)
        return {
            "outcome": issue_lock_recovery.RECOVERY_SANCTIONED,
            "recovery_sanctioned": True,
            "is_candidate": True,
            "reasons": [],
            "evidence": evidence,
        }

    def test_granted_recovery_yields_evidence(self):
        token = issue_lock_recovery.owning_pr_recovery_evidence(self.granted())
        self.assertEqual(token, sanctioned_token())

    def test_none_assessment_yields_nothing(self):
        self.assertIsNone(issue_lock_recovery.owning_pr_recovery_evidence(None))

    def test_refused_assessment_yields_nothing(self):
        refused = self.granted()
        refused["outcome"] = issue_lock_recovery.REFUSED
        refused["recovery_sanctioned"] = False
        self.assertIsNone(issue_lock_recovery.owning_pr_recovery_evidence(refused))

    def test_sanctioned_flag_without_outcome_yields_nothing(self):
        forged = self.granted()
        forged["outcome"] = "SOMETHING_ELSE"
        self.assertIsNone(issue_lock_recovery.owning_pr_recovery_evidence(forged))

    def test_missing_pr_number_yields_nothing(self):
        self.assertIsNone(
            issue_lock_recovery.owning_pr_recovery_evidence(
                self.granted(pr_number=None)
            )
        )

    def test_head_disagreement_in_evidence_yields_nothing(self):
        self.assertIsNone(
            issue_lock_recovery.owning_pr_recovery_evidence(
                self.granted(remote_head=OTHER_HEAD)
            )
        )
        self.assertIsNone(
            issue_lock_recovery.owning_pr_recovery_evidence(
                self.granted(local_head=OTHER_HEAD)
            )
        )

    def test_real_refused_assessment_yields_nothing(self):
        """End-to-end against the real assessor, not a hand-built dict."""
        lock = {
            "issue_number": ISSUE,
            "branch_name": BRANCH,
            "worktree_path": "/scratch/wt",
            "remote": "prgs",
            "org": "Example-Org",
            "repo": "Example-Repo",
            "session_pid": os.getpid(),  # alive → must refuse
            "work_lease": {
                "operation_type": issue_lock_store.AUTHOR_ISSUE_WORK_LEASE,
                "issue_number": ISSUE,
                "branch": BRANCH,
                "worktree_path": "/scratch/wt",
                "claimant": {"username": IDENTITY, "profile": PROFILE},
                "expires_at": shifted_ts(),
            },
        }
        assessment = issue_lock_recovery.assess_dead_session_lock_recovery(
            lock,
            issue_number=ISSUE,
            branch_name=BRANCH,
            worktree_path="/scratch/wt",
            remote="prgs",
            org="Example-Org",
            repo="Example-Repo",
            identity=IDENTITY,
            profile=PROFILE,
            current_branch=BRANCH,
            porcelain_status="",
            head_sha=HEAD,
            remote_head_sha=HEAD,
            pr_head_sha=HEAD,
            pr_number=OWNING_PR,
            competing_live_locks=[],
            candidate_branches=[BRANCH],
            current_pid=os.getpid(),
        )
        self.assertFalse(assessment["recovery_sanctioned"])
        self.assertIsNone(
            issue_lock_recovery.owning_pr_recovery_evidence(assessment)
        )


# ──────────────────── end-to-end: the real gitea_lock_issue ────────────────────


class LockIssueEndToEndBase(unittest.TestCase):
    """Drives ``mcp_server.gitea_lock_issue`` with live git/Gitea observation
    stubbed at the module boundary — the production gate chain itself runs."""

    def setUp(self):
        self.lock_dir = tempfile.TemporaryDirectory()
        self.addCleanup(self.lock_dir.cleanup)
        self.worktree = os.path.realpath(os.getcwd())
        # Bind host/org/repo to what the ``test-author-prgs`` fixture profile is
        # pinned to, so the session-context gate under test is the real one and
        # not a cross-host denial. The issue number and lock dir stay synthetic.
        self.remotes = patch.dict(mcp_server.REMOTES, {
            "prgs": {
                "host": "gitea.prgs.cc",
                "org": ORG,
                "repo": REPO,
            },
        })
        self.remotes.start()
        self.addCleanup(patch.stopall)
        mcp_server._IDENTITY_CACHE.clear()

    def write_durable_lock(self, *, pid, branch=BRANCH, worktree=None):
        path = issue_lock_store.lock_file_path(
            remote="prgs",
            org=ORG,
            repo=REPO,
            issue_number=ISSUE,
            lock_dir=self.lock_dir.name,
        )
        claimant = {"username": IDENTITY, "profile": PROFILE}
        data = {
            "issue_number": ISSUE,
            "branch_name": branch,
            "remote": "prgs",
            "org": ORG,
            "repo": REPO,
            "worktree_path": worktree or self.worktree,
            "session_pid": pid,
            "pid": pid,
            "work_lease": {
                "operation_type": issue_lock_store.AUTHOR_ISSUE_WORK_LEASE,
                "issue_number": ISSUE,
                "branch": branch,
                "worktree_path": worktree or self.worktree,
                "claimant": claimant,
                "created_at": shifted_ts(-1),
                "last_heartbeat_at": shifted_ts(-1),
                "expires_at": shifted_ts(),
            },
            "lock_provenance": issue_lock_provenance.build_sanctioned_lock_provenance(
                tool="gitea_lock_issue",
                claimant=claimant,
            ),
        }
        issue_lock_store.save_lock_file(path, data)
        return path

    def run_lock(
        self,
        *,
        open_prs,
        porcelain="",
        current_branch=BRANCH,
        base_equivalent=False,
        branch_names=None,
        head_sha=HEAD,
        remote_head=HEAD,
    ):
        branch_names = branch_names if branch_names is not None else [BRANCH]
        branch_entries = [
            {"name": name, "commit": {"id": remote_head}} for name in branch_names
        ]
        env = shared_mutation_env(
            "test-author-prgs",
            include_example_repo=True,
            GITEA_ISSUE_LOCK_DIR=self.lock_dir.name,
        )
        with patch(
            "mcp_server.api_get_all", return_value=branch_entries
        ), patch(
            "mcp_server._list_open_pulls", return_value=list(open_prs)
        ), patch(
            "mcp_server.get_auth_header", return_value="token x"
        ), patch(
            "mcp_server._work_lease_claimant",
            return_value={"username": IDENTITY, "profile": PROFILE},
        ), patch(
            "mcp_server.issue_lock_worktree.read_worktree_git_state",
            return_value={
                "current_branch": current_branch,
                "porcelain_status": porcelain,
                "base_equivalent": base_equivalent,
                "head_sha": head_sha,
                "inspected_git_root": self.worktree,
                "base_branch": "master",
            },
        ), patch(
            "mcp_server.issue_duplicate_context_fetcher",
            side_effect=lambda h, o, r, auth, issue_number: (
                list(open_prs), list(branch_names), {"status": "not_claimed"}
            ),
        ):
            with patch.dict(os.environ, env, clear=True):
                os.environ["GITEA_ISSUE_LOCK_DIR"] = self.lock_dir.name
                return mcp_server.gitea_lock_issue(
                    issue_number=ISSUE,
                    branch_name=BRANCH,
                    remote="prgs",
                    worktree_path=self.worktree,
                )


class TestRecoveryWithOwningPrSucceeds(LockIssueEndToEndBase):
    def test_dead_session_recovery_with_owning_pr_relocks(self):
        self.write_durable_lock(pid=dead_pid())
        result = self.run_lock(open_prs=[owning_pr()])

        self.assertTrue(result["success"])
        self.assertEqual(result["issue_number"], ISSUE)
        self.assertEqual(result["branch_name"], BRANCH)
        self.assertTrue(result["lock_freshness"]["live"])
        self.assertTrue(result["lock_freshness"]["pid_alive"])

    def test_recovered_lock_records_truthful_provenance(self):
        prior = dead_pid()
        self.write_durable_lock(pid=prior)
        self.run_lock(open_prs=[owning_pr()])

        lock = issue_lock_store.load_issue_lock(
            remote="prgs",
            org=ORG,
            repo=REPO,
            issue_number=ISSUE,
            lock_dir=self.lock_dir.name,
        )
        record = lock.get("dead_session_recovery") or {}
        self.assertTrue(record.get("recovered"))
        self.assertEqual(record.get("prior_session_pid"), prior)
        self.assertEqual(record.get("replacement_session_pid"), os.getpid())
        self.assertFalse(record.get("prior_pid_alive"))
        self.assertEqual(record.get("pr_number"), OWNING_PR)
        self.assertEqual(record.get("branch_name"), BRANCH)
        self.assertEqual(record.get("identity"), IDENTITY)

    def test_recovered_lock_is_live_and_proves_pr_ownership(self):
        """AC6: the persisted lock satisfies update-by-merge's ownership prover."""
        self.write_durable_lock(pid=dead_pid())
        self.run_lock(open_prs=[owning_pr()])

        lock = issue_lock_store.load_issue_lock(
            remote="prgs",
            org=ORG,
            repo=REPO,
            issue_number=ISSUE,
            lock_dir=self.lock_dir.name,
        )
        self.assertTrue(issue_lock_store.is_lease_live(lock))

        env = shared_mutation_env(
            "test-author-prgs",
            include_example_repo=True,
            GITEA_ISSUE_LOCK_DIR=self.lock_dir.name,
        )
        with patch.dict(os.environ, env, clear=True):
            os.environ["GITEA_ISSUE_LOCK_DIR"] = self.lock_dir.name
            ownership = mcp_server._prove_author_ownership_for_pr(
                pr_number=OWNING_PR,
                pr_title=f"fix: something (Closes #{ISSUE})",
                pr_body=f"Closes #{ISSUE}.",
                source_branch=BRANCH,
                remote="prgs",
                host="gitea.prgs.cc",
                org=ORG,
                repo=REPO,
                worktree_path=self.worktree,
            )
        self.assertTrue(ownership["proven"], ownership["reasons"])
        self.assertTrue(ownership["has_author_lock"])
        self.assertEqual(ownership["matched_issue"], ISSUE)


class TestRecoveryRejectionsEndToEnd(LockIssueEndToEndBase):
    def assert_lock_refused(self, **kwargs):
        with self.assertRaises((ValueError, RuntimeError)) as ctx:
            self.run_lock(**kwargs)
        return str(ctx.exception)

    def test_competing_pr_still_blocked(self):
        self.write_durable_lock(pid=dead_pid())
        message = self.assert_lock_refused(
            open_prs=[owning_pr(number=OTHER_PR, ref=OTHER_BRANCH)],
            branch_names=[BRANCH],
        )
        self.assertIn("already covers issue", message)

    def test_multiple_linked_open_prs_blocked(self):
        self.write_durable_lock(pid=dead_pid())
        message = self.assert_lock_refused(
            open_prs=[owning_pr(), owning_pr(number=OTHER_PR, ref=OTHER_BRANCH)],
        )
        self.assertIn("already covers issue", message)

    def test_owning_pr_on_a_different_head_blocked(self):
        self.write_durable_lock(pid=dead_pid())
        self.assert_lock_refused(open_prs=[owning_pr(sha=OTHER_HEAD)])

    def test_lock_registered_to_a_different_worktree_blocked(self):
        self.write_durable_lock(
            pid=dead_pid(), worktree=os.path.join(self.worktree, "elsewhere")
        )
        self.assert_lock_refused(open_prs=[owning_pr()])

    def test_dirty_worktree_blocked(self):
        self.write_durable_lock(pid=dead_pid())
        self.assert_lock_refused(
            open_prs=[owning_pr()], porcelain=" M gitea_mcp_server.py"
        )

    def test_worktree_parked_on_another_branch_blocked(self):
        self.write_durable_lock(pid=dead_pid())
        self.assert_lock_refused(
            open_prs=[owning_pr()], current_branch="master"
        )

    def test_local_head_differing_from_remote_blocked(self):
        self.write_durable_lock(pid=dead_pid())
        self.assert_lock_refused(
            open_prs=[owning_pr()], head_sha=OTHER_HEAD
        )

    def test_live_prior_pid_blocked(self):
        self.write_durable_lock(pid=os.getpid())
        self.assert_lock_refused(open_prs=[owning_pr()])

    def test_new_claim_without_prior_lock_still_requires_base_equivalence(self):
        """AC10: no durable lock → no recovery → base-equivalence still rules."""
        self.assert_lock_refused(open_prs=[], branch_names=[])


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