"""Tests for PM Choreographer methods.

Covers: triage, triage_all, unblock, complete, escalate_up.
"""

from __future__ import annotations

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

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


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)
    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)


@pytest.mark.asyncio
async def test_cell_pm_triage_returns_blocked_first() -> None:
    pm_id = uuid4()
    blocked_task = MagicMock(id=uuid4(), status="blocked", title="b", team="backend")
    pending_task = MagicMock(
        id=uuid4(), status="awaiting_pm_review", title="p", team="backend"
    )
    task_svc = AsyncMock()
    task_svc.agent_for.return_value = MagicMock(role="cell_pm", team="backend")
    task_svc.list_blocked_for_team.return_value = [blocked_task]
    task_svc.list_awaiting_pm_review_for_team.return_value = [pending_task]
    deps = _make_deps(task=task_svc)
    c = Choreographer(deps)

    env = await c.triage(pm_id)
    body = env.as_dict()
    assert body["task_id"] == str(blocked_task.id)
    assert "unblock" in body["next"].lower()


@pytest.mark.asyncio
async def test_cell_pm_triage_returns_awaiting_review_when_no_blocked() -> None:
    pm_id = uuid4()
    pending_task = MagicMock(id=uuid4(), status="awaiting_pm_review", team="backend")
    task_svc = AsyncMock()
    task_svc.agent_for.return_value = MagicMock(role="cell_pm", team="backend")
    task_svc.list_blocked_for_team.return_value = []
    task_svc.list_awaiting_pm_review_for_team.return_value = [pending_task]
    deps = _make_deps(task=task_svc)
    c = Choreographer(deps)

    env = await c.triage(pm_id)
    body = env.as_dict()
    assert body["task_id"] == str(pending_task.id)
    assert "complete" in body["next"]


@pytest.mark.asyncio
async def test_cell_pm_triage_returns_idle_when_no_work() -> None:
    pm_id = uuid4()
    task_svc = AsyncMock()
    task_svc.agent_for.return_value = MagicMock(role="cell_pm", team="backend")
    task_svc.list_blocked_for_team.return_value = []
    task_svc.list_awaiting_pm_review_for_team.return_value = []
    deps = _make_deps(task=task_svc)
    c = Choreographer(deps)

    env = await c.triage(pm_id)
    body = env.as_dict()
    assert body["status"] == "idle"


@pytest.mark.asyncio
async def test_main_pm_triage_all_includes_cross_team() -> None:
    pm_id = uuid4()
    blocked = MagicMock(id=uuid4(), status="blocked", team="backend", title="x")
    task_svc = AsyncMock()
    task_svc.list_blocked_all_teams.return_value = [blocked]
    task_svc.list_awaiting_main_pm_all.return_value = []
    deps = _make_deps(task=task_svc)
    c = Choreographer(deps)

    env = await c.triage_all(pm_id)
    body = env.as_dict()
    assert body["error"] is None
    assert body["task_id"] == str(blocked.id)


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

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


@pytest.mark.asyncio
async def test_unblock_restores_pre_block_state() -> None:
    pm_id = uuid4()
    task_id = uuid4()
    t = MagicMock(
        id=task_id,
        status="blocked",
        pre_block_state="awaiting_documentation",
        pre_block_assignee=uuid4(),
        pre_block_metadata={"some_field": "x"},
    )
    after = MagicMock(
        **{
            **t.__dict__,
            "status": "awaiting_documentation",
            "assigned_to": t.pre_block_assignee,
        },
    )
    task_svc = AsyncMock()
    task_svc.get.return_value = t
    task_svc.unblock_with_restore.return_value = after
    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.unblock(pm_id, task_id, restore=True)
    assert env.error is None
    assert env.status == "awaiting_documentation"
    task_svc.unblock_with_restore.assert_awaited_once_with(pm_id, task_id, restore=True)


