"""End-to-end tests for the lazy model switch pipeline.

Tests the full flow: set_model (instant) -> send (lazy switch) -> stream,
including concurrent sends, failure recovery, and credential interactions.
"""

from __future__ import annotations

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

import pytest
from icarus.session.model import SwitchResult


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------


@pytest.fixture
def sidecar() -> Any:
    from icarus.sidecar.main import Sidecar

    s = Sidecar()
    engine = s._engine
    engine._agent = MagicMock()
    engine._backend = MagicMock()
    engine._model = MagicMock()
    engine._ctx = MagicMock()
    engine._mcp_tools = []
    engine._checkpointer = MagicMock()
    engine._pending_model = None
    engine._known_threads = set()
    return s


def _ok_result(name: str = "anthropic:claude-sonnet-4") -> SwitchResult:
    return SwitchResult(
        ok=True,
        display_name=name,
        graph=MagicMock(),
        backend=MagicMock(),
        model=MagicMock(),
    )


def _fail_result(error: str = "Model not found") -> SwitchResult:
    return SwitchResult(ok=False, error=error)


# ---------------------------------------------------------------------------
# set_model: instant, lazy
# ---------------------------------------------------------------------------


class TestSetModelLazy:
    """set_model should save pending and respond instantly."""

    @pytest.mark.asyncio
    async def test_responds_immediately(self, sidecar: Any) -> None:
        from icarus.config.app_config import settings
        with (
            patch("icarus.sidecar.main.write_response") as mock_write,
            patch.object(settings, "model_provider", "openai"),
            patch.object(settings, "model_name", "gpt-4o"),
            patch("icarus.config.model_config.save_recent_model"),
        ):
            await sidecar._dispatch({"cmd": "set_model", "model": "anthropic:claude-sonnet-4"})
            data = mock_write.call_args[0][0]
            assert data["type"] == "model_set"
            assert data["model"] == "anthropic:claude-sonnet-4"

    @pytest.mark.asyncio
    async def test_sets_pending_model(self, sidecar: Any) -> None:
        from icarus.config.app_config import settings
        with (
            patch("icarus.sidecar.main.write_response"),
            patch.object(settings, "model_provider", "openai"),
            patch.object(settings, "model_name", "gpt-4o"),
            patch("icarus.config.model_config.save_recent_model"),
        ):
            await sidecar._dispatch({"cmd": "set_model", "model": "anthropic:claude-sonnet-4"})
            assert sidecar._pending_model == "anthropic:claude-sonnet-4"

    @pytest.mark.asyncio
    async def test_does_not_call_switch_model(self, sidecar: Any) -> None:
        from icarus.config.app_config import settings
        with (
            patch("icarus.sidecar.main.write_response"),
            patch.object(settings, "model_provider", "openai"),
            patch.object(settings, "model_name", "gpt-4o"),
            patch("icarus.config.model_config.save_recent_model"),
            patch("icarus.session.model.switch_model") as mock_switch,
        ):
            await sidecar._dispatch({"cmd": "set_model", "model": "anthropic:claude-sonnet-4"})
            mock_switch.assert_not_called()

    @pytest.mark.asyncio
    async def test_saves_recent_model(self, sidecar: Any) -> None:
        from icarus.config.app_config import settings
        with (
            patch("icarus.sidecar.main.write_response"),
            patch.object(settings, "model_provider", "openai"),
            patch.object(settings, "model_name", "gpt-4o"),
            patch("icarus.config.model_config.save_recent_model") as mock_save,
        ):
            await sidecar._dispatch({"cmd": "set_model", "model": "anthropic:claude-sonnet-4"})
            args, kwargs = mock_save.call_args
            assert args[0] == "anthropic:claude-sonnet-4"


# ---------------------------------------------------------------------------
# already_active detection
# ---------------------------------------------------------------------------


