"""Tests for ShipyardNeoBooter — readiness gate, shutdown cleanup, and rebuild recovery."""

from __future__ import annotations

import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch

import pytest

# ═══════════════════════════════════════════════════════════════
# _wait_until_ready
# ═══════════════════════════════════════════════════════════════


def _make_sandbox_mock(statuses: list[str], *, delete_side_effect=None):
    """Build a sandbox mock that returns *statuses* in order on refresh().

    After the list is exhausted subsequent refresh() calls return the last status.
    """
    call_count = 0

    async def _refresh():
        nonlocal call_count
        idx = min(call_count, len(statuses) - 1)
        call_count += 1
        s = statuses[idx]
        sandbox.status = SimpleNamespace(value=s)

    sandbox = SimpleNamespace(
        id="sandbox-test-1",
        profile="python-default",
        status=SimpleNamespace(value=statuses[0]),
        refresh=_refresh,
        delete=AsyncMock(side_effect=delete_side_effect),
    )
    return sandbox


class TestWaitUntilReady:
    def _make_booter(self):
        from astrbot.core.computer.booters.shipyard_neo import ShipyardNeoBooter

        return ShipyardNeoBooter(
            endpoint_url="http://localhost:8114",
            access_token="sk-bay-test",
        )

    @pytest.mark.asyncio
    async def test_already_ready_returns_immediately(self):
        """Sandbox is READY on first poll → instant return (warm hit)."""
        booter = self._make_booter()
        sandbox = _make_sandbox_mock(["ready"])

        await booter._wait_until_ready(sandbox)

        sandbox.delete.assert_not_called()

    @pytest.mark.asyncio
    async def test_starting_then_ready(self):
        """Sandbox transitions STARTING → READY within timeout."""
        booter = self._make_booter()
        sandbox = _make_sandbox_mock(["starting", "starting", "ready"])

        await booter._wait_until_ready(sandbox)

        sandbox.delete.assert_not_called()

    @pytest.mark.asyncio
    async def test_failed_deletes_and_raises(self):
        """Sandbox reaches FAILED → delete called → RuntimeError raised."""
        booter = self._make_booter()
        sandbox = _make_sandbox_mock(["starting", "failed"])

        with pytest.raises(RuntimeError, match="terminal state"):
            await booter._wait_until_ready(sandbox)

        sandbox.delete.assert_awaited_once()

    @pytest.mark.asyncio
    async def test_expired_deletes_and_raises(self):
        """Sandbox reaches EXPIRED → delete called → RuntimeError raised."""
        booter = self._make_booter()
        sandbox = _make_sandbox_mock(["starting", "expired"])

        with pytest.raises(RuntimeError, match="terminal state"):
            await booter._wait_until_ready(sandbox)

        sandbox.delete.assert_awaited_once()

    @pytest.mark.asyncio
    async def test_timeout_deletes_and_raises(self):
        """Sandbox never reaches READY → delete called → TimeoutError raised."""
        booter = self._make_booter()
        # Return 'idle' every time to simulate a stuck sandbox
        sandbox = _make_sandbox_mock(["idle"])

        # Override the deadline so we don't actually sleep 180s
        original_time = asyncio.get_running_loop().time

        call_idx = 0

        def _fake_time():
            nonlocal call_idx
            # After one tick, jump past the deadline
            if call_idx == 0:
                call_idx += 1
                return original_time()
            # Return a value beyond the 180s timeout
            return original_time() + 200

        with patch(
            "astrbot.core.computer.booters.shipyard_neo.asyncio.get_running_loop"
        ) as mock_loop:
            mock_loop.return_value.time = _fake_time

            with pytest.raises(TimeoutError, match="did not become ready"):
                await booter._wait_until_ready(sandbox)

        sandbox.delete.assert_awaited_once()

    @pytest.mark.asyncio
    async def test_delete_failure_during_cleanup_is_safe(self):
        """If sandbox.delete() itself throws, the original error is still raised."""
        booter = self._make_booter()
        sandbox = _make_sandbox_mock(
            ["failed"],
            delete_side_effect=RuntimeError("Bay unreachable"),
        )

        with pytest.raises(RuntimeError, match="terminal state"):
            await booter._wait_until_ready(sandbox)

        sandbox.delete.assert_awaited_once()


