"""Tests for the restored PM lifecycle verbs.

Covers: i_will_plan, delegate, submit_up, pm_give_me_work, and the
auto-pause behavior of i_am_idle for PMs.
"""

from __future__ import annotations

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

import pytest
from roboco.seeds.initial_data import AGENT_UUIDS
from roboco.services.gateway.choreographer import (
    Choreographer,
    ChoreographerDeps,
    DelegateInputs,
)


def _make_deps(**overrides: Any) -> ChoreographerDeps:
    base = {
        "task": AsyncMock(),
        "work_session": AsyncMock(),
        "git": AsyncMock(),
        "a2a": AsyncMock(),
        "journal": AsyncMock(),
        "audit": AsyncMock(),
        "evidence_repo": AsyncMock(),
    }
    base.update(overrides)
    # VerbRunner wraps composed atomic actions in
    # ``task.session.begin_nested()``. AsyncMock auto-attribute access
    # would return an unawaitable coroutine, breaking the
    # ``async with`` protocol. Overwrite session with a MagicMock that
    # implements the async-context-manager protocol explicitly.
    task = base["task"]
    task.session = MagicMock()
    task.session.begin_nested = MagicMock(
        return_value=MagicMock(
            __aenter__=AsyncMock(return_value=None),
            __aexit__=AsyncMock(return_value=False),
        )
    )
    repo = base["evidence_repo"]
    for method in (
        "list_unread_a2a",
        "list_unread_mentions",
        "list_pending_notifications",
        "task_metadata_gaps",
        "recent_team_activity",
        "blockers_in_lane",
        "journal_highlights_for_task",
    ):
        getattr(repo, method).return_value = []
    # C8: default-fresh journal:decision so PM-decision gate passes.
    # Tests that exercise the gate boundary stub their own value.
    # The check matches MagicMock and AsyncMock (the two default sentinel
    # types pytest's unittest.mock leaves on un-stubbed return_values).
    _ldef = base["journal"].latest_decision_at.return_value
    if type(_ldef).__name__ in ("MagicMock", "AsyncMock"):
        base["journal"].latest_decision_at.return_value = datetime.now(UTC)
    return ChoreographerDeps(**base)


# ---------------------------------------------------------------------------
# i_will_plan
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_i_will_plan_claims_starts_and_sets_plan() -> None:
    pm_id = uuid4()
    task_id = uuid4()
    pending = MagicMock(
        id=task_id,
        status="pending",
        plan=None,
        assigned_to=None,
        task_type="planning",
        parent_task_id=None,
        sequence=0,
    )
    claimed = MagicMock(
        id=task_id,
        status="claimed",
        plan=None,
        assigned_to=pm_id,
        task_type="planning",
    )
    started = MagicMock(
        id=task_id,
        status="in_progress",
        plan={"text": "x"},
        assigned_to=pm_id,
        task_type="planning",
    )
    task_svc = AsyncMock()
    task_svc.get.return_value = pending
    task_svc.agent_for.return_value = MagicMock(
        id=pm_id, role="cell_pm", team="backend", slug=None
    )
    task_svc.list_in_progress_for_agent.return_value = []
    task_svc.list_paused_for_agent.return_value = []
    task_svc.get_subtasks.return_value = []
    task_svc.claim.return_value = claimed
    task_svc.set_plan.return_value = claimed
    task_svc.start.return_value = started
    deps = _make_deps(task=task_svc)
    c = Choreographer(deps)

    env = await c.i_will_plan(
        pm_id,
        task_id,
        plan="break the work into 3 subtasks",
        rich_plan={
            "approach": (
                "Three-cell decomposition: backend, frontend, and ux each "
                "own a vertical slice of the work. Backend lands first, QA "
                "reviews each PR after it opens, documentation follows, then "
                "complete and submit up. Strict sequencing with no cross-cell "
                "dependencies beyond the stated ordering."
            ),
            "sub_tasks": [
                {
                    "title": "Backend slice",
                    "description": (
                        "be-dev-1 implements the API + DB migration with "
                        "tests and opens the leaf PR for QA review."
                    ),
                },
                {
                    "title": "Frontend slice",
                    "description": (
                        "fe-dev-1 wires the UI integration with loading and "
                        "error states and opens the leaf PR for QA."
                    ),
                },
            ],
        },
    )
    assert env.error is None
    assert env.status == "in_progress"
    task_svc.claim.assert_awaited_once_with(task_id, pm_id)
    task_svc.set_plan.assert_awaited_once()
    task_svc.start.assert_awaited_once_with(task_id, pm_id)


