"""#170: the closure dispatcher auto-resumes a paused parent before respawn.

A PM auto-pauses its owned parent on i_am_idle (by design, so the
closure dispatcher knows to respawn it when subtasks finish). Pre-gateway
the parent was resumed at respawn so the PM landed actionable; the
gateway refactor dropped that, so the respawned PM had to issue
`resume()` itself — which minimax reliably failed, wedging smoke-15.
_maybe_spawn_pm_closure must resume a `paused` parent (and only a
paused one) immediately before spawning its PM.

#177: symmetric handling for a `blocked` parent. At closure all
descendants are terminal, so a still-`blocked` parent is an errant/
stale block — it must be recovered to in_progress too, else the chain
wedges forever waiting for a PM to manually unblock (this run wedged
exactly there). `paused` and `blocked` are mutually exclusive — each
triggers only its own recovery helper.
"""

from __future__ import annotations

from typing import cast
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
from roboco.runtime.orchestrator import AgentOrchestrator


def _orch() -> AgentOrchestrator:
    with patch.object(AgentOrchestrator, "__init__", return_value=None):
        return AgentOrchestrator.__new__(AgentOrchestrator)


def _ready_orch() -> AgentOrchestrator:
    """Orchestrator with every closure gate stubbed so _maybe_spawn_pm_closure
    reaches the spawn (descendants terminal, not recently paused, not
    already promoted, PM idle)."""
    orch = _orch()
    orch._is_recently_paused = MagicMock(return_value=False)  # type: ignore[method-assign]
    orch._fetch_all_descendants = AsyncMock(  # type: ignore[method-assign]
        return_value=[{"id": "leaf", "status": "completed"}]
    )
    orch._all_descendants_terminal = MagicMock(return_value=True)  # type: ignore[method-assign]
    orch._already_promoted_for_closure = MagicMock(return_value=False)  # type: ignore[method-assign]
    orch._closure_pm_for_team = MagicMock(return_value="be-pm")  # type: ignore[method-assign]
    orch._is_agent_active = MagicMock(return_value=False)  # type: ignore[method-assign]
    orch._build_pm_closure_prompt = MagicMock(return_value="PROMPT")  # type: ignore[method-assign]
    orch._task_git_context = MagicMock(return_value=None)  # type: ignore[method-assign]
    orch.spawn_agent = AsyncMock()  # type: ignore[method-assign]
    orch._auto_resume_paused_parent = AsyncMock()  # type: ignore[method-assign]
    orch._auto_recover_blocked_parent = AsyncMock()  # type: ignore[method-assign]
    return orch


@pytest.mark.asyncio
async def test_paused_parent_is_resumed_before_spawn() -> None:
    orch = _ready_orch()
    client = AsyncMock()
    task = {"id": "parent-1", "status": "paused", "team": "backend"}

    await orch._maybe_spawn_pm_closure(client, task)

    cast("AsyncMock", orch._auto_resume_paused_parent).assert_awaited_once_with(
        client, "parent-1"
    )
    cast("AsyncMock", orch._auto_recover_blocked_parent).assert_not_awaited()
    cast("AsyncMock", orch.spawn_agent).assert_awaited_once()


@pytest.mark.asyncio
async def test_blocked_parent_is_recovered_before_spawn() -> None:
    """#177: a blocked parent at closure is recovered (not the paused path)."""
    orch = _ready_orch()
    client = AsyncMock()
    task = {"id": "parent-2", "status": "blocked", "team": "backend"}

    await orch._maybe_spawn_pm_closure(client, task)

    cast("AsyncMock", orch._auto_recover_blocked_parent).assert_awaited_once_with(
        client, "parent-2"
    )
    cast("AsyncMock", orch._auto_resume_paused_parent).assert_not_awaited()
    cast("AsyncMock", orch.spawn_agent).assert_awaited_once()


@pytest.mark.asyncio
async def test_non_paused_parent_is_not_resumed() -> None:
    """awaiting_pm_review / in_progress parents must NOT be touched by
    either recovery path."""
    for st in ("awaiting_pm_review", "in_progress"):
        orch = _ready_orch()
        client = AsyncMock()
        task = {"id": "p", "status": st, "team": "backend"}

        await orch._maybe_spawn_pm_closure(client, task)

        cast("AsyncMock", orch._auto_resume_paused_parent).assert_not_awaited()
        cast("AsyncMock", orch._auto_recover_blocked_parent).assert_not_awaited()
        cast("AsyncMock", orch.spawn_agent).assert_awaited_once()


@pytest.mark.asyncio
async def test_resume_skipped_when_closure_gate_blocks_spawn() -> None:
    """If descendants aren't terminal there is no spawn — and no resume."""
    orch = _ready_orch()
    orch._all_descendants_terminal = MagicMock(return_value=False)  # type: ignore[method-assign]
    client = AsyncMock()

    await orch._maybe_spawn_pm_closure(
        client, {"id": "p", "status": "paused", "team": "backend"}
    )

    cast("AsyncMock", orch._auto_resume_paused_parent).assert_not_awaited()
    cast("AsyncMock", orch.spawn_agent).assert_not_awaited()


@pytest.mark.asyncio
async def test_auto_resume_patches_status_in_progress() -> None:
    orch = _orch()
    client = AsyncMock()

    await orch._auto_resume_paused_parent(client, "parent-9")

    client.patch.assert_awaited_once()
    call = client.patch.await_args
    assert call.args[0].endswith("/tasks/parent-9")
    assert call.kwargs["json"] == {"status": "in_progress"}


@pytest.mark.asyncio
async def test_auto_resume_swallows_errors() -> None:
    """A resume failure must not block the spawn (best-effort)."""
    orch = _orch()
    client = AsyncMock()
    client.patch = AsyncMock(side_effect=RuntimeError("api down"))

    # Must not raise.
    await orch._auto_resume_paused_parent(client, "p")


@pytest.mark.asyncio
async def test_auto_recover_blocked_patches_status_in_progress() -> None:
    """#177: blocked -> in_progress (same transition unblock(restore=True)
    performs)."""
    orch = _orch()
    client = AsyncMock()

    await orch._auto_recover_blocked_parent(client, "parent-7")

    client.patch.assert_awaited_once()
    call = client.patch.await_args
    assert call.args[0].endswith("/tasks/parent-7")
    assert call.kwargs["json"] == {"status": "in_progress"}


@pytest.mark.asyncio
async def test_auto_recover_blocked_swallows_errors() -> None:
    """A recovery failure must not block the spawn (best-effort)."""
    orch = _orch()
    client = AsyncMock()
    client.patch = AsyncMock(side_effect=RuntimeError("api down"))

    # Must not raise.
    await orch._auto_recover_blocked_parent(client, "p")