# ═══════════════════════════════════════════════════════════════
# shutdown
# ═══════════════════════════════════════════════════════════════


class TestShutdown:
    def _make_booter(self):
        from astrbot.core.computer.booters.shipyard_neo import ShipyardNeoBooter

        return ShipyardNeoBooter(
            endpoint_url="http://localhost:8114",
            access_token="sk-bay-test",
        )

    @pytest.mark.asyncio
    async def test_delete_sandbox_true_calls_delete(self):
        """delete_sandbox=True → sandbox.delete() called, then client closed."""
        booter = self._make_booter()
        sandbox = SimpleNamespace(
            id="sandbox-test-1",
            delete=AsyncMock(),
        )
        client = SimpleNamespace(
            __aexit__=AsyncMock(),
        )
        booter._sandbox = sandbox  # type: ignore[assignment]
        booter._client = client  # type: ignore[assignment]

        await booter.shutdown(delete_sandbox=True)

        sandbox.delete.assert_awaited_once()
        client.__aexit__.assert_awaited_once()
        assert booter._client is None
        assert booter._sandbox is None

    @pytest.mark.asyncio
    async def test_delete_sandbox_false_does_not_call_delete(self):
        """delete_sandbox=False (default) → sandbox.delete() NOT called."""
        booter = self._make_booter()
        sandbox = SimpleNamespace(
            id="sandbox-test-1",
            delete=AsyncMock(),
        )
        client = SimpleNamespace(
            __aexit__=AsyncMock(),
        )
        booter._sandbox = sandbox  # type: ignore[assignment]
        booter._client = client  # type: ignore[assignment]

        await booter.shutdown()  # default delete_sandbox=False

        sandbox.delete.assert_not_called()
        client.__aexit__.assert_awaited_once()
        assert booter._client is None
        assert booter._sandbox is None

    @pytest.mark.asyncio
    async def test_delete_failure_still_closes_client(self):
        """If sandbox.delete() throws, HTTP client is still torn down."""
        booter = self._make_booter()
        sandbox = SimpleNamespace(
            id="sandbox-test-1",
            delete=AsyncMock(side_effect=RuntimeError("Bay gone")),
        )
        client = SimpleNamespace(
            __aexit__=AsyncMock(),
        )
        booter._sandbox = sandbox  # type: ignore[assignment]
        booter._client = client  # type: ignore[assignment]

        # Should not raise — delete failure is logged but swallowed
        await booter.shutdown(delete_sandbox=True)

        sandbox.delete.assert_awaited_once()
        client.__aexit__.assert_awaited_once()
        assert booter._client is None
        assert booter._sandbox is None

    @pytest.mark.asyncio
    async def test_no_client_is_noop(self):
        """shutdown() on an uninitialised booter is a no-op."""
        booter = self._make_booter()
        # _client is None by default
        await booter.shutdown(delete_sandbox=True)
        # No exception → ok


# ═══════════════════════════════════════════════════════════════
# get_booter rebuild path
# ═══════════════════════════════════════════════════════════════


