"""Authoritative controller cross-role generic queue allocation (#840)."""

from __future__ import annotations

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

from allocator_service import (
    ALLOCATION_MODE_CROSS_ROLE,
    ALLOCATION_MODE_ROLE_SCOPED,
    OUTCOME_NO_SAFE,
    OUTCOME_PREVIEW,
    OUTCOME_WAIT,
    ROLE_AUTHOR,
    ROLE_CONTROLLER,
    ROLE_MERGER,
    ROLE_RECONCILER,
    ROLE_REVIEWER,
    WorkCandidate,
    allocate_next_work,
    build_selection_dict,
    classify_skip,
    required_namespace_for_role,
    required_profile_for_role,
    resolve_allocation_mode,
    selected_action_for_candidate,
)
from control_plane_db import ControlPlaneDB
import role_session_router
from role_session_router import (
    ROUTE_ALLOWED,
    ROUTE_AMBIGUOUS,
    ROUTE_WRONG_ROLE,
    route_task_session,
)
import namespace_workspace_binding as nwb
import task_capability_map


class CrossRoleAllocationModeTest(unittest.TestCase):
    def test_controller_defaults_to_cross_role(self) -> None:
        self.assertEqual(
            resolve_allocation_mode(ROLE_CONTROLLER),
            ALLOCATION_MODE_CROSS_ROLE,
        )

    def test_worker_defaults_to_role_scoped(self) -> None:
        for role in (ROLE_AUTHOR, ROLE_REVIEWER, ROLE_MERGER, ROLE_RECONCILER):
            self.assertEqual(
                resolve_allocation_mode(role),
                ALLOCATION_MODE_ROLE_SCOPED,
            )

    def test_explicit_modes(self) -> None:
        self.assertEqual(
            resolve_allocation_mode(ROLE_CONTROLLER, "role_scoped"),
            ALLOCATION_MODE_ROLE_SCOPED,
        )
        self.assertEqual(
            resolve_allocation_mode(ROLE_AUTHOR, "cross_role"),
            ALLOCATION_MODE_CROSS_ROLE,
        )


class CrossRoleSelectionPayloadTest(unittest.TestCase):
    def test_selection_contains_required_fields(self) -> None:
        c = WorkCandidate(
            kind="issue",
            number=840,
            labels=("status:ready",),
            title="cross-role",
            priority=20,
        )
        sel = build_selection_dict(
            c,
            active_role=ROLE_CONTROLLER,
            required_role=ROLE_AUTHOR,
            profile_name="prgs-controller",
            allocation_mode=ALLOCATION_MODE_CROSS_ROLE,
        )
        self.assertEqual(sel["number"], 840)
        self.assertEqual(sel["kind"], "issue")
        self.assertEqual(sel["required_role"], ROLE_AUTHOR)
        self.assertEqual(sel["selected_action"], "implement")
        self.assertEqual(sel["action"], "implement")
        self.assertEqual(sel["required_profile"], "prgs-author")
        self.assertEqual(sel["required_namespace"], "gitea-author")
        self.assertEqual(sel["pinned"]["number"], 840)
        self.assertIsNone(sel["pinned"]["head_sha"])

    def test_profile_prefix_preserved(self) -> None:
        self.assertEqual(
            required_profile_for_role(ROLE_REVIEWER, profile_name="dadeschools-controller"),
            "dadeschools-reviewer",
        )
        self.assertEqual(
            required_namespace_for_role(ROLE_MERGER),
            "gitea-merger",
        )

    def test_selected_actions_per_role(self) -> None:
        issue = WorkCandidate(kind="issue", number=1, labels=("status:ready",))
        pr_review = WorkCandidate(kind="pr", number=2, head_sha="a" * 40)
        pr_rc = WorkCandidate(
            kind="pr",
            number=3,
            head_sha="b" * 40,
            request_changes_current_head=True,
        )
        pr_merge = WorkCandidate(
            kind="pr",
            number=4,
            head_sha="c" * 40,
            approval_on_current_head=True,
            mergeable=True,
        )
        pr_recon = WorkCandidate(
            kind="pr",
            number=5,
            head_sha="d" * 40,
            approval_contaminated=True,
        )
        self.assertEqual(selected_action_for_candidate(issue, ROLE_AUTHOR), "implement")
        self.assertEqual(
            selected_action_for_candidate(pr_rc, ROLE_AUTHOR),
            "address_pr_change_requests",
        )
        self.assertEqual(
            selected_action_for_candidate(pr_review, ROLE_REVIEWER), "review"
        )
        self.assertEqual(selected_action_for_candidate(pr_merge, ROLE_MERGER), "merge")
        self.assertEqual(
            selected_action_for_candidate(pr_recon, ROLE_RECONCILER),
            "reconcile_contaminated_approval",
        )