@pytest.mark.asyncio
async def test_unblock_default_restores() -> None:
    pm_id = uuid4()
    task_id = uuid4()
    t = MagicMock(
        id=task_id,
        status="blocked",
        pre_block_state="awaiting_qa",
        pre_block_assignee=uuid4(),
        pre_block_metadata={},
    )
    after = MagicMock(**{**t.__dict__, "status": "awaiting_qa"})
    task_svc = AsyncMock()
    task_svc.get.return_value = t
    task_svc.unblock_with_restore.return_value = after
    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)

    # restore omitted -> defaults to True
    env = await c.unblock(pm_id, task_id)
    assert env.status == "awaiting_qa"


@pytest.mark.asyncio
async def test_unblock_blocks_without_journal_decision() -> None:
    pm_id = uuid4()
    task_id = uuid4()
    t = MagicMock(id=task_id, status="blocked")
    task_svc = AsyncMock()
    task_svc.get.return_value = t
    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.unblock(pm_id, task_id)
    body = env.as_dict()
    assert body["error"] == "tracing_gap"
    assert "journal:decision" in body["missing"]


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

    env = await c.unblock(pm_id, task_id)
    body = env.as_dict()
    assert body["error"] == "invalid_state"


@pytest.mark.asyncio
async def test_unblock_restore_false_returns_legacy_message() -> None:
    pm_id = uuid4()
    task_id = uuid4()
    t = MagicMock(
        id=task_id,
        status="blocked",
        pre_block_state="awaiting_qa",
        pre_block_assignee=uuid4(),
        pre_block_metadata={},
    )
    after = MagicMock(**{**t.__dict__, "status": "in_progress"})
    task_svc = AsyncMock()
    task_svc.get.return_value = t
    task_svc.unblock_with_restore.return_value = after
    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.unblock(pm_id, task_id, restore=False)
    body = env.as_dict()
    assert body["status"] == "in_progress"
    assert "re-engage" in body["next"].lower()


@pytest.mark.asyncio
async def test_unblock_refused_while_a_dependency_is_unfinished() -> None:
    """A dependency block can't be force-cleared by a PM.

    It auto-clears via _unblock_dependents once the upstream completes; manual
    unblock would let the dependent proceed without the upstream's work.
    """
    pm_id = uuid4()
    task_id = uuid4()
    dep_id = uuid4()
    t = MagicMock(id=task_id, status="blocked", dependency_ids=[dep_id])
    task_svc = AsyncMock()
    task_svc.get.return_value = t
    task_svc.unmet_dependency_ids.return_value = [dep_id]
    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.unblock(pm_id, task_id)
    body = env.as_dict()

    assert body["error"] == "invalid_state"
    assert "depends on" in body["message"]
    # The task must not have been advanced out of blocked.
    task_svc.unblock_with_restore.assert_not_awaited()


@pytest.mark.asyncio
async def test_cell_pm_complete_merges_then_completes() -> None:
    pm_id = uuid4()
    task_id = uuid4()
    parent_id = uuid4()
    t = MagicMock(
        id=task_id,
        status="awaiting_pm_review",
        assigned_to=pm_id,
        pr_number=8,
        branch_name="feature/backend/abc--def",
        parent_task_id=parent_id,
        team="backend",
    )
    after = MagicMock(**{**t.__dict__, "status": "completed"})
    # #181/#182: the merge target is the PARENT task's real branch_name —
    # here under a DIFFERENT team prefix, which the old parent_branch_for
    # would have mis-derived as feature/backend/abc.
    parent = MagicMock(
        id=parent_id, branch_name="feature/main_pm/abc", parent_task_id=None
    )
    task_svc = AsyncMock()
    task_svc.get.side_effect = lambda tid: parent if tid == parent_id else t
    task_svc.all_subtasks_terminal.return_value = True
    task_svc.cell_pm_complete.return_value = after
    git_svc = AsyncMock()
    git_svc.pr_merge.return_value = {"merged": True, "merge_commit_sha": "merge-abc"}
    journal_svc = AsyncMock()
    journal_svc.has_decision_for_task.return_value = True
    journal_svc.latest_decision_at.return_value = datetime.now(UTC)
    journal_svc.has_reflect_for_task.return_value = True
    deps = _make_deps(task=task_svc, git=git_svc, journal=journal_svc)
    c = Choreographer(deps)

    env = await c.cell_pm_complete(pm_id, task_id, notes="reviewed and approved")
    assert env.error is None
    assert env.status == "completed"
    git_svc.pr_merge.assert_awaited_once_with(
        8, target="feature/main_pm/abc", actor_agent_id=pm_id
    )