class TestGetBooterRebuild:
    """Verify that stale ShipyardNeoBooter instances are cleaned up on rebuild."""

    def _make_fake_context(self, booter_type: str = "shipyard_neo"):
        """Build a context-like object for get_booter()."""
        _cfg = {
            "provider_settings": {
                "computer_use_runtime": "sandbox",
                "sandbox": {
                    "booter": booter_type,
                    "shipyard_neo_endpoint": "http://bay:8114",
                    "shipyard_neo_access_token": "sk-test",
                    "shipyard_neo_ttl": 3600,
                    "shipyard_neo_profile": "python-default",
                },
            }
        }
        return SimpleNamespace(
            get_config=lambda umo=None: _cfg,
        )

    @pytest.mark.asyncio
    async def test_stale_neo_booter_calls_shutdown_with_delete(self, monkeypatch):
        """A stale ShipyardNeoBooter gets shutdown(delete_sandbox=True) on eviction."""
        from astrbot.core.computer import computer_client
        from astrbot.core.computer.booters.shipyard_neo import ShipyardNeoBooter

        ctx = self._make_fake_context()

        stale = ShipyardNeoBooter(
            endpoint_url="http://bay:8114", access_token="sk-test"
        )
        stale._sandbox = SimpleNamespace(id="stale-sandbox")  # type: ignore[assignment]
        stale._client = SimpleNamespace(__aexit__=AsyncMock())  # type: ignore[assignment]
        stale._sandbox.refresh = AsyncMock(side_effect=RuntimeError("sandbox gone"))  # type: ignore[union-attr]
        # available() will return False because refresh() throws
        stale.shutdown = AsyncMock()

        monkeypatch.setitem(computer_client.session_booter, "session-1", stale)

        from astrbot.core.computer.computer_client import get_booter

        # get_booter should evict stale and rebuild.
        # We need to mock the entire rebuild path so it doesn't actually
        # try to connect to Bay.
        async def _fake_boot(_self, _sid):
            _self._sandbox = SimpleNamespace(  # type: ignore[assignment]
                id="new-sandbox",
                refresh=AsyncMock(),
                status=SimpleNamespace(value="ready"),
                capabilities=["python", "shell", "filesystem"],
            )
            _self._client = SimpleNamespace()  # type: ignore[assignment]
            _self._shell = SimpleNamespace()  # type: ignore[assignment]
            _self._fs = SimpleNamespace()  # type: ignore[assignment]
            _self._python = SimpleNamespace()  # type: ignore[assignment]

        with (
            patch.object(ShipyardNeoBooter, "boot", _fake_boot),
            patch(
                "astrbot.core.computer.computer_client._sync_skills_to_sandbox",
                AsyncMock(),
            ),
        ):
            await get_booter(ctx, "session-1")

        stale.shutdown.assert_awaited_once_with(delete_sandbox=True)
        # Old entry should be replaced
        new_booter = computer_client.session_booter.get("session-1")
        assert new_booter is not None
        assert new_booter is not stale

    @pytest.mark.asyncio
    async def test_stale_non_neo_booter_calls_plain_shutdown(self, monkeypatch):
        """Non-neo booter (e.g. shipyard) → plain shutdown() without delete_sandbox."""
        from astrbot.core.computer import computer_client

        ctx = self._make_fake_context(booter_type="shipyard")

        stale = SimpleNamespace(shutdown=AsyncMock())
        stale.available = AsyncMock(return_value=False)

        monkeypatch.setitem(computer_client.session_booter, "session-1", stale)

        # Patch ShipyardBooter entirely to skip its __init__ validation
        class _FakeShipyardBooter:
            def __init__(self, **kwargs):
                pass

            async def boot(self, _sid):
                self._sandbox = SimpleNamespace(  # type: ignore[assignment]
                    refresh=AsyncMock(),
                    status=SimpleNamespace(value="ready"),
                )
                self._shell = SimpleNamespace()  # type: ignore[assignment]
                self._fs = SimpleNamespace()  # type: ignore[assignment]
                self._python = SimpleNamespace()  # type: ignore[assignment]

            async def shutdown(self, **kwargs):
                pass

        with (
            patch(
                "astrbot.core.computer.booters.shipyard.ShipyardBooter",
                _FakeShipyardBooter,
            ),
            patch(
                "astrbot.core.computer.computer_client._sync_skills_to_sandbox",
                AsyncMock(),
            ),
        ):
            from astrbot.core.computer.computer_client import get_booter

            await get_booter(ctx, "session-1")

        stale.shutdown.assert_awaited_once()
        # No delete_sandbox kwarg for non-neo booters
        call_kwargs = stale.shutdown.call_args.kwargs
        assert call_kwargs == {}