@pytest.mark.asyncio
async def test_i_will_plan_blocks_when_journal_decision_at_claim_missing() -> None:
    """Pre-gateway parity P3: i_will_plan requires a journal:decision at claim.

    The composed (claim, set_plan, start) sequence runs first — the
    claim sticks. Then the post-claim tracing gate fires because no
    journal:decision exists for (PM, task), and the agent gets a
    tracing_gap pointing at note(scope='decision', ...).
    """
    pm_id = uuid4()
    task_id = uuid4()
    pending = MagicMock(
        id=task_id,
        status="pending",
        plan=None,
        assigned_to=None,
        task_type="planning",
        parent_task_id=None,
        sequence=0,
    )
    started = MagicMock(
        id=task_id,
        status="in_progress",
        plan={"text": "x"},
        assigned_to=pm_id,
        task_type="planning",
    )
    task_svc = AsyncMock()
    # `get` is called twice: at verb entry and inside _post_claim_journal_gate.
    task_svc.get.side_effect = [pending, started]
    task_svc.agent_for.return_value = MagicMock(
        id=pm_id, role="cell_pm", team="backend", slug=None
    )
    task_svc.list_in_progress_for_agent.return_value = []
    task_svc.list_paused_for_agent.return_value = []
    task_svc.get_subtasks.return_value = []
    task_svc.claim.return_value = MagicMock(
        id=task_id, status="claimed", plan=None, assigned_to=pm_id
    )
    task_svc.set_plan.return_value = MagicMock(
        id=task_id, status="claimed", plan={"text": "x"}, assigned_to=pm_id
    )
    task_svc.start.return_value = started
    journal_svc = AsyncMock()
    journal_svc.has_decision_for_task.return_value = False
    journal_svc.latest_decision_at.return_value = None
    deps = _make_deps(task=task_svc, journal=journal_svc)
    c = Choreographer(deps)

    env = await c.i_will_plan(
        pm_id,
        task_id,
        plan="break the work into 3 subtasks",
        rich_plan={
            "approach": (
                "Three-cell decomposition: backend, frontend, and ux each "
                "own a vertical slice of the work. Backend lands first, QA "
                "reviews each PR after it opens, documentation follows, then "
                "complete and submit up. Strict sequencing with no cross-cell "
                "dependencies beyond the stated ordering."
            ),
            "sub_tasks": [
                {
                    "title": "Backend slice",
                    "description": (
                        "be-dev-1 implements the API + DB migration with "
                        "tests and opens the leaf PR for QA review."
                    ),
                },
                {
                    "title": "Frontend slice",
                    "description": (
                        "fe-dev-1 wires the UI integration with loading and "
                        "error states and opens the leaf PR for QA."
                    ),
                },
            ],
        },
    )
    body = env.as_dict()
    assert body["error"] == "tracing_gap"
    assert "journal:decision_at_claim" in body["missing"]
    assert "note(scope='decision'" in body["remediate"]
    # The composed action ran — claim+set_plan+start fired even though
    # the post-claim gate failed.
    task_svc.claim.assert_awaited_once_with(task_id, pm_id)
    task_svc.start.assert_awaited_once_with(task_id, pm_id)


@pytest.mark.asyncio
async def test_i_will_plan_rejects_non_pm_role() -> None:
    pm_id = uuid4()
    task_id = uuid4()
    task_svc = AsyncMock()
    task_svc.get.return_value = MagicMock(id=task_id, status="pending")
    task_svc.agent_for.return_value = MagicMock(role="developer", team="backend")
    deps = _make_deps(task=task_svc)
    c = Choreographer(deps)

    env = await c.i_will_plan(pm_id, task_id, plan="x")
    body = env.as_dict()
    assert body["error"] == "not_authorized"


@pytest.mark.asyncio
async def test_i_will_plan_rejects_non_pending_state() -> None:
    pm_id = uuid4()
    task_id = uuid4()
    task_svc = AsyncMock()
    task_svc.get.return_value = MagicMock(
        id=task_id, status="in_progress", assigned_to=uuid4()
    )
    task_svc.agent_for.return_value = MagicMock(role="cell_pm", team="backend")
    deps = _make_deps(task=task_svc)
    c = Choreographer(deps)

    env = await c.i_will_plan(
        pm_id,
        task_id,
        plan="x",
        rich_plan={
            "approach": (
                "Single-cell decomposition: backend handles all scope. "
                "be-dev-1 owns the change end to end — branch, implement, "
                "test, open PR; QA reviews after the PR opens, documentation "
                "follows, then be-pm completes and submits up. No cross-cell "
                "dependencies for this planning task."
            ),
            "sub_tasks": [
                {
                    "title": "Slice A",
                    "description": (
                        "be-dev-1 implements the backend API change with "
                        "tests and opens the leaf PR for QA review."
                    ),
                }
            ],
        },
    )
    body = env.as_dict()
    assert body["error"] == "invalid_state"


