"""Tests for controller-owned allocator (#600) on control-plane DB (#613)."""

from __future__ import annotations

import os
import tempfile
import threading
import unittest
from concurrent.futures import ThreadPoolExecutor, as_completed

from allocator_service import (
    OUTCOME_ASSIGNED,
    OUTCOME_BLOCKED_TERMINAL,
    OUTCOME_NO_SAFE,
    OUTCOME_PREVIEW,
    OUTCOME_WAIT,
    WorkCandidate,
    allocate_next_work,
    candidate_from_dict,
    classify_skip,
    expected_role_for_candidate,
)
from control_plane_db import ControlPlaneDB, InvalidWorkKindError


class AllocatorServiceTest(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)

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

    def _alloc(self, **kwargs):
        defaults = dict(
            db=self.db,
            session_id="s-test",
            role="author",
            remote="prgs",
            org="org",
            repo="repo",
            candidates=[],
            apply=False,
            profile_name="prgs-author",
            username="jcwalker3",
        )
        defaults.update(kwargs)
        return allocate_next_work(**defaults)

    def test_selects_ready_issue_for_author(self) -> None:
        cands = [
            WorkCandidate(
                kind="issue",
                number=612,
                labels=("status:ready",),
                title="bridge",
                dependency_unmet=True,
                dependency_reason="downstream of #600",
            ),
            WorkCandidate(
                kind="issue",
                number=600,
                labels=("status:ready", "type:feature"),
                title="allocator",
                priority=50,
            ),
            WorkCandidate(
                kind="issue",
                number=601,
                labels=("status:blocked",),
                title="blocked",
                blocked=True,
            ),
        ]
        res = self._alloc(candidates=cands, apply=False)
        self.assertTrue(res["success"])
        self.assertEqual(res["outcome"], OUTCOME_PREVIEW)
        self.assertEqual(res["selected"]["number"], 600)
        self.assertEqual(res["selected"]["kind"], "issue")
        # When 600 is highest priority valid work, lower-priority blocked/dep
        # candidates are not visited. Prove they are skipped when they sort first.
        res2 = self._alloc(
            candidates=[
                WorkCandidate(
                    kind="issue",
                    number=612,
                    labels=("status:ready",),
                    priority=99,
                    dependency_unmet=True,
                    dependency_reason="downstream of #600",
                ),
                WorkCandidate(
                    kind="issue",
                    number=601,
                    labels=("status:blocked",),
                    priority=98,
                    blocked=True,
                ),
                WorkCandidate(
                    kind="issue",
                    number=600,
                    labels=("status:ready",),
                    priority=1,
                ),
            ],
            apply=False,
        )
        skipped_nums = {s["number"] for s in res2["skipped"]}
        self.assertIn(612, skipped_nums)
        self.assertIn(601, skipped_nums)
        self.assertEqual(res2["selected"]["number"], 600)
        self.assertIsNone(res["assignment"])
        self.assertFalse(res["file_lock_only"])
        self.assertFalse(res["comment_lease_only"])

    def test_atomic_assign_and_lease_on_apply(self) -> None:
        cands = [
            WorkCandidate(
                kind="issue",
                number=600,
                labels=("status:ready",),
                priority=10,
            )
        ]
        res = self._alloc(candidates=cands, apply=True, session_id="s-a")
        self.assertEqual(res["outcome"], OUTCOME_ASSIGNED)
        asn = res["assignment"]
        self.assertEqual(asn["outcome"], "assigned")
        self.assertIsNotNone(asn["assignment_id"])
        self.assertIsNotNone(asn["lease_id"])
        self.assertEqual(asn["work_number"], 600)
        self.assertIn("implement", asn["allowed_actions"])
        self.assertIn("merge", asn["forbidden_actions"])
        proof = res["lease_proof"]
        self.assertEqual(proof["source"], "control_plane_db.assign_and_lease")

    def test_concurrent_allocators_no_double_assign(self) -> None:
        cand = WorkCandidate(
            kind="pr",
            number=100,
            head_sha="a" * 40,
            priority=10,
        )
        # Seed work item so both race the same target
        self.db.upsert_work_item(
            remote="prgs",
            org="org",
            repo="repo",
            kind="pr",
            number=100,
            current_head_sha="a" * 40,
        )
        results = []
        lock = threading.Lock()

        def worker(sid: str):
            r = allocate_next_work(
                self.db,
                session_id=sid,
                role="reviewer",
                remote="prgs",
                org="org",
                repo="repo",
                candidates=[cand],
                apply=True,
                profile_name="prgs-reviewer",
            )
            with lock:
                results.append(r)

        with ThreadPoolExecutor(max_workers=2) as pool:
            futs = [pool.submit(worker, f"s-{i}") for i in range(2)]
            for f in as_completed(futs):
                f.result()

        outcomes = [r["outcome"] for r in results]
        self.assertEqual(outcomes.count(OUTCOME_ASSIGNED), 1, outcomes)
        self.assertEqual(outcomes.count(OUTCOME_WAIT), 1, outcomes)

    def test_blocked_and_dependency_skipped(self) -> None:
        cands = [
            WorkCandidate(kind="issue", number=1, blocked=True, labels=("status:blocked",)),
            WorkCandidate(
                kind="issue",
                number=612,
                labels=("status:ready",),
                dependency_unmet=True,
                dependency_reason="downstream of #600",
            ),
        ]
        res = self._alloc(candidates=cands, apply=True, role="author")
        self.assertEqual(res["outcome"], OUTCOME_NO_SAFE)
        self.assertIsNone(res["selected"])
        reasons = " ".join(s["reason"] for s in res["skipped"])
        self.assertIn("blocked", reasons.lower())
        self.assertIn("600", reasons)

    def test_already_leased_returns_wait(self) -> None:
        self.db.upsert_session(session_id="owner", role="author")
        self.db.assign_and_lease(
            session_id="owner",
            role="author",
            remote="prgs",
            org="org",
            repo="repo",
            kind="issue",
            number=50,
        )
        res = self._alloc(
            session_id="other",
            candidates=[
                WorkCandidate(kind="issue", number=50, labels=("status:ready",))
            ],
            apply=True,
        )
        self.assertEqual(res["outcome"], OUTCOME_WAIT)
        self.assertEqual(res["owner_session_id"], "owner")

    def test_role_ineligible_skipped(self) -> None:
        # PR with current-head REQUEST_CHANGES expects author, not reviewer.
        cand = WorkCandidate(
            kind="pr",
            number=9,
            head_sha="b" * 40,
            request_changes_current_head=True,
        )
        self.assertEqual(expected_role_for_candidate(cand), "author")
        res = self._alloc(
            role="reviewer",
            profile_name="prgs-reviewer",
            candidates=[cand],
            apply=False,
        )
        self.assertEqual(res["outcome"], OUTCOME_NO_SAFE)
        self.assertTrue(any("expects role" in s["reason"] for s in res["skipped"]))

    def test_terminal_lock_blocks_downstream_reviewer_prs(self) -> None:
        self.db.set_terminal_lock(
            remote="prgs",
            org="org",
            repo="repo",
            terminal_pr=10,
            decision="request_changes",
            status="active",
        )
        cands = [
            WorkCandidate(kind="pr", number=11, head_sha="c" * 40, priority=5),
            WorkCandidate(kind="pr", number=10, head_sha="d" * 40, priority=1),
        ]
        res = self._alloc(
            role="reviewer",
            profile_name="prgs-reviewer",
            candidates=cands,
            apply=False,
        )
        # Terminal PR #10 is selectable; #11 skipped for terminal path.
        self.assertEqual(res["selected"]["number"], 10)
        self.assertTrue(
            any(
                s["number"] == 11 and "terminal-review lock" in s["reason"]
                for s in res["skipped"]
            )
        )

    def test_terminal_lock_blocks_all_when_only_downstream(self) -> None:
        self.db.set_terminal_lock(
            remote="prgs",
            org="org",
            repo="repo",
            terminal_pr=10,
            decision="request_changes",
            status="active",
        )
        res = self._alloc(
            role="reviewer",
            profile_name="prgs-reviewer",
            candidates=[
                WorkCandidate(kind="pr", number=99, head_sha="e" * 40),
            ],
            apply=False,
        )
        self.assertEqual(res["outcome"], OUTCOME_BLOCKED_TERMINAL)

    def test_no_work_structured(self) -> None:
        res = self._alloc(candidates=[], apply=True)
        self.assertEqual(res["outcome"], OUTCOME_NO_SAFE)
        self.assertIsNone(res["selected"])
        self.assertTrue(res["reasons"])

    def test_rejects_incident_kind(self) -> None:
        with self.assertRaises(InvalidWorkKindError):
            WorkCandidate(kind="sentry_incident", number=1)

    def test_612_downstream_marker_in_result(self) -> None:
        res = self._alloc(
            candidates=[
                WorkCandidate(kind="issue", number=600, labels=("status:ready",))
            ],
            apply=True,
        )
        self.assertIn("612", res.get("downstream_note", ""))

    def test_substrate_not_file_or_comment_lease(self) -> None:
        res = self._alloc(
            candidates=[
                WorkCandidate(kind="issue", number=1, labels=("status:ready",))
            ],
            apply=True,
        )
        self.assertEqual(res["substrate"], "control_plane_db")
        self.assertFalse(res["file_lock_only"])
        self.assertFalse(res["comment_lease_only"])
        self.assertEqual(
            res["lease_proof"]["source"], "control_plane_db.assign_and_lease"
        )

    def test_candidate_from_dict(self) -> None:
        c = candidate_from_dict(
            {
                "kind": "pr",
                "number": 7,
                "head_sha": "f" * 40,
                "approval_on_current_head": True,
                "mergeable": True,
            }
        )
        self.assertEqual(expected_role_for_candidate(c), "merger")

    def test_merger_gets_clean_approval(self) -> None:
        c = WorkCandidate(
            kind="pr",
            number=3,
            head_sha="1" * 40,
            approval_on_current_head=True,
            mergeable=True,
            priority=100,
        )
        res = self._alloc(
            role="merger",
            profile_name="prgs-merger",
            candidates=[c],
            apply=True,
            session_id="merger-1",
        )
        self.assertEqual(res["outcome"], OUTCOME_ASSIGNED)
        self.assertEqual(res["assignment"]["work_number"], 3)
        self.assertIn("merge", res["assignment"]["allowed_actions"])

    def test_db_unavailable_fails_closed(self) -> None:
        res = allocate_next_work(
            None,  # type: ignore[arg-type]
            session_id="x",
            role="author",
            remote="prgs",
            org="o",
            repo="r",
            candidates=[],
            apply=True,
        )
        self.assertFalse(res["success"])
        self.assertIn("unavailable", res["reasons"][0].lower())


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