"""Tests for the shared model lifecycle in session/model.py."""

from __future__ import annotations

from unittest.mock import MagicMock, patch

from icarus.session.model import ModelsInfo, list_models, switch_model

# ---------------------------------------------------------------------------
# list_models
# ---------------------------------------------------------------------------

class TestListModels:
    @patch("icarus.session.model.get_available_models")
    @patch("icarus.session.model.ModelConfig")
    def test_returns_models_info(
        self, mock_config_cls: MagicMock, mock_available: MagicMock
    ) -> None:
        mock_available.return_value = {
            "anthropic": ["claude-sonnet-4-5", "claude-opus-4"],
            "openai": ["gpt-4o"],
        }
        mock_config = MagicMock()
        mock_config.default_model = "anthropic:claude-sonnet-4-5"
        mock_config.recent_model = None
        mock_config_cls.load.return_value = mock_config

        info = list_models()

        assert isinstance(info, ModelsInfo)
        # Sorted by provider, so anthropic before openai
        assert len(info.models) == 3
        assert info.models[0] == {"provider": "anthropic", "model": "claude-sonnet-4-5"}
        assert info.models[2] == {"provider": "openai", "model": "gpt-4o"}
        assert info.default == "anthropic:claude-sonnet-4-5"

    @patch("icarus.session.model.get_available_models", return_value={})
    @patch("icarus.session.model.ModelConfig")
    def test_empty_models(
        self, mock_config_cls: MagicMock, _mock_available: MagicMock
    ) -> None:
        mock_config = MagicMock()
        mock_config.default_model = None
        mock_config.recent_model = None
        mock_config_cls.load.return_value = mock_config

        info = list_models()

        assert info.models == []
        assert info.current is None
        assert info.default is None

    @patch("icarus.session.model.get_available_models", return_value={})
    @patch("icarus.session.model.ModelConfig")
    @patch("icarus.session.model.settings")
    def test_current_from_settings(
        self,
        mock_settings: MagicMock,
        mock_config_cls: MagicMock,
        _mock_available: MagicMock,
    ) -> None:
        mock_settings.model_name = "gpt-4o"
        mock_settings.model_provider = "openai"
        mock_config = MagicMock()
        mock_config.default_model = None
        mock_config.recent_model = None
        mock_config_cls.load.return_value = mock_config

        info = list_models()
        assert info.current == "openai:gpt-4o"

    @patch("icarus.session.model.get_available_models", return_value={"openai": ["gpt-4o"]})
    @patch("icarus.session.model.ModelConfig")
    def test_default_falls_back_to_recent(
        self, mock_config_cls: MagicMock, _mock_available: MagicMock
    ) -> None:
        mock_config = MagicMock()
        mock_config.default_model = None
        mock_config.recent_model = "openai:gpt-4o"
        mock_config_cls.load.return_value = mock_config

        info = list_models()
        assert info.default == "openai:gpt-4o"

    @patch(
        "icarus.session.model.get_available_models",
        return_value={"anthropic": ["claude-sonnet-4-6"]},
    )
    @patch("icarus.session.model.ModelConfig")
    def test_unreachable_default_falls_back(
        self, mock_config_cls: MagicMock, _mock_available: MagicMock
    ) -> None:
        """Default pointing to an unreachable model should fall back."""
        mock_config = MagicMock()
        mock_config.default_model = "ollama:dead-model:7b"
        mock_config.recent_model = "anthropic:claude-sonnet-4-6"
        mock_config_cls.load.return_value = mock_config

        info = list_models()
        assert info.default == "anthropic:claude-sonnet-4-6"


# ---------------------------------------------------------------------------
# switch_model
# ---------------------------------------------------------------------------