class CrossRoleAllocateServiceTest(unittest.TestCase):
    def setUp(self) -> None:
        self._tmp = tempfile.TemporaryDirectory()
        self.db = ControlPlaneDB(os.path.join(self._tmp.name, "cp.sqlite3"))

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

    def _alloc(self, **kwargs):
        defaults = dict(
            db=self.db,
            session_id="ctrl-session",
            role=ROLE_CONTROLLER,
            remote="prgs",
            org="org",
            repo="repo",
            candidates=[],
            apply=False,
            profile_name="prgs-controller",
            username="controller-bot",
            controller_instance_id="ctrl-1",
        )
        defaults.update(kwargs)
        return allocate_next_work(**defaults)

    def test_eligible_author_work(self) -> None:
        cands = [
            WorkCandidate(
                kind="issue",
                number=100,
                labels=("status:ready",),
                title="author work",
                priority=20,
            ),
        ]
        res = self._alloc(candidates=cands)
        self.assertTrue(res["success"])
        self.assertEqual(res["outcome"], OUTCOME_PREVIEW)
        self.assertEqual(res["allocation_mode"], ALLOCATION_MODE_CROSS_ROLE)
        self.assertIsNotNone(res["selected"])
        self.assertEqual(res["selected"]["number"], 100)
        self.assertEqual(res["required_role"], ROLE_AUTHOR)
        self.assertEqual(res["selected_action"], "implement")
        self.assertEqual(res["required_profile"], "prgs-author")
        self.assertEqual(res["required_namespace"], "gitea-author")
        self.assertIn("allocate", res["controller_allowed_actions"])
        self.assertIn("merge", res["controller_forbidden_actions"])
        self.assertFalse(res["allocation_evidence"]["lease_created"])

    def test_eligible_reviewer_work(self) -> None:
        cands = [
            WorkCandidate(
                kind="pr",
                number=200,
                head_sha="e" * 40,
                title="needs review",
                priority=30,
            ),
        ]
        res = self._alloc(candidates=cands)
        self.assertEqual(res["selected"]["number"], 200)
        self.assertEqual(res["required_role"], ROLE_REVIEWER)
        self.assertEqual(res["selected_action"], "review")
        self.assertEqual(res["required_profile"], "prgs-reviewer")
        self.assertEqual(res["selected"]["pinned"]["head_sha"], "e" * 40)

    def test_eligible_merger_work(self) -> None:
        cands = [
            WorkCandidate(
                kind="pr",
                number=300,
                head_sha="f" * 40,
                approval_on_current_head=True,
                mergeable=True,
                priority=40,
            ),
        ]
        res = self._alloc(candidates=cands)
        self.assertEqual(res["selected"]["number"], 300)
        self.assertEqual(res["required_role"], ROLE_MERGER)
        self.assertEqual(res["selected_action"], "merge")

    def test_eligible_reconciler_work(self) -> None:
        cands = [
            WorkCandidate(
                kind="pr",
                number=400,
                head_sha="1" * 40,
                approval_contaminated=True,
                priority=50,
            ),
        ]
        res = self._alloc(candidates=cands)
        self.assertEqual(res["selected"]["number"], 400)
        self.assertEqual(res["required_role"], ROLE_RECONCILER)
        self.assertIn("reconcile", res["selected_action"])

    def test_no_eligible_work(self) -> None:
        cands = [
            WorkCandidate(
                kind="issue",
                number=10,
                labels=("status:blocked",),
                blocked=True,
                priority=99,
            ),
            WorkCandidate(
                kind="issue",
                number=11,
                labels=("status:ready",),
                dependency_unmet=True,
                dependency_reason="blocked by #10",
                priority=98,
            ),
        ]
        res = self._alloc(candidates=cands)
        self.assertTrue(res["success"])
        self.assertEqual(res["outcome"], OUTCOME_NO_SAFE)
        self.assertIsNone(res["selected"])
        self.assertEqual(res["allocation_mode"], ALLOCATION_MODE_CROSS_ROLE)

    def test_leased_work_skipped(self) -> None:
        cands = [
            WorkCandidate(
                kind="issue",
                number=50,
                labels=("status:ready",),
                priority=20,
            ),
            WorkCandidate(
                kind="issue",
                number=51,
                labels=("status:ready",),
                priority=10,
            ),
        ]
        # Seed a foreign lease on issue 50 via assign_and_lease under another session.
        other = allocate_next_work(
            self.db,
            session_id="other-worker",
            role=ROLE_AUTHOR,
            remote="prgs",
            org="org",
            repo="repo",
            candidates=cands[:1],
            apply=True,
            profile_name="prgs-author",
            controller_instance_id="other-ctrl",
        )
        self.assertEqual(other["outcome"], "assigned_work")
        res = self._alloc(candidates=cands)
        self.assertIsNotNone(res["selected"])
        self.assertEqual(res["selected"]["number"], 51)
        self.assertTrue(any(s["number"] == 50 for s in res["skipped"]))
        self.assertTrue(res["claims_excluded"])

    def test_dependencies_skipped(self) -> None:
        cands = [
            WorkCandidate(
                kind="issue",
                number=1,
                labels=("status:ready",),
                priority=99,
                dependency_unmet=True,
                dependency_reason="needs #2",
            ),
            WorkCandidate(
                kind="issue",
                number=2,
                labels=("status:ready",),
                priority=1,
            ),
        ]
        res = self._alloc(candidates=cands)
        self.assertEqual(res["selected"]["number"], 2)
        skipped = {s["number"]: s["reason"] for s in res["skipped"]}
        self.assertIn(1, skipped)
        self.assertIn("needs #2", skipped[1])

    def test_pagination_limit_only_truncates_skip_report(self) -> None:
        """Ranking uses full inventory; reporting limit is MCP-layer only.

        Service ranks all candidates; prove higher-priority eligible item
        wins even when many skipped precede it.
        """
        cands = []
        for n in range(1, 30):
            cands.append(
                WorkCandidate(
                    kind="issue",
                    number=n,
                    labels=("status:ready",),
                    priority=100 - n,
                    dependency_unmet=True,
                    dependency_reason=f"dep {n}",
                )
            )
        cands.append(
            WorkCandidate(
                kind="issue",
                number=999,
                labels=("status:ready",),
                priority=1,
            )
        )
        res = self._alloc(candidates=cands)
        self.assertEqual(res["selected"]["number"], 999)
        self.assertGreaterEqual(len(res["skipped"]), 29)

    def test_role_scoped_controller_legacy_still_restricts(self) -> None:
        """role_scoped controller only takes reconciler-needed items."""
        cands = [
            WorkCandidate(
                kind="issue",
                number=1,
                labels=("status:ready",),
                priority=50,
            ),
            WorkCandidate(
                kind="pr",
                number=2,
                head_sha="a" * 40,
                approval_contaminated=True,
                priority=1,
            ),
        ]
        res = self._alloc(
            candidates=cands,
            allocation_mode=ALLOCATION_MODE_ROLE_SCOPED,
        )
        self.assertEqual(res["allocation_mode"], ALLOCATION_MODE_ROLE_SCOPED)
        self.assertEqual(res["selected"]["number"], 2)
        self.assertEqual(res["required_role"], ROLE_RECONCILER)

    def test_cross_role_prefers_highest_priority_across_roles(self) -> None:
        cands = [
            WorkCandidate(
                kind="issue",
                number=10,
                labels=("status:ready",),
                priority=10,
            ),
            WorkCandidate(
                kind="pr",
                number=20,
                head_sha="b" * 40,
                priority=50,
            ),
            WorkCandidate(
                kind="pr",
                number=30,
                head_sha="c" * 40,
                approval_on_current_head=True,
                mergeable=True,
                priority=20,
            ),
        ]
        res = self._alloc(candidates=cands)
        # PR #20 highest priority → reviewer
        self.assertEqual(res["selected"]["number"], 20)
        self.assertEqual(res["required_role"], ROLE_REVIEWER)

    def test_apply_creates_lease_evidence_for_required_role(self) -> None:
        cands = [
            WorkCandidate(
                kind="issue",
                number=777,
                labels=("status:ready",),
                priority=20,
            ),
        ]
        res = self._alloc(candidates=cands, apply=True)
        self.assertEqual(res["outcome"], "assigned_work")
        self.assertTrue(res["allocation_evidence"]["lease_created"])
        self.assertEqual(res["allocation_evidence"]["lease_role"], ROLE_AUTHOR)
        proof = res["lease_proof"]
        self.assertIsNotNone(proof["lease_id"])
        self.assertEqual(proof["lease_role"], ROLE_AUTHOR)
        self.assertIn("implement", proof["allowed_actions"])
        # Controller isolation: controller still forbids merge/push/create_pr
        self.assertIn("merge", res["controller_forbidden_actions"])
        self.assertIn("push", res["controller_forbidden_actions"])

    def test_metadata_consistency_role_is_controller(self) -> None:
        cands = [
            WorkCandidate(
                kind="issue",
                number=1,
                labels=("status:ready",),
            ),
        ]
        res = self._alloc(candidates=cands)
        self.assertEqual(res["role"], ROLE_CONTROLLER)
        self.assertEqual(res["routing_role"], ROLE_CONTROLLER)
        self.assertEqual(res["required_role"], ROLE_AUTHOR)