@pytest.mark.asyncio
async def test_i_will_plan_calls_claim_when_pre_assigned_and_pending() -> None:
    """Regression: CEO pre-assigns root task to main-pm with status=pending.

    Old code skipped claim when task.assigned_to already matched the
    caller, leaving status=pending and start() silently returning None
    while the choreographer fabricated an OK envelope. Smoke 2026-05-03.
    """
    pm_id = uuid4()
    task_id = uuid4()
    pending_pre_assigned = MagicMock(
        id=task_id,
        status="pending",
        plan=None,
        assigned_to=pm_id,  # CEO pre-assigned to this PM
        task_type="planning",
        parent_task_id=None,
        sequence=0,
    )
    claimed = MagicMock(
        id=task_id,
        status="claimed",
        plan=None,
        assigned_to=pm_id,
        task_type="planning",
    )
    started = MagicMock(
        id=task_id,
        status="in_progress",
        plan={"text": "x"},
        assigned_to=pm_id,
        task_type="planning",
    )
    task_svc = AsyncMock()
    task_svc.get.return_value = pending_pre_assigned
    task_svc.agent_for.return_value = MagicMock(
        id=pm_id, role="main_pm", team="main_pm", slug=None
    )
    task_svc.list_in_progress_for_agent.return_value = []
    task_svc.list_paused_for_agent.return_value = []
    task_svc.get_subtasks.return_value = []
    task_svc.claim.return_value = claimed
    task_svc.set_plan.return_value = claimed
    task_svc.start.return_value = started
    deps = _make_deps(task=task_svc)
    c = Choreographer(deps)

    env = await c.i_will_plan(
        pm_id,
        task_id,
        plan="distribute to be-pm and fe-pm",
        rich_plan={
            "approach": (
                "Two-cell dispatch: be-pm owns the backend vertical, fe-pm "
                "owns the frontend vertical. Backend lands first, QA reviews "
                "each PR after it opens, documentation follows, then complete "
                "and submit up. Strict sequencing with no cross-cell "
                "dependencies beyond the stated ordering."
            ),
            "sub_tasks": [
                {
                    "title": "Backend cell",
                    "description": (
                        "be-pm decomposes and delegates the backend vertical "
                        "to be-dev-1, who implements and opens the leaf PR."
                    ),
                },
                {
                    "title": "Frontend cell",
                    "description": (
                        "fe-pm decomposes and delegates the frontend vertical "
                        "to fe-dev-1, who implements and opens the leaf PR."
                    ),
                },
            ],
        },
    )

    assert env.error is None
    assert env.status == "in_progress"
    # The bug: claim was skipped when assigned_to == pm_agent_id.
    # The fix: claim is driven by status == pending, not assigned_to mismatch.
    task_svc.claim.assert_awaited_once_with(task_id, pm_id)
    task_svc.start.assert_awaited_once_with(task_id, pm_id)


@pytest.mark.asyncio
async def test_i_will_plan_surfaces_start_failure_instead_of_faking_ok() -> None:
    """Regression: when start() returns None, the verb must reject — not lie.

    Old code returned Envelope.ok with status='in_progress' even when
    start failed silently. Agents then called delegate against a still-
    pending task and got PARENT_NOT_CLAIMED in a loop. Smoke 2026-05-03.
    """
    pm_id = uuid4()
    task_id = uuid4()
    pending = MagicMock(
        id=task_id,
        status="pending",
        plan=None,
        assigned_to=None,
        task_type="planning",
        parent_task_id=None,
        sequence=0,
    )
    claimed = MagicMock(
        id=task_id,
        status="claimed",
        plan=None,
        assigned_to=pm_id,
        task_type="planning",
    )
    task_svc = AsyncMock()
    task_svc.get.return_value = pending
    task_svc.agent_for.return_value = MagicMock(
        id=pm_id, role="main_pm", team="main_pm", slug=None
    )
    task_svc.list_in_progress_for_agent.return_value = []
    task_svc.list_paused_for_agent.return_value = []
    task_svc.get_subtasks.return_value = []
    task_svc.claim.return_value = claimed
    task_svc.set_plan.return_value = claimed
    task_svc.start.return_value = None  # the bug: start fails silently
    deps = _make_deps(task=task_svc)
    c = Choreographer(deps)

    env = await c.i_will_plan(
        pm_id,
        task_id,
        plan="x",
        rich_plan={
            "approach": (
                "Single-cell decomposition: backend handles all scope. "
                "be-dev-1 owns the change end to end — branch, implement, "
                "test, open PR; QA reviews after the PR opens, documentation "
                "follows, then be-pm completes and submits up. No cross-cell "
                "dependencies for this planning task."
            ),
            "sub_tasks": [
                {
                    "title": "Slice A",
                    "description": (
                        "be-dev-1 implements the backend API change with "
                        "tests and opens the leaf PR for QA review."
                    ),
                }
            ],
        },
    )
    body = env.as_dict()
    assert body["error"] == "invalid_state"
    assert "start failed" in body["message"]