@pytest.mark.asyncio
async def test_cell_pm_complete_blocks_if_subtasks_unfinished() -> None:
    pm_id = uuid4()
    task_id = uuid4()
    t = MagicMock(
        id=task_id,
        status="awaiting_pm_review",
        assigned_to=pm_id,
        parent_task_id=uuid4(),
    )
    task_svc = AsyncMock()
    task_svc.get.return_value = t
    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)
    journal_svc.has_reflect_for_task.return_value = True
    deps = _make_deps(task=task_svc, journal=journal_svc)
    c = Choreographer(deps)

    env = await c.cell_pm_complete(
        pm_id, task_id, notes="reviewed cell scope and approved merge"
    )
    body = env.as_dict()
    assert body["error"] == "tracing_gap"
    assert "subtasks" in str(body["missing"]).lower()


@pytest.mark.asyncio
async def test_cell_pm_complete_requires_journal_decision() -> None:
    pm_id = uuid4()
    task_id = uuid4()
    t = MagicMock(
        id=task_id,
        status="awaiting_pm_review",
        assigned_to=pm_id,
        parent_task_id=uuid4(),
    )
    task_svc = AsyncMock()
    task_svc.get.return_value = t
    task_svc.all_subtasks_terminal.return_value = True
    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.cell_pm_complete(pm_id, task_id, notes="x")
    body = env.as_dict()
    assert body["error"] == "tracing_gap"
    assert "journal:decision" in body["missing"]


@pytest.mark.asyncio
async def test_cell_pm_complete_no_pr_returns_invalid_state() -> None:
    pm_id = uuid4()
    task_id = uuid4()
    t = MagicMock(
        id=task_id,
        status="awaiting_pm_review",
        assigned_to=pm_id,
        pr_number=None,
        branch_name="feature/backend/abc--def",
        parent_task_id=uuid4(),
    )
    task_svc = AsyncMock()
    task_svc.get.return_value = t
    task_svc.all_subtasks_terminal.return_value = True
    journal_svc = AsyncMock()
    journal_svc.has_decision_for_task.return_value = True
    journal_svc.latest_decision_at.return_value = datetime.now(UTC)
    journal_svc.has_reflect_for_task.return_value = True
    deps = _make_deps(task=task_svc, journal=journal_svc)
    c = Choreographer(deps)

    env = await c.cell_pm_complete(
        pm_id, task_id, notes="reviewed cell scope and approved merge"
    )
    assert env.as_dict()["error"] == "invalid_state"


@pytest.mark.asyncio
async def test_cell_pm_complete_not_assigned_returns_not_authorized() -> None:
    pm_id = uuid4()
    other = uuid4()
    task_id = uuid4()
    t = MagicMock(id=task_id, status="awaiting_pm_review", assigned_to=other)
    task_svc = AsyncMock()
    task_svc.get.return_value = t
    deps = _make_deps(task=task_svc)
    c = Choreographer(deps)

    env = await c.cell_pm_complete(pm_id, task_id, notes="x")
    assert env.as_dict()["error"] == "not_authorized"