class TestAlreadyActive:
    """set_model should detect when the requested model is already current."""

    @pytest.mark.asyncio
    async def test_matches_settings(self, sidecar: Any) -> None:
        from icarus.config.app_config import settings
        with (
            patch("icarus.sidecar.main.write_response") as mock_write,
            patch.object(settings, "model_provider", "openai"),
            patch.object(settings, "model_name", "gpt-4o"),
        ):
            await sidecar._dispatch({"cmd": "set_model", "model": "openai:gpt-4o"})
            data = mock_write.call_args[0][0]
            assert data["already_active"] is True
            assert sidecar._pending_model is None

    @pytest.mark.asyncio
    async def test_matches_pending(self, sidecar: Any) -> None:
        sidecar._pending_model = "anthropic:claude-sonnet-4"
        from icarus.config.app_config import settings
        with (
            patch("icarus.sidecar.main.write_response") as mock_write,
            patch.object(settings, "model_provider", "openai"),
            patch.object(settings, "model_name", "gpt-4o"),
        ):
            await sidecar._dispatch({"cmd": "set_model", "model": "anthropic:claude-sonnet-4"})
            data = mock_write.call_args[0][0]
            assert data["already_active"] is True

    @pytest.mark.asyncio
    async def test_pending_overrides_settings(self, sidecar: Any) -> None:
        """After lazy switch away, the boot model should NOT match as active."""
        sidecar._pending_model = "anthropic:claude-sonnet-4"
        from icarus.config.app_config import settings
        with (
            patch("icarus.sidecar.main.write_response") as mock_write,
            patch.object(settings, "model_provider", "openai"),
            patch.object(settings, "model_name", "gpt-4o"),
            patch("icarus.config.model_config.save_recent_model"),
        ):
            # Requesting the boot model (openai:gpt-4o) should NOT be already_active
            # because _pending_model is set to something else
            await sidecar._dispatch({"cmd": "set_model", "model": "openai:gpt-4o"})
            data = mock_write.call_args[0][0]
            assert "already_active" not in data or data.get("already_active") is not True
            assert sidecar._pending_model == "openai:gpt-4o"


# ---------------------------------------------------------------------------
# list_models: reports pending
# ---------------------------------------------------------------------------


class TestListModelsWithPending:
    @pytest.mark.asyncio
    async def test_reports_pending_as_current(self, sidecar: Any) -> None:
        sidecar._pending_model = "anthropic:claude-sonnet-4"
        from icarus.session.model import ModelsInfo
        mock_info = ModelsInfo(
            models=[], current="openai:gpt-4o", default=None, default_explicit=False,
        )
        with (
            patch("icarus.sidecar.main.write_response") as mock_write,
            patch("icarus.session.model.list_models", return_value=mock_info),
        ):
            await sidecar._dispatch({"cmd": "list_models"})
            data = mock_write.call_args[0][0]
            assert data["current"] == "anthropic:claude-sonnet-4"

    @pytest.mark.asyncio
    async def test_no_pending_reports_actual(self, sidecar: Any) -> None:
        sidecar._pending_model = None
        from icarus.session.model import ModelsInfo
        mock_info = ModelsInfo(
            models=[], current="openai:gpt-4o", default=None, default_explicit=False,
        )
        with (
            patch("icarus.sidecar.main.write_response") as mock_write,
            patch("icarus.session.model.list_models", return_value=mock_info),
        ):
            await sidecar._dispatch({"cmd": "list_models"})
            data = mock_write.call_args[0][0]
            assert data["current"] == "openai:gpt-4o"


# ---------------------------------------------------------------------------
# send: lazy switch execution
# ---------------------------------------------------------------------------