@pytest.mark.asyncio
async def test_i_will_plan_idempotent_when_already_in_progress_for_caller() -> None:
    """Regression: respawned PM re-calling i_will_plan on a task they
    already moved to in_progress must NOT be rejected. Returns OK with
    current state. Smoke 2026-05-04 captured the cycle the old reject
    caused: respawn → reject → reaper drops claim → respawn → loop.
    """
    pm_id = uuid4()
    task_id = uuid4()
    in_progress = MagicMock(
        id=task_id,
        status="in_progress",
        plan={"text": "x"},
        assigned_to=pm_id,
        task_type="planning",
        parent_task_id=None,
        sequence=0,
    )
    task_svc = AsyncMock()
    task_svc.get.return_value = in_progress
    task_svc.agent_for.return_value = MagicMock(role="main_pm", team="main_pm")
    task_svc.list_in_progress_for_agent.return_value = [in_progress]
    task_svc.list_paused_for_agent.return_value = []
    deps = _make_deps(task=task_svc)
    c = Choreographer(deps)

    env = await c.i_will_plan(
        pm_id,
        task_id,
        plan="re-entry plan",
        rich_plan={
            "approach": (
                "Idempotent re-entry: task already in progress, refresh heartbeat."
            ),
            "sub_tasks": [{"title": "Re-entry subtask", "description": "Resume work"}],
        },
    )

    assert env.error is None
    assert env.status == "in_progress"
    # Heartbeat refreshed so the reaper sees activity.
    task_svc.heartbeat.assert_awaited()
    # Did NOT re-call claim or start — already past those.
    task_svc.claim.assert_not_awaited()
    task_svc.start.assert_not_awaited()


@pytest.mark.asyncio
async def test_i_will_plan_recovery_when_already_claimed_for_caller() -> None:
    """Same regression but task is in claimed (post-claim, pre-start).

    Recovery semantics (Task 12 spec migration): when the caller already
    owns the task in `claimed`, the verb runs only set_plan + start
    (skipping re-claim, which the spec gate would reject because CLAIMED
    is not a source state for `claim`). End state is `in_progress`.
    """
    pm_id = uuid4()
    task_id = uuid4()
    claimed = MagicMock(
        id=task_id,
        status="claimed",
        plan=None,
        assigned_to=pm_id,
        task_type="planning",
        parent_task_id=None,
        sequence=0,
    )
    started = MagicMock(
        id=task_id,
        status="in_progress",
        plan="re-entry plan",
        assigned_to=pm_id,
        task_type="planning",
    )
    task_svc = AsyncMock()
    task_svc.get.return_value = claimed
    task_svc.agent_for.return_value = MagicMock(
        id=pm_id, role="main_pm", team="main_pm", slug=None
    )
    task_svc.list_in_progress_for_agent.return_value = []
    task_svc.list_paused_for_agent.return_value = []
    task_svc.get_subtasks.return_value = []
    task_svc.set_plan.return_value = claimed
    task_svc.start.return_value = started
    deps = _make_deps(task=task_svc)
    c = Choreographer(deps)

    env = await c.i_will_plan(
        pm_id,
        task_id,
        plan="re-entry plan",
        rich_plan={
            "approach": (
                "Recovery re-entry: task claimed but not started; run set_plan + start."
            ),
            "sub_tasks": [
                {"title": "Recovery subtask", "description": "Resume from claimed"}
            ],
        },
    )

    assert env.error is None
    assert env.status == "in_progress"
    task_svc.heartbeat.assert_awaited()
    # Recovery does NOT re-call claim — CLAIMED is not a claim source state.
    task_svc.claim.assert_not_awaited()
    # Recovery DOES call start to push claimed → in_progress.
    task_svc.start.assert_awaited_once_with(task_id, pm_id)


@pytest.mark.asyncio
async def test_i_will_plan_still_rejects_in_progress_for_other_agent() -> None:
    """Idempotency only applies to the caller. Different PM still rejected."""
    pm_id = uuid4()
    other_pm_id = uuid4()
    task_id = uuid4()
    in_progress = MagicMock(
        id=task_id,
        status="in_progress",
        plan={"text": "x"},
        assigned_to=other_pm_id,  # different PM owns it
        task_type="planning",
        parent_task_id=None,
        sequence=0,
    )
    task_svc = AsyncMock()
    task_svc.get.return_value = in_progress
    task_svc.agent_for.return_value = MagicMock(role="main_pm", team="main_pm")
    deps = _make_deps(task=task_svc)
    c = Choreographer(deps)

    env = await c.i_will_plan(
        pm_id,
        task_id,
        plan="x",
        rich_plan={
            "approach": (
                "Single-cell decomposition: backend handles all scope. "
                "be-dev-1 owns the change end to end — branch, implement, "
                "test, open PR; QA reviews after the PR opens, documentation "
                "follows, then be-pm completes and submits up. No cross-cell "
                "dependencies for this planning task."
            ),
            "sub_tasks": [
                {
                    "title": "Slice A",
                    "description": (
                        "be-dev-1 implements the backend API change with "
                        "tests and opens the leaf PR for QA review."
                    ),
                }
            ],
        },
    )
    body = env.as_dict()

    assert body["error"] == "invalid_state"