@pytest.mark.asyncio
async def test_main_pm_complete_opens_master_pr_and_escalates() -> None:
    main_pm_id = uuid4()
    root_task_id = uuid4()
    t = MagicMock(
        id=root_task_id,
        status="awaiting_pm_review",
        assigned_to=main_pm_id,
        pr_number=None,
        branch_name="feature/backend/root123",
        parent_task_id=None,
        team="backend",
    )
    after = MagicMock(**{**t.__dict__, "status": "awaiting_ceo_approval"})
    task_svc = AsyncMock()
    task_svc.get.return_value = t
    task_svc.escalate_to_ceo.return_value = after
    task_svc.all_subtasks_terminal.return_value = True
    git_svc = AsyncMock()
    git_svc.create_pr.return_value = {"pr_number": 99, "pr_url": "https://x/y/pull/99"}
    journal_svc = AsyncMock()
    journal_svc.has_decision_for_task.return_value = True
    journal_svc.latest_decision_at.return_value = datetime.now(UTC)
    journal_svc.has_reflect_for_task.return_value = True
    deps = _make_deps(task=task_svc, git=git_svc, journal=journal_svc)
    c = Choreographer(deps)

    env = await c.main_pm_complete(
        main_pm_id, root_task_id, notes="root scope reviewed and ready for production"
    )
    assert env.error is None
    assert env.status == "awaiting_ceo_approval"
    git_svc.create_pr.assert_awaited_once_with(
        "feature/backend/root123",
        parent="master",
        is_root_pr=True,
    )
    task_svc.escalate_to_ceo.assert_awaited_once()


@pytest.mark.asyncio
async def test_main_pm_complete_advances_in_progress_root_to_ceo() -> None:
    """#183: a root resumed to in_progress (subtasks all done) has no submit_up
    to reach awaiting_pm_review. main_pm_complete opens the root→master PR,
    walks the root through awaiting_pm_review, then escalates to CEO."""
    main_pm_id = uuid4()
    root_task_id = uuid4()
    in_prog = MagicMock(
        id=root_task_id,
        status="in_progress",
        assigned_to=main_pm_id,
        pr_number=None,
        branch_name="feature/main_pm/root123",
        parent_task_id=None,
        team="main_pm",
    )
    awaiting = MagicMock(**{**in_prog.__dict__, "status": "awaiting_pm_review"})
    after = MagicMock(**{**in_prog.__dict__, "status": "awaiting_ceo_approval"})
    task_svc = AsyncMock()
    task_svc.get.return_value = in_prog
    task_svc.submit_pm_review.return_value = awaiting
    task_svc.escalate_to_ceo.return_value = after
    task_svc.all_subtasks_terminal.return_value = True
    git_svc = AsyncMock()
    git_svc.create_pr.return_value = {"pr_number": 99, "pr_url": "https://x/y/pull/99"}
    journal_svc = AsyncMock()
    journal_svc.has_decision_for_task.return_value = True
    journal_svc.latest_decision_at.return_value = datetime.now(UTC)
    journal_svc.has_reflect_for_task.return_value = True
    deps = _make_deps(task=task_svc, git=git_svc, journal=journal_svc)
    c = Choreographer(deps)

    env = await c.main_pm_complete(
        main_pm_id, root_task_id, notes="root scope reviewed; ready for CEO sign-off"
    )
    assert env.error is None
    assert env.status == "awaiting_ceo_approval"
    git_svc.create_pr.assert_awaited_once_with(
        "feature/main_pm/root123",
        parent="master",
        is_root_pr=True,
    )
    # #183: the in_progress→awaiting_pm_review hop must run before escalation.
    task_svc.submit_pm_review.assert_awaited_once()
    task_svc.escalate_to_ceo.assert_awaited_once()


@pytest.mark.asyncio
async def test_main_pm_complete_skips_pr_creation_if_already_master_targeted() -> None:
    main_pm_id = uuid4()
    root_task_id = uuid4()
    t = MagicMock(
        id=root_task_id,
        status="awaiting_pm_review",
        assigned_to=main_pm_id,
        pr_number=42,
        branch_name="feature/backend/root123",
        parent_task_id=None,
        team="backend",
    )
    after = MagicMock(**{**t.__dict__, "status": "awaiting_ceo_approval"})
    task_svc = AsyncMock()
    task_svc.get.return_value = t
    task_svc.escalate_to_ceo.return_value = after
    task_svc.all_subtasks_terminal.return_value = True
    git_svc = AsyncMock()
    git_svc.pr_target.return_value = "master"
    journal_svc = AsyncMock()
    journal_svc.has_decision_for_task.return_value = True
    journal_svc.latest_decision_at.return_value = datetime.now(UTC)
    journal_svc.has_reflect_for_task.return_value = True
    deps = _make_deps(task=task_svc, git=git_svc, journal=journal_svc)
    c = Choreographer(deps)

    await c.main_pm_complete(
        main_pm_id, root_task_id, notes="root scope reviewed and ready"
    )
    git_svc.create_pr.assert_not_awaited()
    task_svc.escalate_to_ceo.assert_awaited_once()


