"""Cross-role allocation handoff consumable by independent workers (#843).

Regression coverage for the controller→required-role consume path:

* controller allocates author work; independent author adopts successfully
* author adoption succeeds after allocating controller process exits
* author adoption without sharing controller session identity
* wrong-role adoption rejected
* concurrent/second adoption rejected without state corruption
* terminal allocation adoption rejected
* successful adoption produces authoritative ownership evidence
* genuine abandoned-lease recovery remains valid
* process_work_queue / allocate results include consume identifiers
* same-role allocation behavior remains compatible
"""

from __future__ import annotations

import os
import tempfile
import unittest
from datetime import timedelta
from unittest.mock import patch

from allocator_service import (
    ALLOCATION_MODE_CROSS_ROLE,
    ALLOCATION_MODE_ROLE_SCOPED,
    OUTCOME_ASSIGNED,
    ROLE_AUTHOR,
    ROLE_CONTROLLER,
    ROLE_REVIEWER,
    WorkCandidate,
    allocate_next_work,
)
from control_plane_db import ControlPlaneDB, ForeignLeaseError, _ts, _utc_now
import lease_lifecycle as ll


class CrossRoleHandoffTest(unittest.TestCase):
    def setUp(self) -> None:
        self._tmp = tempfile.TemporaryDirectory()
        self.db_path = os.path.join(self._tmp.name, "cp.sqlite3")
        self.db = ControlPlaneDB(self.db_path)
        self.db.upsert_session(
            session_id="ctrl-session",
            role="controller",
            profile="prgs-controller",
            pid=99999999,  # dead-looking pid
        )
        self.db.upsert_session(
            session_id="author-worker",
            role="author",
            profile="prgs-author",
            pid=os.getpid(),
        )
        self.db.upsert_session(
            session_id="author-worker-2",
            role="author",
            profile="prgs-author",
            pid=os.getpid(),
        )
        self.db.upsert_session(
            session_id="reviewer-worker",
            role="reviewer",
            profile="prgs-reviewer",
            pid=os.getpid(),
        )
        self.wt = self._tmp.name

    def tearDown(self) -> None:
        self._tmp.cleanup()

    def _ready_issue(self, number: int = 843, title: str = "handoff target") -> WorkCandidate:
        return WorkCandidate(
            kind="issue",
            number=number,
            labels=("status:ready", "type:bug"),
            title=title,
            priority=20,
        )

    def _controller_allocate(self, number: int = 843, **kwargs):
        defaults = dict(
            db=self.db,
            session_id="ctrl-session",
            role=ROLE_CONTROLLER,
            remote="prgs",
            org="Scaled-Tech-Consulting",
            repo="Gitea-Tools",
            candidates=[self._ready_issue(number)],
            apply=True,
            profile_name="prgs-controller",
            username="controller-user",
            allocation_mode=ALLOCATION_MODE_CROSS_ROLE,
        )
        defaults.update(kwargs)
        return allocate_next_work(**defaults)

    def test_controller_allocates_author_independent_author_adopts(self) -> None:
        res = self._controller_allocate()
        self.assertEqual(res["outcome"], OUTCOME_ASSIGNED)
        self.assertEqual(res["required_role"], ROLE_AUTHOR)
        self.assertIn("consume_allocation", res)
        consume = res["consume_allocation"]
        self.assertEqual(consume["tool"], "gitea_adopt_workflow_lease")
        self.assertEqual(consume["required_role"], ROLE_AUTHOR)
        self.assertFalse(consume["controller_session_required"])
        lid = res["assignment"]["lease_id"]
        self.assertEqual(consume["lease_id"], lid)
        self.assertIn(lid, res["next_valid_command"])

        adopted = ll.adopt_lease(
            self.db,
            lease_id=lid,
            adopter_session_id="author-worker",
            role=ROLE_AUTHOR,
            worktree_path=self.wt,
        )
        self.assertTrue(adopted["success"])
        self.assertEqual(adopted["outcome"], "adopted_cross_role_handoff")
        self.assertEqual(adopted["adopted_by_session_id"], "author-worker")
        self.assertEqual(adopted["adopted_from_session_id"], "ctrl-session")
        raw = adopted["read_after_write"]
        self.assertEqual(raw["session_id"], "author-worker")
        self.assertEqual(raw["adopted_by_session_id"], "author-worker")
        self.assertEqual(raw["status"], "active")
        self.assertEqual(raw["phase"], "adopted")

        # Authoritative re-read
        state = self.db.get_lease_workflow_state(lid)
        self.assertEqual(state["lease"]["session_id"], "author-worker")
        self.assertEqual(state["lease"]["adopted_by_session_id"], "author-worker")
        self.assertEqual(state["assignment"]["session_id"], "author-worker")
        self.assertEqual(state["provenance"]["handoff_status"], "adopted")

    def test_author_adoption_after_controller_process_exits(self) -> None:
        res = self._controller_allocate(number=900)
        lid = res["assignment"]["lease_id"]
        # Force owner_pid dead + freshness stale_dead_process
        import sqlite3

        conn = sqlite3.connect(self.db_path)
        try:
            conn.execute(
                "UPDATE leases SET owner_pid = 99999999 WHERE lease_id = ?",
                (lid,),
            )
            conn.commit()
        finally:
            conn.close()
        state = self.db.get_lease_workflow_state(lid)
        fr = ll.classify_lease_freshness(
            state["lease"], pid_checker=lambda _p: False
        )
        self.assertEqual(fr["freshness"], "stale_dead_process")

        adopted = ll.adopt_lease(
            self.db,
            lease_id=lid,
            adopter_session_id="author-worker",
            role=ROLE_AUTHOR,
            worktree_path=self.wt,
        )
        self.assertEqual(adopted["outcome"], "adopted_cross_role_handoff")
        self.assertEqual(adopted["adopted_by_session_id"], "author-worker")
        # No abandon required
        state2 = self.db.get_lease_workflow_state(lid)
        self.assertEqual(state2["lease"]["status"], "active")
        self.assertNotEqual(state2["lease"]["status"], "abandoned")

    def test_adoption_without_sharing_controller_session_identity(self) -> None:
        res = self._controller_allocate(number=901)
        lid = res["assignment"]["lease_id"]
        adopted = ll.adopt_lease(
            self.db,
            lease_id=lid,
            adopter_session_id="author-worker",
            role=ROLE_AUTHOR,
            worktree_path=self.wt,
        )
        self.assertNotEqual(adopted["adopted_by_session_id"], "ctrl-session")
        self.assertFalse(adopted["same_owner"])
        self.assertEqual(adopted["adopted_from_session_id"], "ctrl-session")

    def test_wrong_role_adoption_rejected(self) -> None:
        res = self._controller_allocate(number=902)
        lid = res["assignment"]["lease_id"]
        with self.assertRaises(ll.LeaseLifecycleError) as ctx:
            ll.adopt_lease(
                self.db,
                lease_id=lid,
                adopter_session_id="reviewer-worker",
                role=ROLE_REVIEWER,
            )
        self.assertIn("wrong role", str(ctx.exception).lower())
        # State unchanged
        state = self.db.get_lease_workflow_state(lid)
        self.assertEqual(state["lease"]["session_id"], "ctrl-session")
        self.assertIsNone(state["lease"].get("adopted_by_session_id") or None)
        self.assertEqual(state["provenance"]["handoff_status"], "pending")

    def test_second_adoption_rejected_without_corruption(self) -> None:
        res = self._controller_allocate(number=903)
        lid = res["assignment"]["lease_id"]
        first = ll.adopt_lease(
            self.db,
            lease_id=lid,
            adopter_session_id="author-worker",
            role=ROLE_AUTHOR,
            worktree_path=self.wt,
        )
        self.assertEqual(first["outcome"], "adopted_cross_role_handoff")
        with self.assertRaises(ll.LeaseLifecycleError):
            ll.adopt_lease(
                self.db,
                lease_id=lid,
                adopter_session_id="author-worker-2",
                role=ROLE_AUTHOR,
                worktree_path=self.wt,
            )
        state = self.db.get_lease_workflow_state(lid)
        self.assertEqual(state["lease"]["session_id"], "author-worker")
        self.assertEqual(state["lease"]["adopted_by_session_id"], "author-worker")
        self.assertEqual(state["assignment"]["session_id"], "author-worker")
        self.assertEqual(state["lease"]["status"], "active")

    def test_terminal_allocation_adoption_rejected(self) -> None:
        res = self._controller_allocate(number=904)
        lid = res["assignment"]["lease_id"]
        # Abandon as terminal
        proof = ll.AbandonProof(
            dead_process=True,
            missing_worktree=True,
            no_open_pr=True,
            no_live_mutation_risk=True,
            owner_pid=99999999,
            worktree_path="/nonexistent/for-843",
        )
        # Attach dead pid / missing wt for abandon eligibility
        import sqlite3

        conn = sqlite3.connect(self.db_path)
        try:
            conn.execute(
                "UPDATE leases SET owner_pid = 99999999, worktree_path = ? WHERE lease_id = ?",
                ("/nonexistent/for-843", lid),
            )
            conn.commit()
        finally:
            conn.close()
        abandoned = ll.abandon_lease(
            self.db,
            lease_id=lid,
            requester_session_id="author-worker",
            proof=proof,
        )
        self.assertEqual(abandoned["outcome"], "abandoned")
        with self.assertRaises(ll.LeaseLifecycleError) as ctx:
            ll.adopt_lease(
                self.db,
                lease_id=lid,
                adopter_session_id="author-worker",
                role=ROLE_AUTHOR,
            )
        self.assertIn("abandoned", str(ctx.exception).lower())

    def test_successful_adoption_read_after_write_ownership(self) -> None:
        res = self._controller_allocate(number=905)
        lid = res["assignment"]["lease_id"]
        adopted = ll.adopt_lease(
            self.db,
            lease_id=lid,
            adopter_session_id="author-worker",
            role=ROLE_AUTHOR,
            worktree_path=self.wt,
        )
        raw = adopted["read_after_write"]
        self.assertEqual(raw["lease_id"], lid)
        self.assertEqual(raw["session_id"], "author-worker")
        self.assertEqual(raw["adopted_by_session_id"], "author-worker")
        self.assertEqual(raw["adopted_from_session_id"], "ctrl-session")
        # Re-fetch proves durable write
        state = self.db.get_lease_workflow_state(lid)
        self.assertEqual(state["lease"]["session_id"], raw["session_id"])
        self.assertEqual(
            state["lease"]["adopted_by_session_id"], raw["adopted_by_session_id"]
        )

    def test_genuine_abandoned_recovery_still_valid(self) -> None:
        """Same-role author lease abandoned remains reclaimable via abandon path."""
        same = allocate_next_work(
            self.db,
            session_id="author-worker",
            role=ROLE_AUTHOR,
            remote="prgs",
            org="Scaled-Tech-Consulting",
            repo="Gitea-Tools",
            candidates=[self._ready_issue(906, "same-role")],
            apply=True,
            profile_name="prgs-author",
            username="author-user",
            allocation_mode=ALLOCATION_MODE_ROLE_SCOPED,
        )
        self.assertEqual(same["outcome"], OUTCOME_ASSIGNED)
        lid = same["assignment"]["lease_id"]
        import sqlite3

        conn = sqlite3.connect(self.db_path)
        try:
            conn.execute(
                "UPDATE leases SET owner_pid = 99999999, worktree_path = ? WHERE lease_id = ?",
                ("/nonexistent/same-role", lid),
            )
            conn.commit()
        finally:
            conn.close()
        proof = ll.AbandonProof(
            dead_process=True,
            missing_worktree=True,
            no_open_pr=True,
            no_live_mutation_risk=True,
            owner_pid=99999999,
            worktree_path="/nonexistent/same-role",
        )
        abandoned = ll.abandon_lease(
            self.db,
            lease_id=lid,
            requester_session_id="author-worker-2",
            proof=proof,
        )
        self.assertEqual(abandoned["outcome"], "abandoned")
        # Foreign author cannot handoff-consume an abandoned non-handoff lease
        with self.assertRaises(ll.LeaseLifecycleError):
            ll.adopt_lease(
                self.db,
                lease_id=lid,
                adopter_session_id="author-worker-2",
                role=ROLE_AUTHOR,
            )
        # Reclaim path still works for expired/abandoned after force-expire
        reclaimed = ll.reclaim_expired_lease(
            self.db,
            lease_id=lid,
            session_id="author-worker-2",
            role=ROLE_AUTHOR,
            worktree_path=self.wt,
        )
        self.assertEqual(reclaimed["outcome"], "reclaimed")
        self.assertEqual(reclaimed["assignment"]["session_id"], "author-worker-2")

    def test_allocate_payload_includes_consume_identifiers(self) -> None:
        res = self._controller_allocate(number=907)
        self.assertIn("consume_allocation", res)
        c = res["consume_allocation"]
        for key in (
            "tool",
            "lease_id",
            "assignment_id",
            "required_role",
            "required_profile",
            "required_namespace",
            "instructions",
            "handoff_status",
        ):
            self.assertIn(key, c)
        self.assertEqual(c["required_namespace"], "gitea-author")
        self.assertEqual(c["required_profile"], "prgs-author")
        self.assertIn("gitea_adopt_workflow_lease", c["instructions"])
        self.assertTrue(res["lease_proof"]["cross_role_handoff"])
        self.assertEqual(res["lease_proof"]["handoff_status"], "pending")

    def test_same_role_allocation_remains_compatible(self) -> None:
        res = allocate_next_work(
            self.db,
            session_id="author-worker",
            role=ROLE_AUTHOR,
            remote="prgs",
            org="Scaled-Tech-Consulting",
            repo="Gitea-Tools",
            candidates=[self._ready_issue(908)],
            apply=True,
            profile_name="prgs-author",
            username="author-user",
        )
        self.assertEqual(res["outcome"], OUTCOME_ASSIGNED)
        self.assertNotIn("consume_allocation", res)
        lid = res["assignment"]["lease_id"]
        state = self.db.get_lease_workflow_state(lid)
        # No cross-role handoff provenance
        prov = state.get("provenance") or {}
        self.assertFalse(prov.get("cross_role_handoff"))
        # Owner resume still works
        resume = ll.adopt_lease(
            self.db,
            lease_id=lid,
            adopter_session_id="author-worker",
            role=ROLE_AUTHOR,
            worktree_path=self.wt,
        )
        self.assertTrue(resume["same_owner"])
        self.assertEqual(resume["outcome"], "adopted_owner_resume")

    def test_inspect_points_required_role_at_consume(self) -> None:
        res = self._controller_allocate(number=909)
        lid = res["assignment"]["lease_id"]
        decision = ll.inspect_lease(
            self.db, lid, caller_session_id="author-worker"
        )
        self.assertEqual(
            decision["safe_next_action"], ll.SAFE_CONSUME_CROSS_ROLE
        )
        self.assertFalse(decision["block"])
        self.assertEqual(decision["required_role"], ROLE_AUTHOR)

    def test_db_cas_rejects_concurrent_second_consume(self) -> None:
        res = self._controller_allocate(number=910)
        lid = res["assignment"]["lease_id"]
        # First consume via DB layer directly
        first = self.db.adopt_lease(
            lease_id=lid,
            adopter_session_id="author-worker",
            role=ROLE_AUTHOR,
            worktree_path=self.wt,
            provenance={
                "cross_role_handoff": True,
                "handoff_status": "adopted",
                "required_role": "author",
            },
        )
        self.assertEqual(first["outcome"], "adopted_cross_role_handoff")
        # Second CAS must fail
        with self.assertRaises(ForeignLeaseError):
            self.db.adopt_lease(
                lease_id=lid,
                adopter_session_id="author-worker-2",
                role=ROLE_AUTHOR,
                worktree_path=self.wt,
                provenance={
                    "cross_role_handoff": True,
                    "handoff_status": "pending",
                    "required_role": "author",
                },
            )
        state = self.db.get_lease_workflow_state(lid)
        self.assertEqual(state["lease"]["session_id"], "author-worker")