@pytest.mark.asyncio
async def test_i_will_plan_returns_tracing_gap_without_plan() -> None:
    pm_id = uuid4()
    task_id = uuid4()
    task_svc = AsyncMock()
    task_svc.get.return_value = MagicMock(
        id=task_id, status="pending", plan=None, assigned_to=None
    )
    task_svc.agent_for.return_value = MagicMock(role="main_pm", team="main_pm")
    deps = _make_deps(task=task_svc)
    c = Choreographer(deps)

    env = await c.i_will_plan(
        pm_id,
        task_id,
        plan="",
        rich_plan={
            "approach": (
                "Single-cell decomposition: backend handles all scope. "
                "be-dev-1 owns the change end to end — branch, implement, "
                "test, open PR; QA reviews after the PR opens, documentation "
                "follows, then be-pm completes and submits up. No cross-cell "
                "dependencies for this planning task."
            ),
            "sub_tasks": [
                {
                    "title": "Slice A",
                    "description": (
                        "be-dev-1 implements the backend API change with "
                        "tests and opens the leaf PR for QA review."
                    ),
                }
            ],
        },
    )
    body = env.as_dict()
    assert body["error"] == "tracing_gap"
    assert "plan" in body["missing"]


@pytest.mark.asyncio
async def test_i_will_plan_task_not_found() -> None:
    pm_id = uuid4()
    task_id = uuid4()
    task_svc = AsyncMock()
    task_svc.get.return_value = None
    deps = _make_deps(task=task_svc)
    c = Choreographer(deps)

    env = await c.i_will_plan(pm_id, task_id, plan="x")
    assert env.as_dict()["error"] == "not_found"


# ---------------------------------------------------------------------------
# delegate
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_delegate_main_pm_to_cell_pm_creates_subtask() -> None:
    main_pm_id = uuid4()
    parent_id = uuid4()
    project_id = uuid4()
    parent = MagicMock(
        id=parent_id,
        project_id=project_id,
        status="in_progress",
        assigned_to=main_pm_id,
    )
    new_task = MagicMock(id=uuid4())
    task_svc = AsyncMock()
    task_svc.get.return_value = parent
    task_svc.agent_for.return_value = MagicMock(role="main_pm", team="main_pm")
    task_svc.get_subtasks.return_value = []
    task_svc.create_subtask.return_value = new_task
    deps = _make_deps(task=task_svc)
    c = Choreographer(deps)

    env = await c.delegate(
        main_pm_id,
        parent_id,
        DelegateInputs(
            title="Backend planning",
            description="Plan backend work for feature X",
            assigned_to="be-pm",
            team="backend",
            task_type="planning",
            nature="technical",
            acceptance_criteria=["all backend subtasks defined with criteria"],
        ),
    )
    assert env.error is None
    assert env.status == "created"
    task_svc.create_subtask.assert_awaited_once()
    req = task_svc.create_subtask.call_args.args[0]
    assert req.parent_task_id == parent_id
    assert req.assigned_to == UUID(AGENT_UUIDS["be-pm"])


@pytest.mark.asyncio
async def test_delegate_cell_pm_to_team_dev_creates_subtask() -> None:
    cell_pm_id = uuid4()
    parent_id = uuid4()
    project_id = uuid4()
    parent = MagicMock(
        id=parent_id,
        project_id=project_id,
        status="in_progress",
        assigned_to=cell_pm_id,
    )
    new_task = MagicMock(id=uuid4())
    task_svc = AsyncMock()
    task_svc.get.return_value = parent
    task_svc.agent_for.return_value = MagicMock(role="cell_pm", team="backend")
    task_svc.get_subtasks.return_value = []
    task_svc.create_subtask.return_value = new_task
    deps = _make_deps(task=task_svc)
    c = Choreographer(deps)

    env = await c.delegate(
        cell_pm_id,
        parent_id,
        DelegateInputs(
            title="Implement endpoint",
            description="Add /v1/foo endpoint with tests",
            assigned_to="be-dev-1",
            team="backend",
            task_type="code",
            nature="technical",
            acceptance_criteria=["GET /v1/foo returns 200 with body"],
        ),
    )
    assert env.error is None
    assert env.status == "created"


@pytest.mark.asyncio
async def test_delegate_main_pm_to_dev_is_rejected() -> None:
    main_pm_id = uuid4()
    parent_id = uuid4()
    parent = MagicMock(
        id=parent_id,
        project_id=uuid4(),
        status="in_progress",
        assigned_to=main_pm_id,
    )
    task_svc = AsyncMock()
    task_svc.get.return_value = parent
    task_svc.agent_for.return_value = MagicMock(role="main_pm", team="main_pm")
    task_svc.get_subtasks.return_value = []
    deps = _make_deps(task=task_svc)
    c = Choreographer(deps)

    env = await c.delegate(
        main_pm_id,
        parent_id,
        DelegateInputs(
            title="Implement endpoint",
            description="Add /v1/foo endpoint with passing tests please",
            assigned_to="be-dev-1",
            team="backend",
            task_type="code",
            nature="technical",
            acceptance_criteria=["GET /v1/foo returns 200 with body"],
        ),
    )
    body = env.as_dict()
    assert body["error"] == "not_authorized"
    assert "be-pm" in body["remediate"]


