"""Per-dev lane barrier on the give_me_work -> claim path.

A developer with a pre-delegated sequenced code queue must not start a later
code leaf while an earlier same-assignee sibling is still open: that is the
out-of-order start that wedged the merge (a later PR cut from a base that
predates the earlier sibling's unmerged changes). ``i_am_idle`` already drops
lane-held leaves via ``_pending_not_lane_held``; this locks the same barrier
on ``give_me_work``'s pre-assigned path and on ``_run_claim_guards`` (the
direct claim verb), so neither route can jump the queue.
"""

from __future__ import annotations

from typing import Any
from unittest.mock import AsyncMock, MagicMock
from uuid import UUID, uuid4

import pytest
from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps


def _make_deps(task: AsyncMock) -> ChoreographerDeps:
    return ChoreographerDeps(
        task=task,
        work_session=AsyncMock(),
        git=AsyncMock(),
        a2a=AsyncMock(),
        journal=AsyncMock(),
        audit=AsyncMock(),
        evidence_repo=AsyncMock(),
    )


def _dev_agent_task_svc() -> tuple[AsyncMock, UUID]:
    task_svc = AsyncMock()
    task_svc.agent_for.return_value = MagicMock(role="developer")
    task_svc.list_pending_for_agent.return_value = []
    task_svc.list_assigned_for_agent.return_value = []
    task_svc.list_paused_for_agent.return_value = []
    task_svc.list_in_progress_for_agent.return_value = []
    # Default: lane clear (no earlier incomplete sibling).
    task_svc.has_earlier_incomplete_code_sibling.return_value = False
    return task_svc, uuid4()


# ---------------------------------------------------------------------------
# give_me_work: pre-assigned path must drop a lane-held code leaf
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_give_me_work_skips_lane_held_pre_assigned_dev_task() -> None:
    """A pre-assigned pending code leaf sitting behind an earlier open
    same-assignee sibling is dropped (not offered); with nothing else
    available the dev goes idle rather than jumping its queue."""
    task_svc, agent_id = _dev_agent_task_svc()
    leaf = MagicMock(id=uuid4(), status="pending", title="later-leaf")
    task_svc.list_pending_for_agent.return_value = [leaf]
    task_svc.has_earlier_incomplete_code_sibling.return_value = True
    deps = _make_deps(task_svc)
    c = Choreographer(deps)

    env = await c.give_me_work(agent_id)
    body = env.as_dict()
    assert body["status"] == "idle"
    assert body["task_id"] is None
    task_svc.has_earlier_incomplete_code_sibling.assert_awaited_once_with(leaf)


@pytest.mark.asyncio
async def test_give_me_work_offers_pre_assigned_when_lane_clear() -> None:
    """A pre-assigned code leaf whose lane is clear (no earlier open sibling)
    is offered as normal — the filter only drops positively lane-held leaves."""
    task_svc, agent_id = _dev_agent_task_svc()
    leaf = MagicMock(id=uuid4(), status="pending", title="ready-leaf")
    task_svc.list_pending_for_agent.return_value = [leaf]
    task_svc.has_earlier_incomplete_code_sibling.return_value = False
    deps = _make_deps(task_svc)
    c = Choreographer(deps)

    env = await c.give_me_work(agent_id)
    body = env.as_dict()
    assert body["task_id"] == str(leaf.id)


@pytest.mark.asyncio
async def test_give_me_work_lane_filter_inert_under_partial_mock() -> None:
    """An AsyncMock stub returns a truthy non-bool (not ``True``); ``is not
    True`` keeps the filter inert so a partial test mock never drops a leaf
    it cannot positively confirm is lane-held."""
    task_svc, agent_id = _dev_agent_task_svc()
    leaf = MagicMock(id=uuid4(), status="pending", title="maybe-leaf")
    task_svc.list_pending_for_agent.return_value = [leaf]
    # Truthy stub, NOT the literal bool True -> inert (leaf kept).
    task_svc.has_earlier_incomplete_code_sibling.return_value = MagicMock()
    deps = _make_deps(task_svc)
    c = Choreographer(deps)

    env = await c.give_me_work(agent_id)
    body = env.as_dict()
    assert body["task_id"] == str(leaf.id)