class TestSendLazySwitch:
    """send should execute the pending model switch before streaming."""

    @pytest.mark.asyncio
    async def test_calls_switch_model(self, sidecar: Any) -> None:
        engine = sidecar._engine
        engine._pending_model = "anthropic:claude-sonnet-4"

        async def mock_apply(result):
            engine._agent = result.graph
        engine._apply_model_result = mock_apply

        with (
            patch("icarus.sidecar.main.write_response"),
            patch("icarus.session.model.switch_model", return_value=_ok_result()) as mock_switch,
        ):
            await sidecar._dispatch({"cmd": "send", "thread_id": "t1", "content": "hi"})
            mock_switch.assert_called_once()

    @pytest.mark.asyncio
    async def test_clears_pending_on_success(self, sidecar: Any) -> None:
        engine = sidecar._engine
        engine._pending_model = "anthropic:claude-sonnet-4"

        async def mock_apply(result):
            engine._agent = result.graph
        engine._apply_model_result = mock_apply

        with (
            patch("icarus.sidecar.main.write_response"),
            patch("icarus.session.model.switch_model", return_value=_ok_result()),
        ):
            await sidecar._dispatch({"cmd": "send", "thread_id": "t1", "content": "hi"})
            assert sidecar._pending_model is None

    @pytest.mark.asyncio
    async def test_skips_switch_when_no_pending(self, sidecar: Any) -> None:
        engine = sidecar._engine
        engine._pending_model = None
        engine._known_threads = {"t1"}

        with (
            patch("icarus.sidecar.main.write_response"),
            patch("icarus.session.model.switch_model") as mock_switch,
            patch.object(engine, "_start_stream"),
        ):
            await sidecar._dispatch({"cmd": "send", "thread_id": "t1", "content": "hi"})
            mock_switch.assert_not_called()


# ---------------------------------------------------------------------------
# send: failure recovery
# ---------------------------------------------------------------------------


class TestSendSwitchFailure:
    """Failed lazy switch should revert the frontend and show inline error."""

    @pytest.mark.asyncio
    async def test_sends_model_set_revert(self, sidecar: Any) -> None:
        sidecar._pending_model = "bad:model"
        from icarus.config.app_config import settings
        with (
            patch("icarus.sidecar.main.write_response") as mock_write,
            patch("icarus.session.model.switch_model", return_value=_fail_result()),
            patch.object(settings, "model_provider", "openai"),
            patch.object(settings, "model_name", "gpt-4o"),
        ):
            await sidecar._dispatch({"cmd": "send", "thread_id": "t1", "content": "hi"})
            calls = [c[0][0] for c in mock_write.call_args_list]
            revert = [c for c in calls if c["type"] == "model_set"]
            assert len(revert) == 1
            assert revert[0]["model"] == "openai:gpt-4o"

    @pytest.mark.asyncio
    async def test_sends_inline_error(self, sidecar: Any) -> None:
        sidecar._pending_model = "bad:model"
        from icarus.config.app_config import settings
        with (
            patch("icarus.sidecar.main.write_response") as mock_write,
            patch("icarus.session.model.switch_model", return_value=_fail_result("No access")),
            patch.object(settings, "model_provider", "openai"),
            patch.object(settings, "model_name", "gpt-4o"),
        ):
            await sidecar._dispatch({"cmd": "send", "thread_id": "t1", "content": "hi"})
            calls = [c[0][0] for c in mock_write.call_args_list]
            errors = [c for c in calls if c["type"] == "error"]
            assert len(errors) == 1
            assert errors[0]["thread_id"] == "t1"
            assert "No access" in errors[0]["message"]

    @pytest.mark.asyncio
    async def test_clears_pending_on_failure(self, sidecar: Any) -> None:
        sidecar._pending_model = "bad:model"
        from icarus.config.app_config import settings
        with (
            patch("icarus.sidecar.main.write_response"),
            patch("icarus.session.model.switch_model", return_value=_fail_result()),
            patch.object(settings, "model_provider", "openai"),
            patch.object(settings, "model_name", "gpt-4o"),
        ):
            await sidecar._dispatch({"cmd": "send", "thread_id": "t1", "content": "hi"})
            assert sidecar._pending_model is None

    @pytest.mark.asyncio
    async def test_exception_also_reverts(self, sidecar: Any) -> None:
        sidecar._pending_model = "bad:model"
        from icarus.config.app_config import settings
        with (
            patch("icarus.sidecar.main.write_response") as mock_write,
            patch("icarus.session.model.switch_model", side_effect=RuntimeError("boom")),
            patch.object(settings, "model_provider", "openai"),
            patch.object(settings, "model_name", "gpt-4o"),
        ):
            await sidecar._dispatch({"cmd": "send", "thread_id": "t1", "content": "hi"})
            calls = [c[0][0] for c in mock_write.call_args_list]
            types = [c["type"] for c in calls]
            assert "model_set" in types
            assert "error" in types
            assert sidecar._pending_model is None

    @pytest.mark.asyncio
    async def test_failure_reverts_with_null_model(self, sidecar: Any) -> None:
        """Fresh install: no previous model. Revert should send model=None."""
        sidecar._pending_model = "bad:model"
        sidecar._agent = None  # No agent -- fresh install
        from icarus.config.app_config import settings
        with (
            patch("icarus.sidecar.main.write_response") as mock_write,
            patch("icarus.session.model.switch_model", return_value=_fail_result()),
            patch.object(settings, "model_provider", ""),
            patch.object(settings, "model_name", ""),
        ):
            await sidecar._dispatch({"cmd": "send", "thread_id": "t1", "content": "hi"})
            calls = [c[0][0] for c in mock_write.call_args_list]
            revert = [c for c in calls if c["type"] == "model_set"]
            assert len(revert) == 1
            # model should be None when no previous model was configured
            assert revert[0]["model"] is None