@pytest.mark.asyncio
async def test_delegate_cell_pm_to_other_pm_rejected() -> None:
    cell_pm_id = uuid4()
    parent_id = uuid4()
    parent = MagicMock(
        id=parent_id,
        project_id=uuid4(),
        status="in_progress",
        assigned_to=cell_pm_id,
    )
    task_svc = AsyncMock()
    task_svc.get.return_value = parent
    task_svc.agent_for.return_value = MagicMock(role="cell_pm", team="backend")
    task_svc.get_subtasks.return_value = []
    deps = _make_deps(task=task_svc)
    c = Choreographer(deps)

    env = await c.delegate(
        cell_pm_id,
        parent_id,
        DelegateInputs(
            title="Implement endpoint",
            description="Add /v1/foo endpoint with passing tests please",
            assigned_to="be-pm",
            team="backend",
            task_type="code",
            nature="technical",
            acceptance_criteria=["GET /v1/foo returns 200 with body"],
        ),
    )
    body = env.as_dict()
    assert body["error"] == "not_authorized"


@pytest.mark.asyncio
async def test_delegate_unknown_assignee_returns_invalid_state() -> None:
    pm_id = uuid4()
    parent_id = uuid4()
    parent = MagicMock(
        id=parent_id,
        project_id=uuid4(),
        status="in_progress",
        assigned_to=pm_id,
    )
    task_svc = AsyncMock()
    task_svc.get.return_value = parent
    task_svc.agent_for.return_value = MagicMock(role="main_pm", team="main_pm")
    task_svc.get_subtasks.return_value = []
    deps = _make_deps(task=task_svc)
    c = Choreographer(deps)

    env = await c.delegate(
        pm_id,
        parent_id,
        DelegateInputs(
            title="Implement endpoint",
            description="Add /v1/foo endpoint with passing tests please",
            assigned_to="nope-pm",
            team="backend",
            task_type="code",
            nature="technical",
            acceptance_criteria=["GET /v1/foo returns 200 with body"],
        ),
    )
    body = env.as_dict()
    assert body["error"] == "not_authorized"


@pytest.mark.asyncio
async def test_delegate_invalid_team_enum_rejected() -> None:
    pm_id = uuid4()
    parent_id = uuid4()
    parent = MagicMock(
        id=parent_id,
        project_id=uuid4(),
        status="in_progress",
        assigned_to=pm_id,
    )
    task_svc = AsyncMock()
    task_svc.get.return_value = parent
    task_svc.agent_for.return_value = MagicMock(role="cell_pm", team="backend")
    task_svc.get_subtasks.return_value = []
    deps = _make_deps(task=task_svc)
    c = Choreographer(deps)

    env = await c.delegate(
        pm_id,
        parent_id,
        DelegateInputs(
            title="Implement endpoint",
            description="Add /v1/foo endpoint with passing tests please",
            assigned_to="be-dev-1",
            team="not-a-team",
            task_type="code",
            nature="technical",
            acceptance_criteria=["GET /v1/foo returns 200 with body"],
        ),
    )
    assert env.as_dict()["error"] == "invalid_state"


# ---------------------------------------------------------------------------
# submit_up
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_submit_up_opens_pr_and_keeps_cell_pm_assignment() -> None:
    """submit_up opens the cell→root PR and moves the cell task into the
    in-path PR-review gate (awaiting_pr_review), but does NOT hand it off to
    Main PM. The cell PM owns cell completion (it is respawned to `complete`
    after the cell reviewer pr_passes), so the assignment stays put — no
    reassign."""
    pm_id = uuid4()
    task_id = uuid4()
    t = MagicMock(
        id=task_id,
        status="in_progress",
        assigned_to=pm_id,
        parent_task_id=None,
        branch_name="feature/backend/abc123",
        team="backend",
    )
    after = MagicMock(
        id=task_id,
        status="awaiting_pr_review",
        assigned_to=pm_id,
        parent_task_id=None,
        branch_name="feature/backend/abc123",
        team="backend",
    )
    task_svc = AsyncMock()
    task_svc.get.return_value = t
    task_svc.agent_for.return_value = MagicMock(role="cell_pm", team="backend")
    task_svc.all_subtasks_terminal.return_value = True
    task_svc.submit_for_review.return_value = after
    git_svc = AsyncMock()
    git_svc.create_pr.return_value = {"pr_number": 12, "pr_url": "x"}
    journal_svc = AsyncMock()
    journal_svc.has_decision_for_task.return_value = True
    journal_svc.latest_decision_at.return_value = datetime.now(UTC)
    deps = _make_deps(task=task_svc, git=git_svc, journal=journal_svc)
    c = Choreographer(deps)

    env = await c.submit_up(
        pm_id, task_id, notes="cell completed all subtasks; ready for review"
    )
    assert env.error is None
    assert env.status == "awaiting_pr_review"
    git_svc.create_pr.assert_awaited_once()
    task_svc.reassign.assert_not_called()