class ProcessWorkQueueRouterTest(unittest.TestCase):
    def tearDown(self) -> None:
        role_session_router.clear_route_state()

    def test_process_work_queue_allowed_for_controller(self) -> None:
        res = route_task_session(
            "process_work_queue",
            active_profile="prgs-controller",
            active_role_kind="controller",
            allowed_in_current_session=True,
        )
        self.assertEqual(res["route_result"], ROUTE_ALLOWED)
        self.assertEqual(res["required_role"], "controller")
        self.assertTrue(res["downstream_allowed"])

    def test_process_work_queue_hyphen_alias(self) -> None:
        res = route_task_session(
            "process-work-queue",
            active_profile="prgs-controller",
            active_role_kind="controller",
            allowed_in_current_session=True,
        )
        self.assertEqual(res["route_result"], ROUTE_ALLOWED)

    def test_process_work_queue_wrong_role_for_author(self) -> None:
        res = route_task_session(
            "process_work_queue",
            active_profile="prgs-author",
            active_role_kind="author",
            allowed_in_current_session=False,
        )
        self.assertEqual(res["route_result"], ROUTE_WRONG_ROLE)
        self.assertEqual(res["required_role"], "controller")
        self.assertFalse(res["downstream_allowed"])

    def test_unknown_still_ambiguous(self) -> None:
        res = route_task_session(
            "not_a_real_task",
            active_profile="prgs-controller",
            active_role_kind="controller",
            allowed_in_current_session=False,
        )
        self.assertEqual(res["route_result"], ROUTE_AMBIGUOUS)

    def test_capability_map_process_work_queue_is_controller(self) -> None:
        self.assertEqual(
            task_capability_map.required_role("process_work_queue"),
            "controller",
        )
        self.assertEqual(
            task_capability_map.required_permission("process_work_queue"),
            "gitea.read",
        )