@pytest.mark.asyncio
async def test_main_pm_complete_rejects_non_root_task() -> None:
    main_pm_id = uuid4()
    task_id = uuid4()
    t = MagicMock(
        id=task_id,
        status="awaiting_pm_review",
        assigned_to=main_pm_id,
        parent_task_id=uuid4(),  # has parent -> not a root task
    )
    task_svc = AsyncMock()
    task_svc.get.return_value = t
    deps = _make_deps(task=task_svc)
    c = Choreographer(deps)

    env = await c.main_pm_complete(main_pm_id, task_id, notes="x")
    body = env.as_dict()
    assert body["error"] == "invalid_state"
    assert "root tasks" in body["message"]


@pytest.mark.asyncio
async def test_main_pm_complete_blocks_unfinished_subtasks() -> None:
    main_pm_id = uuid4()
    root_task_id = uuid4()
    t = MagicMock(
        id=root_task_id,
        status="awaiting_pm_review",
        assigned_to=main_pm_id,
        parent_task_id=None,
        team="backend",
    )
    task_svc = AsyncMock()
    task_svc.get.return_value = t
    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.main_pm_complete(main_pm_id, root_task_id, notes="x")
    body = env.as_dict()
    assert body["error"] == "tracing_gap"


@pytest.mark.asyncio
async def test_complete_dispatches_cell_pm() -> None:
    pm_id = uuid4()
    task_id = uuid4()
    t = MagicMock(
        id=task_id,
        status="awaiting_pm_review",
        assigned_to=pm_id,
        parent_task_id=uuid4(),
        pr_number=8,
        branch_name="feature/backend/abc--def",
        team="backend",
    )
    after = MagicMock(**{**t.__dict__, "status": "completed"})
    task_svc = AsyncMock()
    task_svc.get.return_value = t
    task_svc.agent_for.return_value = MagicMock(role="cell_pm")
    task_svc.all_subtasks_terminal.return_value = True
    task_svc.cell_pm_complete.return_value = after
    git_svc = AsyncMock()
    git_svc.pr_merge.return_value = {"merged": True, "merge_commit_sha": "x"}
    journal_svc = AsyncMock()
    journal_svc.has_decision_for_task.return_value = True
    journal_svc.latest_decision_at.return_value = datetime.now(UTC)
    journal_svc.has_reflect_for_task.return_value = True
    deps = _make_deps(task=task_svc, git=git_svc, journal=journal_svc)
    c = Choreographer(deps)

    env = await c.complete(
        pm_id, task_id, notes="cell scope reviewed and approved for merge"
    )
    assert env.status == "completed"
    task_svc.cell_pm_complete.assert_awaited_once()