@pytest.mark.asyncio
async def test_submit_up_blocks_when_subtasks_not_terminal() -> None:
    pm_id = uuid4()
    task_id = uuid4()
    t = MagicMock(
        id=task_id,
        status="in_progress",
        assigned_to=pm_id,
        branch_name="feature/backend/abc123",
        team="backend",
    )
    task_svc = AsyncMock()
    task_svc.get.return_value = t
    task_svc.agent_for.return_value = MagicMock(role="cell_pm", team="backend")
    task_svc.all_subtasks_terminal.return_value = False
    journal_svc = AsyncMock()
    journal_svc.has_decision_for_task.return_value = True
    journal_svc.latest_decision_at.return_value = datetime.now(UTC)
    deps = _make_deps(task=task_svc, journal=journal_svc)
    c = Choreographer(deps)

    env = await c.submit_up(pm_id, task_id, notes="ready for main pm please review")
    body = env.as_dict()
    assert body["error"] == "tracing_gap"
    assert "subtasks" in str(body["missing"]).lower()


@pytest.mark.asyncio
async def test_submit_up_rejects_main_pm_role() -> None:
    main_pm_id = uuid4()
    task_id = uuid4()
    t = MagicMock(id=task_id, status="in_progress", assigned_to=main_pm_id)
    task_svc = AsyncMock()
    task_svc.get.return_value = t
    task_svc.agent_for.return_value = MagicMock(role="main_pm", team="main_pm")
    deps = _make_deps(task=task_svc)
    c = Choreographer(deps)

    env = await c.submit_up(main_pm_id, task_id, notes="enough words to pass min len")
    body = env.as_dict()
    assert body["error"] == "not_authorized"


@pytest.mark.asyncio
async def test_submit_up_blocks_without_journal_decision() -> None:
    pm_id = uuid4()
    task_id = uuid4()
    t = MagicMock(
        id=task_id,
        status="in_progress",
        assigned_to=pm_id,
        branch_name="feature/backend/abc",
        team="backend",
    )
    task_svc = AsyncMock()
    task_svc.get.return_value = t
    task_svc.agent_for.return_value = MagicMock(role="cell_pm", team="backend")
    journal_svc = AsyncMock()
    journal_svc.has_decision_for_task.return_value = False
    journal_svc.latest_decision_at.return_value = None
    deps = _make_deps(task=task_svc, journal=journal_svc)
    c = Choreographer(deps)

    env = await c.submit_up(pm_id, task_id, notes="enough words to pass min len")
    body = env.as_dict()
    assert body["error"] == "tracing_gap"
    assert "journal:decision" in body["missing"]


@pytest.mark.asyncio
async def test_submit_up_short_notes_rejected() -> None:
    pm_id = uuid4()
    task_id = uuid4()
    t = MagicMock(id=task_id, status="in_progress", assigned_to=pm_id)
    task_svc = AsyncMock()
    task_svc.get.return_value = t
    task_svc.agent_for.return_value = MagicMock(role="cell_pm", team="backend")
    deps = _make_deps(task=task_svc)
    c = Choreographer(deps)

    env = await c.submit_up(pm_id, task_id, notes="short")
    body = env.as_dict()
    assert body["error"] == "tracing_gap"


# ---------------------------------------------------------------------------
# pm_give_me_work
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_pm_give_me_work_returns_first_assigned() -> None:
    pm_id = uuid4()
    t = MagicMock(id=uuid4(), status="pending", title="x", team="backend")
    task_svc = AsyncMock()
    task_svc.list_pending_for_agent.return_value = []
    task_svc.list_assigned_for_agent.return_value = [t]
    deps = _make_deps(task=task_svc)
    c = Choreographer(deps)

    env = await c.pm_give_me_work(pm_id)
    assert env.error is None
    assert env.task_id == str(t.id)
    assert env.next is not None
    assert "i_will_plan" in env.next


@pytest.mark.asyncio
async def test_pm_give_me_work_returns_idle_when_empty() -> None:
    pm_id = uuid4()
    task_svc = AsyncMock()
    task_svc.list_pending_for_agent.return_value = []
    task_svc.list_assigned_for_agent.return_value = []
    deps = _make_deps(task=task_svc)
    c = Choreographer(deps)

    env = await c.pm_give_me_work(pm_id)
    assert env.status == "idle"