# ---------------------------------------------------------------------------
# _run_claim_guards: a direct claim of a lane-held code task is refused
# ---------------------------------------------------------------------------


def _claim_task(
    *, task_type: str = "code", dependency_ids: list[Any] | None = None
) -> Any:
    return MagicMock(
        id=uuid4(),
        status="pending",
        assigned_to=uuid4(),
        parent_task_id=uuid4(),
        task_type=task_type,
        dependency_ids=dependency_ids or [],
        team="backend",
    )


@pytest.mark.asyncio
async def test_claim_guard_blocks_lane_held_code_task() -> None:
    """A direct claim of a code leaf with an earlier open same-assignee
    sibling is refused (invalid_state) and parked back to pending."""
    task_svc, agent_id = _dev_agent_task_svc()
    task = _claim_task()
    task_svc.get.return_value = task
    task_svc.has_earlier_incomplete_code_sibling.return_value = True
    deps = _make_deps(task_svc)
    c = Choreographer(deps)

    guard = await c._run_claim_guards(
        agent_id=agent_id, task=task, role_str="developer"
    )
    assert guard is not None
    assert guard.error == "invalid_state"
    task_svc.release_dependency_blocked_claim.assert_awaited_once_with(task.id)


@pytest.mark.asyncio
async def test_claim_guard_allows_when_lane_clear() -> None:
    """A code leaf whose lane is clear proceeds (no rejection)."""
    task_svc, agent_id = _dev_agent_task_svc()
    task = _claim_task()
    task_svc.get.return_value = task
    task_svc.has_earlier_incomplete_code_sibling.return_value = False
    deps = _make_deps(task_svc)
    c = Choreographer(deps)

    guard = await c._run_claim_guards(
        agent_id=agent_id, task=task, role_str="developer"
    )
    assert guard is None
    task_svc.release_dependency_blocked_claim.assert_not_awaited()


@pytest.mark.asyncio
async def test_claim_guard_fail_closed_on_lookup_error() -> None:
    """If the lane lookup raises, the claim is refused (fail-closed) rather
    than letting an out-of-order start through on a DB hiccup. The fail-closed
    path returns the error envelope WITHOUT releasing the claim (#29): a
    transient lookup error must not bounce the dev + abandon their work
    session when the lane may actually be free — they retry give_me_work and
    keep their claim."""
    task_svc, agent_id = _dev_agent_task_svc()
    task = _claim_task()
    task_svc.get.return_value = task
    task_svc.has_earlier_incomplete_code_sibling.side_effect = RuntimeError("db down")
    deps = _make_deps(task_svc)
    c = Choreographer(deps)

    guard = await c._run_claim_guards(
        agent_id=agent_id, task=task, role_str="developer"
    )
    assert guard is not None
    assert guard.error == "invalid_state"
    # No claim release on a transient lookup error — the dev keeps their claim.
    task_svc.release_dependency_blocked_claim.assert_not_awaited()


@pytest.mark.asyncio
async def test_claim_guard_lane_inert_for_non_code_task() -> None:
    """A non-code task (e.g. planning) is not lane-ordered; even if the
    predicate were to return True the guard must not block a coordinator's
    non-code claim — the lane is code-only. Predicate False -> proceed."""
    task_svc, agent_id = _dev_agent_task_svc()
    task = _claim_task(task_type="planning")
    task_svc.get.return_value = task
    task_svc.has_earlier_incomplete_code_sibling.return_value = False
    deps = _make_deps(task_svc)
    c = Choreographer(deps)

    guard = await c._run_claim_guards(agent_id=agent_id, task=task, role_str="main_pm")
    assert guard is None