class MCPBoundaryAdoptRoleBindingTest(unittest.TestCase):
    """#843 F1: MCP-boundary role binding for ``gitea_adopt_workflow_lease``.

    The library-level wrong-role test calls ``lease_lifecycle.adopt_lease``
    directly. These tests prove the MCP entry point derives the adopter role
    authoritatively from the active authenticated profile and rejects any
    caller-supplied role that disagrees, so a reviewer/merger profile cannot
    consume an author handoff by passing ``role="author"``.
    """

    AUTHOR_PROFILE = {
        "profile_name": "prgs-author",
        "role": "author",
        "allowed_operations": [
            "gitea.read",
            "gitea.pr.create",
            "gitea.branch.push",
        ],
        "forbidden_operations": [],
    }
    REVIEWER_PROFILE = {
        "profile_name": "prgs-reviewer",
        "role": "reviewer",
        "allowed_operations": [
            "gitea.read",
            "gitea.pr.review",
            "gitea.pr.approve",
            "gitea.pr.request_changes",
        ],
        "forbidden_operations": ["gitea.pr.create", "gitea.branch.push"],
    }
    MERGER_PROFILE = {
        "profile_name": "prgs-merger",
        "role": "merger",
        "allowed_operations": ["gitea.read", "gitea.pr.merge"],
        "forbidden_operations": ["gitea.pr.create", "gitea.branch.push"],
    }
    FOREIGN_AUTHOR_PROFILE = {
        "profile_name": "dadeschools-author",
        "role": "author",
        "allowed_operations": [
            "gitea.read",
            "gitea.pr.create",
            "gitea.branch.push",
        ],
        "forbidden_operations": [],
    }

    def setUp(self) -> None:
        self._tmp = tempfile.TemporaryDirectory()
        self.db_path = os.path.join(self._tmp.name, "cp.sqlite3")
        self.db = ControlPlaneDB(self.db_path)
        self.db.upsert_session(
            session_id="ctrl-session",
            role="controller",
            profile="prgs-controller",
            pid=99999999,
        )
        self.db.upsert_session(
            session_id="author-worker",
            role="author",
            profile="prgs-author",
            pid=os.getpid(),
        )
        self.wt = self._tmp.name

    def tearDown(self) -> None:
        self._tmp.cleanup()

    def _ready_issue(self, number: int) -> WorkCandidate:
        return WorkCandidate(
            kind="issue",
            number=number,
            labels=("status:ready", "type:bug"),
            title="handoff target",
            priority=20,
        )

    def _handoff_lease(self, number: int = 843) -> str:
        res = allocate_next_work(
            db=self.db,
            session_id="ctrl-session",
            role=ROLE_CONTROLLER,
            remote="prgs",
            org="Scaled-Tech-Consulting",
            repo="Gitea-Tools",
            candidates=[self._ready_issue(number)],
            apply=True,
            profile_name="prgs-controller",
            username="controller-user",
            allocation_mode=ALLOCATION_MODE_CROSS_ROLE,
        )
        self.assertEqual(res["outcome"], OUTCOME_ASSIGNED)
        self.assertEqual(res["required_role"], ROLE_AUTHOR)
        return res["assignment"]["lease_id"]

    def _call_adopt_tool(self, profile: dict, **kwargs):
        import gitea_mcp_server as mcp_server

        with (
            patch.object(mcp_server, "get_profile", return_value=profile),
            patch.object(
                mcp_server,
                "_control_plane_db_or_error",
                return_value=(self.db, []),
            ),
        ):
            return mcp_server.gitea_adopt_workflow_lease(
                remote="prgs", **kwargs
            )

    def _assert_handoff_untouched(self, lease_id: str) -> None:
        state = self.db.get_lease_workflow_state(lease_id)
        self.assertEqual(state["lease"]["session_id"], "ctrl-session")
        self.assertIsNone(state["lease"].get("adopted_by_session_id") or None)
        self.assertEqual(state["lease"]["status"], "active")
        self.assertEqual(state["provenance"]["handoff_status"], "pending")

    def test_reviewer_profile_cannot_consume_author_handoff_via_role_author(
        self,
    ) -> None:
        lid = self._handoff_lease(920)
        result = self._call_adopt_tool(
            self.REVIEWER_PROFILE,
            lease_id=lid,
            session_id="reviewer-worker",
            role="author",
            worktree_path=self.wt,
        )
        self.assertFalse(result["success"])
        self.assertEqual(result["outcome"], "blocked")
        self.assertEqual(result["profile_role_kind"], "reviewer")
        self.assertEqual(result["supplied_role"], "author")
        self.assertIn("does not match", result["reasons"][0])
        self._assert_handoff_untouched(lid)

    def test_merger_profile_cannot_consume_author_handoff_via_role_author(
        self,
    ) -> None:
        lid = self._handoff_lease(921)
        result = self._call_adopt_tool(
            self.MERGER_PROFILE,
            lease_id=lid,
            session_id="merger-worker",
            role="author",
            worktree_path=self.wt,
        )
        self.assertFalse(result["success"])
        self.assertEqual(result["outcome"], "blocked")
        self.assertEqual(result["profile_role_kind"], "merger")
        self._assert_handoff_untouched(lid)

    def test_reviewer_profile_rejected_without_role_argument(self) -> None:
        """Even without a spoofed role, the profile-derived role binds."""
        lid = self._handoff_lease(922)
        result = self._call_adopt_tool(
            self.REVIEWER_PROFILE,
            lease_id=lid,
            session_id="reviewer-worker",
            worktree_path=self.wt,
        )
        self.assertFalse(result["success"])
        self.assertEqual(result["outcome"], "blocked")
        self.assertIn("wrong role", result["reasons"][0].lower())
        self._assert_handoff_untouched(lid)

    def test_author_profile_mismatching_supplied_role_rejected(self) -> None:
        lid = self._handoff_lease(923)
        result = self._call_adopt_tool(
            self.AUTHOR_PROFILE,
            lease_id=lid,
            session_id="author-worker",
            role="reviewer",
            worktree_path=self.wt,
        )
        self.assertFalse(result["success"])
        self.assertEqual(result["outcome"], "blocked")
        self.assertEqual(result["profile_role_kind"], "author")
        self.assertEqual(result["supplied_role"], "reviewer")
        self._assert_handoff_untouched(lid)

    def test_foreign_profile_name_rejected_for_author_handoff(self) -> None:
        """Provenance required_profile binds even when the role matches."""
        lid = self._handoff_lease(924)
        result = self._call_adopt_tool(
            self.FOREIGN_AUTHOR_PROFILE,
            lease_id=lid,
            session_id="foreign-author-worker",
            worktree_path=self.wt,
        )
        self.assertFalse(result["success"])
        self.assertEqual(result["outcome"], "blocked")
        self.assertIn("wrong profile", result["reasons"][0].lower())
        self._assert_handoff_untouched(lid)

    def test_author_profile_consumes_author_handoff(self) -> None:
        lid = self._handoff_lease(925)
        result = self._call_adopt_tool(
            self.AUTHOR_PROFILE,
            lease_id=lid,
            session_id="author-worker",
            role="author",
            worktree_path=self.wt,
        )
        self.assertTrue(result["success"])
        self.assertEqual(result["outcome"], "adopted_cross_role_handoff")
        self.assertEqual(result["adopted_by_session_id"], "author-worker")
        self.assertEqual(result["adopted_from_session_id"], "ctrl-session")
        state = self.db.get_lease_workflow_state(lid)
        self.assertEqual(state["lease"]["session_id"], "author-worker")
        self.assertEqual(
            state["lease"]["adopted_by_session_id"], "author-worker"
        )
        self.assertEqual(state["assignment"]["session_id"], "author-worker")
        self.assertEqual(state["provenance"]["handoff_status"], "adopted")

    def test_author_profile_consumes_author_handoff_without_role_argument(
        self,
    ) -> None:
        lid = self._handoff_lease(926)
        result = self._call_adopt_tool(
            self.AUTHOR_PROFILE,
            lease_id=lid,
            session_id="author-worker",
            worktree_path=self.wt,
        )
        self.assertTrue(result["success"])
        self.assertEqual(result["outcome"], "adopted_cross_role_handoff")
        state = self.db.get_lease_workflow_state(lid)
        self.assertEqual(state["lease"]["session_id"], "author-worker")
        self.assertEqual(state["lease"]["role"], "author")


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