@pytest.mark.asyncio
async def test_complete_dispatches_main_pm() -> None:
    main_pm_id = uuid4()
    root_task_id = uuid4()
    t = MagicMock(
        id=root_task_id,
        status="awaiting_pm_review",
        assigned_to=main_pm_id,
        pr_number=None,
        branch_name="feature/backend/root123",
        parent_task_id=None,
        team="backend",
    )
    after = MagicMock(**{**t.__dict__, "status": "awaiting_ceo_approval"})
    task_svc = AsyncMock()
    task_svc.get.return_value = t
    task_svc.agent_for.return_value = MagicMock(role="main_pm")
    task_svc.all_subtasks_terminal.return_value = True
    task_svc.escalate_to_ceo.return_value = after
    git_svc = AsyncMock()
    git_svc.create_pr.return_value = {"pr_number": 99, "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)
    journal_svc.has_reflect_for_task.return_value = True
    deps = _make_deps(task=task_svc, git=git_svc, journal=journal_svc)
    c = Choreographer(deps)

    env = await c.complete(
        main_pm_id, root_task_id, notes="root scope reviewed and ready"
    )
    assert env.status == "awaiting_ceo_approval"
    task_svc.escalate_to_ceo.assert_awaited_once()


@pytest.mark.asyncio
async def test_complete_rejects_non_pm_role() -> None:
    dev_id = uuid4()
    task_id = uuid4()
    task_svc = AsyncMock()
    task_svc.agent_for.return_value = MagicMock(role="developer")
    deps = _make_deps(task=task_svc)
    c = Choreographer(deps)

    env = await c.complete(dev_id, task_id, notes="x")
    body = env.as_dict()
    assert body["error"] == "not_authorized"
    assert "cell_pm" in body["remediate"] and "main_pm" in body["remediate"]


@pytest.mark.asyncio
async def test_escalate_up_routes_by_escalation_target() -> None:
    pm_id = uuid4()
    task_id = uuid4()
    t = MagicMock(id=task_id, status="blocked", assigned_to=pm_id, team="backend")
    after = MagicMock(**{**t.__dict__, "assigned_to": uuid4()})
    task_svc = AsyncMock()
    task_svc.get.return_value = t
    task_svc.agent_for.return_value = MagicMock(
        role="cell_pm",
        escalation_target="main-pm",
    )
    task_svc.escalate.return_value = after
    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.escalate_up(pm_id, task_id, reason="cross-cell coordination needed")
    assert env.error is None
    task_svc.escalate.assert_awaited_once_with(
        pm_id,
        task_id,
        "cross-cell coordination needed",
    )


@pytest.mark.asyncio
async def test_escalate_up_returns_invalid_state_when_target_lookup_fails() -> None:
    """Regression: escalate_up_to_role returning None used to crash on t.status."""
    pm_id = uuid4()
    task_id = uuid4()
    t = MagicMock(id=task_id, status="blocked", assigned_to=pm_id, team="backend")
    task_svc = AsyncMock()
    task_svc.get.return_value = t
    task_svc.agent_for.return_value = MagicMock(
        role="cell_pm",
        escalation_target="main-pm",
    )
    task_svc.escalate.return_value = None  # target slug not found in DB
    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.escalate_up(pm_id, task_id, reason="x")
    body = env.as_dict()
    assert body["error"] == "invalid_state"
    assert "main-pm" in body["message"]


@pytest.mark.asyncio
async def test_escalate_up_blocks_without_journal_decision() -> None:
    pm_id = uuid4()
    task_id = uuid4()
    t = MagicMock(id=task_id, status="blocked")
    task_svc = AsyncMock()
    task_svc.get.return_value = t
    # Spec gate runs before the journal:decision preflight; provide a
    # valid PM role so the gate passes and the preflight is the
    # load-bearing rejector.
    task_svc.agent_for.return_value = MagicMock(
        role="cell_pm", escalation_target="main-pm"
    )
    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.escalate_up(pm_id, task_id, reason="x")
    body = env.as_dict()
    assert body["error"] == "tracing_gap"
    assert "journal:decision" in body["missing"]


@pytest.mark.asyncio
async def test_escalate_up_no_target_returns_invalid_state() -> None:
    """Verb-specific preflight: PM whose escalation_target is unconfigured.

    The spec allows cell_pm/main_pm to call escalate_up regardless of
    target slug presence (target metadata lives on the agent record, not
    the lifecycle). The verb body's preflight is what surfaces the
    invalid_state when no target is configured.
    """
    pm_id = uuid4()
    task_id = uuid4()
    t = MagicMock(id=task_id, status="blocked")
    task_svc = AsyncMock()
    task_svc.get.return_value = t
    task_svc.agent_for.return_value = MagicMock(
        role="cell_pm",
        escalation_target=None,
    )
    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.escalate_up(pm_id, task_id, reason="x")
    body = env.as_dict()
    assert body["error"] == "invalid_state"


@pytest.mark.asyncio
async def test_escalate_up_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.escalate_up(pm_id, task_id, reason="x")
    assert env.as_dict()["error"] == "not_found"