class ControllerRoleMetadataTest(unittest.TestCase):
    def test_normalize_role_kind_controller(self) -> None:
        self.assertEqual(
            nwb.normalize_role_kind("controller"),
            "controller",
        )
        self.assertEqual(
            nwb.normalize_role_kind("author", profile_name="prgs-controller"),
            "controller",
        )
        self.assertEqual(
            nwb.normalize_role_kind("reconciler", profile_name="prgs-controller"),
            "controller",
        )

    def test_profile_role_kind_prefers_declared_controller(self) -> None:
        # Import from worktree package path via sys.path already set by pytest.
        import gitea_mcp_server as mcp

        profile = {
            "profile_name": "prgs-controller",
            "role": "controller",
            "allowed_operations": [
                "gitea.read",
                "gitea.issue.comment",
                "gitea.pr.close",
            ],
            "forbidden_operations": [
                "gitea.pr.approve",
                "gitea.pr.merge",
                "gitea.pr.create",
                "gitea.branch.push",
            ],
        }
        # Declared role wins even if permissions look reconciler-like.
        self.assertEqual(mcp._profile_role_kind(profile), "controller")
        # Name-based fallback.
        profile_no_role = dict(profile)
        profile_no_role["role"] = None
        profile_no_role["role_kind"] = None
        self.assertEqual(mcp._profile_role_kind(profile_no_role), "controller")

    def test_permission_inference_without_controller_name_stays_reconciler(self) -> None:
        import gitea_mcp_server as mcp

        # Pure permission inference still may return reconciler when no controller
        # declaration exists — that is intentional for reconciler profiles.
        role = mcp._role_kind(
            ["gitea.read", "gitea.pr.close", "gitea.issue.comment"],
            ["gitea.pr.approve", "gitea.pr.merge", "gitea.pr.create", "gitea.branch.push"],
        )
        self.assertEqual(role, "reconciler")