@pytest.mark.asyncio
async def test_pm_give_me_work_paused_hint_mentions_subtasks() -> None:
    pm_id = uuid4()
    t = MagicMock(id=uuid4(), status="paused", title="x", team="backend")
    task_svc = AsyncMock()
    task_svc.list_pending_for_agent.return_value = []
    task_svc.list_assigned_for_agent.return_value = [t]
    deps = _make_deps(task=task_svc)
    c = Choreographer(deps)

    env = await c.pm_give_me_work(pm_id)
    assert env.next is not None
    assert "subtasks" in env.next or "complete" in env.next


# ---------------------------------------------------------------------------
# i_am_idle auto-pause
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_i_am_idle_auto_pauses_in_progress_tasks() -> None:
    agent_id = uuid4()
    t = MagicMock(id=uuid4(), status="in_progress")
    task_svc = AsyncMock()
    task_svc.list_in_progress_for_agent.return_value = [t]
    deps = _make_deps(task=task_svc)
    c = Choreographer(deps)

    env = await c.i_am_idle(agent_id)
    assert env.status == "idle"
    task_svc.pause_for_agent.assert_awaited_once_with(agent_id, t.id)
    task_svc.mark_agent_idle.assert_awaited_once()


@pytest.mark.asyncio
async def test_i_am_idle_no_in_progress_skips_pause() -> None:
    agent_id = uuid4()
    task_svc = AsyncMock()
    task_svc.list_in_progress_for_agent.return_value = []
    deps = _make_deps(task=task_svc)
    c = Choreographer(deps)

    env = await c.i_am_idle(agent_id)
    assert env.status == "idle"
    task_svc.pause_for_agent.assert_not_awaited()


@pytest.mark.asyncio
async def test_i_am_idle_with_unread_skips_pause_and_idle() -> None:
    agent_id = uuid4()
    task_svc = AsyncMock()
    deps = _make_deps(task=task_svc)
    # Override the default empty list AFTER _make_deps has zeroed it out.
    deps.evidence_repo.list_unread_a2a.return_value = ["something"]
    c = Choreographer(deps)

    env = await c.i_am_idle(agent_id)
    assert env.status == "idle_with_unread"
    task_svc.list_in_progress_for_agent.assert_not_awaited()
    task_svc.mark_agent_idle.assert_not_awaited()


# ---------------------------------------------------------------------------
# Bug B from 2026-05-09 smoke: main-pm sent task_type="code" when delegating
# to be-pm. A Cell PM PLANS the slice; it does not execute code. The
# delegate verb must reject this misclassification at the gateway so future
# spawns can't recreate the wrong-type-task condition.
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_delegate_main_pm_to_cell_pm_rejects_code_typed_subtask() -> None:
    main_pm_id = uuid4()
    parent_id = uuid4()
    parent = MagicMock(
        id=parent_id,
        project_id=uuid4(),
        status="in_progress",
        assigned_to=main_pm_id,
    )
    task_svc = AsyncMock()
    task_svc.get.return_value = parent
    task_svc.agent_for.return_value = MagicMock(role="main_pm", team="main_pm")
    task_svc.get_subtasks.return_value = []
    deps = _make_deps(task=task_svc)
    c = Choreographer(deps)

    env = await c.delegate(
        main_pm_id,
        parent_id,
        DelegateInputs(
            title="Backend slice",
            description="Plan + drive backend work end to end please",
            assigned_to="be-pm",
            team="backend",
            task_type="code",  # WRONG — Cell PM should get planning
            nature="technical",
            acceptance_criteria=["all subtasks created with criteria"],
        ),
    )
    body = env.as_dict()
    assert body["error"] == "invalid_state"
    assert "planning" in body["message"].lower()
    assert "be-pm" in body["message"] or "cell_pm" in body["message"].lower()


@pytest.mark.asyncio
async def test_delegate_main_pm_to_cell_pm_accepts_planning_subtask() -> None:
    """The contract from the test above: planning IS the right type."""
    main_pm_id = uuid4()
    parent_id = uuid4()
    project_id = uuid4()
    parent = MagicMock(
        id=parent_id,
        project_id=project_id,
        status="in_progress",
        assigned_to=main_pm_id,
    )
    new_task = MagicMock(id=uuid4())
    task_svc = AsyncMock()
    task_svc.get.return_value = parent
    task_svc.agent_for.return_value = MagicMock(role="main_pm", team="main_pm")
    task_svc.get_subtasks.return_value = []
    task_svc.create_subtask.return_value = new_task
    deps = _make_deps(task=task_svc)
    c = Choreographer(deps)

    env = await c.delegate(
        main_pm_id,
        parent_id,
        DelegateInputs(
            title="Backend slice",
            description="Plan + drive backend work end to end please",
            assigned_to="be-pm",
            team="backend",
            task_type="planning",
            nature="technical",
            acceptance_criteria=["all subtasks created with criteria"],
        ),
    )
    assert env.error is None