class TestSwitchModel:
    def _make_ctx(self) -> MagicMock:
        ctx = MagicMock()
        ctx.config_dir = MagicMock()
        return ctx

    @patch("icarus.session.model.settings")
    @patch("icarus.session.model.has_provider_credentials", return_value=False)
    @patch("icarus.session.model.get_credential_env_var", return_value="ANTHROPIC_API_KEY")
    def test_missing_credentials(
        self,
        _mock_env: MagicMock,
        _mock_creds: MagicMock,
        _mock_settings: MagicMock,
    ) -> None:
        result = switch_model(
            "anthropic:claude-sonnet-4-5",
            ctx=self._make_ctx(),
            checkpointer=MagicMock(),
        )
        assert not result.ok
        assert "ANTHROPIC_API_KEY" in result.error

    @patch("icarus.session.model.has_provider_credentials", return_value=False)
    @patch("icarus.session.model.get_credential_env_var", return_value=None)
    @patch("icarus.session.model.settings")
    def test_unrecognized_provider(
        self,
        _mock_settings: MagicMock,
        _mock_env: MagicMock,
        _mock_creds: MagicMock,
    ) -> None:
        result = switch_model(
            "fakeprovider:some-model",
            ctx=self._make_ctx(),
            checkpointer=MagicMock(),
        )
        assert not result.ok
        assert "not recognized" in result.error

    @patch("icarus.session.model.has_provider_credentials", return_value=True)
    @patch("icarus.session.model.settings")
    def test_already_active(
        self, mock_settings: MagicMock, _mock_creds: MagicMock
    ) -> None:
        mock_settings.model_name = "claude-sonnet-4-5"
        mock_settings.model_provider = "anthropic"

        result = switch_model(
            "anthropic:claude-sonnet-4-5",
            ctx=self._make_ctx(),
            checkpointer=MagicMock(),
        )
        assert not result.ok
        assert result.already_active
        assert result.display_name == "anthropic:claude-sonnet-4-5"

    @patch("icarus.session.model.has_provider_credentials", return_value=True)
    @patch("icarus.session.model.settings")
    @patch("icarus.session.model.create_model")
    def test_invalid_model_spec(
        self,
        mock_create: MagicMock,
        mock_settings: MagicMock,
        _mock_creds: MagicMock,
    ) -> None:
        from icarus.config.model_config import ModelConfigError

        mock_settings.model_name = "old-model"
        mock_settings.model_provider = "openai"
        mock_create.side_effect = ModelConfigError("No such model")

        result = switch_model(
            "anthropic:nonexistent",
            ctx=self._make_ctx(),
            checkpointer=MagicMock(),
        )
        assert not result.ok
        assert "No such model" in result.error

    @patch("icarus.session.model.save_recent_model", return_value=True)
    @patch("icarus.runtime.builder.create_icarus_agent")
    @patch("icarus.session.model.has_provider_credentials", return_value=True)
    @patch("icarus.session.model.settings")
    @patch("icarus.session.model.create_model")
    def test_successful_switch(
        self,
        mock_create: MagicMock,
        mock_settings: MagicMock,
        _mock_creds: MagicMock,
        mock_agent: MagicMock,
        _mock_save: MagicMock,
    ) -> None:
        mock_settings.model_name = "old-model"
        mock_settings.model_provider = "openai"

        model_result = MagicMock()
        model_result.model = MagicMock()
        mock_create.return_value = model_result

        agent_result = MagicMock()
        agent_result.graph = MagicMock()
        agent_result.backend = MagicMock()
        agent_result.model = MagicMock()
        mock_agent.return_value = agent_result

        result = switch_model(
            "anthropic:claude-sonnet-4-5",
            ctx=self._make_ctx(),
            checkpointer=MagicMock(),
        )
        assert result.ok
        assert result.graph is agent_result.graph
        assert result.backend is agent_result.backend

    @patch("icarus.runtime.builder.create_icarus_agent")
    @patch("icarus.session.model.has_provider_credentials", return_value=True)
    @patch("icarus.session.model.settings")
    @patch("icarus.session.model.create_model")
    def test_agent_creation_failure_rolls_back_settings(
        self,
        mock_create: MagicMock,
        mock_settings: MagicMock,
        _mock_creds: MagicMock,
        mock_agent: MagicMock,
    ) -> None:
        mock_settings.model_name = "old-model"
        mock_settings.model_provider = "openai"
        mock_settings.model_context_limit = 128_000

        model_result = MagicMock()
        mock_create.return_value = model_result

        mock_agent.side_effect = RuntimeError("Graph compilation failed")

        result = switch_model(
            "anthropic:claude-sonnet-4-5",
            ctx=self._make_ctx(),
            checkpointer=MagicMock(),
        )
        assert not result.ok
        assert "Graph compilation failed" in result.error
        # Settings should be rolled back
        assert mock_settings.model_name == "old-model"
        assert mock_settings.model_provider == "openai"
        assert mock_settings.model_context_limit == 128_000

    @patch("icarus.session.model.has_provider_credentials", return_value=True)
    @patch("icarus.session.model.settings")
    def test_leading_colon_stripped(
        self, mock_settings: MagicMock, _mock_creds: MagicMock
    ) -> None:
        """Leading colon in spec is stripped — `:claude-opus-4-6` → `claude-opus-4-6`."""
        mock_settings.model_name = "claude-opus-4-6"
        mock_settings.model_provider = "anthropic"

        # Should detect it's already active after stripping the colon
        result = switch_model(
            ":claude-opus-4-6",
            ctx=self._make_ctx(),
            checkpointer=MagicMock(),
        )
        assert result.already_active