class DashboardRemainsExplanatoryTest(unittest.TestCase):
    def test_dashboard_prompt_points_at_allocator_not_self_select(self) -> None:
        import workflow_dashboard as wd

        self.assertIn("gitea_allocate_next_work", wd.PROMPT_CONTROLLER)
        self.assertIn("process_work_queue", wd.PROMPT_CONTROLLER)
        self.assertIn("never replaces allocator", wd.PROMPT_CONTROLLER.lower())
        self.assertNotIn("self-select", wd.PROMPT_CONTROLLER.lower())


class ClassifySkipCrossRoleTest(unittest.TestCase):
    def test_controller_cross_role_accepts_author_issue(self) -> None:
        c = WorkCandidate(kind="issue", number=1, labels=("status:ready",))
        self.assertIsNone(
            classify_skip(
                c,
                role=ROLE_CONTROLLER,
                terminal_pr=None,
                allocation_mode=ALLOCATION_MODE_CROSS_ROLE,
            )
        )

    def test_legacy_controller_skips_author_issue(self) -> None:
        c = WorkCandidate(kind="issue", number=1, labels=("status:ready",))
        reason = classify_skip(
            c,
            role=ROLE_CONTROLLER,
            terminal_pr=None,
            allocation_mode=ALLOCATION_MODE_ROLE_SCOPED,
        )
        self.assertIsNotNone(reason)
        self.assertIn("does not require controller", reason or "")


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