# ---------------------------------------------------------------------------
# Credentials interaction
# ---------------------------------------------------------------------------


class TestCredentialsInteraction:
    @pytest.mark.asyncio
    async def test_clears_pending_before_rebuild(self, sidecar: Any) -> None:
        """Saving credentials should clear _pending_model so agent rebuild runs."""
        engine = sidecar._engine
        engine._pending_model = "anthropic:claude-sonnet-4"
        from icarus.config.app_config import settings
        with (
            patch("icarus.sidecar.main.write_response"),
            patch("icarus.config.model_config.save_credentials"),
            patch("icarus.config.model_config.clear_caches"),
            patch.object(settings, "reload_credentials"),
            patch.object(settings, "model_name", "gpt-4o"),
            patch.object(settings, "model_provider", "openai"),
            patch.object(engine, "_close_model", return_value=asyncio.sleep(0)),
            patch.object(engine, "_try_create_agent_sync"),
            patch("icarus.config.model_config.has_provider_credentials", return_value=True),
        ):
            await sidecar._dispatch({
                "cmd": "set_credentials",
                "credentials": {"ANTHROPIC_API_KEY": "test"},
            })
            assert sidecar._pending_model is None


# ---------------------------------------------------------------------------
# Switch lock: concurrent sends
# ---------------------------------------------------------------------------


class TestSwitchLock:
    """The switch lock should serialize concurrent lazy switches."""

    @pytest.mark.asyncio
    async def test_second_send_waits_for_switch(self, sidecar: Any) -> None:
        """Two sends during a pending switch: both should use the new model."""
        engine = sidecar._engine
        engine._pending_model = "anthropic:claude-sonnet-4"
        switch_started = asyncio.Event()
        switch_complete = asyncio.Event()

        original_switch = None

        def slow_switch(*args, **kwargs):
            """Simulate a 100ms model switch."""
            import time
            switch_started.set()
            time.sleep(0.1)
            result = _ok_result()
            return result

        async def mock_apply(result):
            engine._agent = result.graph
            engine._backend = result.backend
            engine._model = result.model
            switch_complete.set()
        engine._apply_model_result = mock_apply

        with (
            patch("icarus.sidecar.main.write_response"),
            patch("icarus.session.model.switch_model", side_effect=slow_switch) as mock_switch,
        ):
            # Launch two sends concurrently
            task1 = asyncio.create_task(
                sidecar._dispatch({"cmd": "send", "thread_id": "t1", "content": "first"})
            )
            # Small delay to ensure task1 enters the lock first
            await asyncio.sleep(0.01)
            task2 = asyncio.create_task(
                sidecar._dispatch({"cmd": "send", "thread_id": "t2", "content": "second"})
            )

            await asyncio.gather(task1, task2)

            # switch_model should only be called once -- second send
            # waits for the lock, finds _pending_model cleared, skips
            assert mock_switch.call_count == 1
            assert sidecar._pending_model is None
