"""Sandbox teardown/janitor wiring in the orchestrator's removal + reaper paths.

`_remove_container` is the single chokepoint every removal path routes
through (stop_agent, reaper kills, pre-spawn stale-clear), so sandbox
teardown lives there rather than duplicated at each call site. Gated on the
flag: when off, behavior must stay byte-for-byte identical to before this
feature (no extra docker calls).
"""

from __future__ import annotations

import asyncio
from typing import Any
from unittest.mock import AsyncMock, MagicMock

import pytest
from roboco.config import settings
from roboco.runtime.orchestrator import AgentOrchestrator


class _FakeProc:
    def __init__(self, returncode: int) -> None:
        self.returncode = returncode

    async def wait(self) -> int:
        return self.returncode

    async def communicate(self) -> tuple[bytes, bytes]:
        return b"", b""


async def _fake_create_subprocess_exec(*args: Any, **_kwargs: Any) -> _FakeProc:
    if args[1] == "inspect":
        return _FakeProc(1)  # container does not exist -> skip log dump
    if args[1] == "rm":
        return _FakeProc(0)
    raise AssertionError(f"unexpected docker args: {args}")


def _make_orchestrator() -> tuple[AgentOrchestrator, MagicMock]:
    orch = AgentOrchestrator.__new__(AgentOrchestrator)
    sandbox = MagicMock()
    sandbox.teardown = AsyncMock()
    sandbox.janitor_sweep = AsyncMock()
    orch._sandbox = sandbox
    return orch, sandbox


@pytest.mark.asyncio
async def test_remove_container_tears_down_sandbox_when_flag_on(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    monkeypatch.setattr(settings, "sandbox_db_enabled", True)
    monkeypatch.setattr(asyncio, "create_subprocess_exec", _fake_create_subprocess_exec)
    orch, sandbox = _make_orchestrator()

    await orch._remove_container("roboco-agent-dev-1")

    sandbox.teardown.assert_awaited_once_with("dev-1")


@pytest.mark.asyncio
async def test_remove_container_teardown_sandbox_false_skips_even_when_flag_on(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    monkeypatch.setattr(settings, "sandbox_db_enabled", True)
    monkeypatch.setattr(asyncio, "create_subprocess_exec", _fake_create_subprocess_exec)
    orch, sandbox = _make_orchestrator()

    await orch._remove_container("roboco-agent-dev-1", teardown_sandbox=False)

    sandbox.teardown.assert_not_called()


@pytest.mark.asyncio
async def test_remove_container_skips_sandbox_teardown_when_flag_off(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    monkeypatch.setattr(settings, "sandbox_db_enabled", False)
    monkeypatch.setattr(asyncio, "create_subprocess_exec", _fake_create_subprocess_exec)
    orch, sandbox = _make_orchestrator()

    await orch._remove_container("roboco-agent-dev-1")

    sandbox.teardown.assert_not_called()


@pytest.mark.asyncio
async def test_sandbox_janitor_sweep_noop_when_flag_off(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    monkeypatch.setattr(settings, "sandbox_db_enabled", False)
    orch, sandbox = _make_orchestrator()

    await orch._sandbox_janitor_sweep()

    sandbox.janitor_sweep.assert_not_called()


@pytest.mark.asyncio
async def test_sandbox_janitor_sweep_runs_when_flag_on(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    monkeypatch.setattr(settings, "sandbox_db_enabled", True)
    orch, sandbox = _make_orchestrator()

    await orch._sandbox_janitor_sweep()

    sandbox.janitor_sweep.assert_awaited_once()


@pytest.mark.asyncio
async def test_sandbox_janitor_sweep_swallows_errors(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    monkeypatch.setattr(settings, "sandbox_db_enabled", True)
    orch, sandbox = _make_orchestrator()
    sandbox.janitor_sweep.side_effect = RuntimeError("boom")

    await orch._sandbox_janitor_sweep()  # must not raise


# ---------------------------------------------------------------------------
# ensure_sandbox cache eviction (request_sandbox on-demand provisioning)
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_remove_container_evicts_ensure_sandbox_cache(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    monkeypatch.setattr(settings, "sandbox_db_enabled", True)
    monkeypatch.setattr(asyncio, "create_subprocess_exec", _fake_create_subprocess_exec)
    orch, _sandbox = _make_orchestrator()
    orch._sandbox_info = {"dev-1": MagicMock(), "dev-2": MagicMock()}

    await orch._remove_container("roboco-agent-dev-1")

    assert "dev-1" not in orch._sandbox_info
    assert "dev-2" in orch._sandbox_info


@pytest.mark.asyncio
async def test_remove_container_teardown_false_spares_cache(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    monkeypatch.setattr(settings, "sandbox_db_enabled", True)
    monkeypatch.setattr(asyncio, "create_subprocess_exec", _fake_create_subprocess_exec)
    orch, _sandbox = _make_orchestrator()
    orch._sandbox_info = {"dev-1": MagicMock()}

    await orch._remove_container("roboco-agent-dev-1", teardown_sandbox=False)

    assert "dev-1" in orch._sandbox_info


@pytest.mark.asyncio
async def test_janitor_sweep_evicts_cache_for_reaped_agents(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    monkeypatch.setattr(settings, "sandbox_db_enabled", True)
    orch, _sandbox = _make_orchestrator()
    orch._sandbox_info = {"dev-1": MagicMock(), "dev-2": MagicMock()}
    orch._instances = {"dev-2": MagicMock()}  # dev-1's agent instance is gone

    await orch._sandbox_janitor_sweep()

    assert "dev-1" not in orch._sandbox_info
    assert "dev-2" in orch._sandbox_info


# ---------------------------------------------------------------------------
# release_sandbox (end-of-engagement teardown, called by the Choreographer's
# post-verb hook — i_am_done / unclaim / i_am_idle / pass_review / fail_review
# / i_documented — instead of only at container removal)
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_release_sandbox_no_cache_entry_is_fast_noop() -> None:
    """The overwhelmingly common case: no sandbox for this agent. Must not
    take the per-agent lock or call docker — the cache dict check alone
    decides, before any lock is even allocated."""
    orch, sandbox = _make_orchestrator()
    orch._sandbox_info = {}

    await orch.release_sandbox("dev-1")

    sandbox.teardown.assert_not_called()
    assert not hasattr(orch, "_sandbox_locks")


@pytest.mark.asyncio
async def test_release_sandbox_tears_down_and_evicts_cache() -> None:
    orch, sandbox = _make_orchestrator()
    orch._sandbox_info = {"dev-1": MagicMock(), "dev-2": MagicMock()}

    await orch.release_sandbox("dev-1")

    sandbox.teardown.assert_awaited_once_with("dev-1")
    assert "dev-1" not in orch._sandbox_info
    assert "dev-2" in orch._sandbox_info


@pytest.mark.asyncio
async def test_release_sandbox_is_idempotent() -> None:
    """A second release for the same slug (e.g. unclaim right after
    i_am_idle) is a no-op — the first call already evicted the cache."""
    orch, sandbox = _make_orchestrator()
    orch._sandbox_info = {"dev-1": MagicMock()}

    await orch.release_sandbox("dev-1")
    await orch.release_sandbox("dev-1")

    sandbox.teardown.assert_awaited_once_with("dev-1")
