"""Tests for interactive model selector (issue #113)."""

from __future__ import annotations

from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from llmhosts.discovery.models import ModelDetails, OllamaModel
from llmhosts.interactive import (
    _filter_models,
    _fuzzy_score,
    _render_model_table,
    discover_and_select,
    is_interactive,
    select_models_interactive,
)

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


def _make_model(
    name: str,
    size: int = 5_000_000_000,
    family: str = "llama",
    parameter_size: str = "8B",
    quantization: str = "Q4_K_M",
) -> OllamaModel:
    """Create a test OllamaModel."""
    return OllamaModel(
        name=name,
        model=name,
        size=size,
        details=ModelDetails(
            family=family,
            parameter_size=parameter_size,
            quantization_level=quantization,
        ),
    )


@pytest.fixture
def sample_models() -> list[OllamaModel]:
    return [
        _make_model("llama3.2:8b", size=4_700_000_000, family="llama", parameter_size="8B"),
        _make_model("mistral:7b", size=4_100_000_000, family="mistral", parameter_size="7B"),
        _make_model("codellama:13b", size=7_400_000_000, family="llama", parameter_size="13B", quantization="Q4_0"),
        _make_model("phi3:mini", size=2_300_000_000, family="phi", parameter_size="3.8B"),
        _make_model("qwen2.5:7b", size=4_400_000_000, family="qwen2", parameter_size="7B"),
    ]


# ---------------------------------------------------------------------------
# Fuzzy scoring
# ---------------------------------------------------------------------------


class TestFuzzyScore:
    """Verify fuzzy matching logic."""

    def test_exact_match(self) -> None:
        assert _fuzzy_score("llama", "llama3.2:8b") == 1.0

    def test_substring_match(self) -> None:
        assert _fuzzy_score("code", "codellama:13b") == 1.0

    def test_token_match(self) -> None:
        # "llama 8b" is a substring of the text, so substring match (1.0) fires first
        assert _fuzzy_score("llama 8b", "llama3.2:8b llama 8B Q4_K_M") == 1.0
        # When tokens match but aren't a substring, we get 0.9
        assert _fuzzy_score("mistral 7b", "mistral:7b mistral 7B Q4_0") == 1.0

    def test_no_match(self) -> None:
        score = _fuzzy_score("nonexistent_model_xyz", "llama3.2:8b")
        assert score < 0.3

    def test_case_insensitive(self) -> None:
        assert _fuzzy_score("LLAMA", "llama3.2:8b") == 1.0

    def test_empty_query(self) -> None:
        score = _fuzzy_score("", "llama3.2:8b")
        # Empty string is a substring of everything
        assert score == 1.0

    def test_partial_match_scores_above_threshold(self) -> None:
        score = _fuzzy_score("mistrl", "mistral:7b mistral 7B Q4_K_M")
        assert score >= 0.3


# ---------------------------------------------------------------------------
# Filtering
# ---------------------------------------------------------------------------


class TestFilterModels:
    """Verify model filtering with fuzzy search."""

    def test_empty_query_returns_all(self, sample_models: list[OllamaModel]) -> None:
        result = _filter_models(sample_models, "")
        assert len(result) == len(sample_models)

    def test_filter_by_name(self, sample_models: list[OllamaModel]) -> None:
        result = _filter_models(sample_models, "llama")
        names = [m.name for m in result]
        assert "llama3.2:8b" in names
        assert "codellama:13b" in names

    def test_filter_by_family(self, sample_models: list[OllamaModel]) -> None:
        result = _filter_models(sample_models, "mistral")
        assert len(result) >= 1
        assert result[0].name == "mistral:7b"

    def test_filter_by_quantization(self, sample_models: list[OllamaModel]) -> None:
        result = _filter_models(sample_models, "Q4_0")
        names = [m.name for m in result]
        assert "codellama:13b" in names

    def test_filter_no_match_returns_empty(self, sample_models: list[OllamaModel]) -> None:
        result = _filter_models(sample_models, "zzznonexistent")
        assert len(result) == 0

    def test_filter_ranks_exact_match_first(self, sample_models: list[OllamaModel]) -> None:
        result = _filter_models(sample_models, "phi3")
        assert result[0].name == "phi3:mini"


# ---------------------------------------------------------------------------
# Table rendering
# ---------------------------------------------------------------------------


class TestRenderModelTable:
    """Verify table rendering doesn't crash."""

    def test_render_with_models(self, sample_models: list[OllamaModel]) -> None:
        mock_console = MagicMock()
        _render_model_table(mock_console, sample_models)
        mock_console.print.assert_called_once()

    def test_render_empty_models(self) -> None:
        mock_console = MagicMock()
        _render_model_table(mock_console, [])
        mock_console.print.assert_called_once()

    def test_render_without_index(self, sample_models: list[OllamaModel]) -> None:
        mock_console = MagicMock()
        _render_model_table(mock_console, sample_models, show_index=False)
        mock_console.print.assert_called_once()


# ---------------------------------------------------------------------------
# Interactive selection (mocked prompts)
# ---------------------------------------------------------------------------


class TestSelectModelsInteractive:
    """Verify interactive selection logic."""

    def test_empty_models_returns_empty(self) -> None:
        mock_console = MagicMock()
        result = select_models_interactive(mock_console, [])
        assert result == []

    @patch("llmhosts.interactive.Prompt.ask", return_value="all")
    def test_select_all(self, _mock_ask: MagicMock, sample_models: list[OllamaModel]) -> None:
        mock_console = MagicMock()
        result = select_models_interactive(mock_console, sample_models)
        assert len(result) == len(sample_models)

    @patch("llmhosts.interactive.Prompt.ask", return_value="1,3")
    def test_select_by_index(self, _mock_ask: MagicMock, sample_models: list[OllamaModel]) -> None:
        mock_console = MagicMock()
        result = select_models_interactive(mock_console, sample_models)
        assert len(result) == 2
        assert result[0].name == "llama3.2:8b"
        assert result[1].name == "codellama:13b"

    @patch("llmhosts.interactive.Prompt.ask", return_value="q")
    def test_cancel_returns_empty(self, _mock_ask: MagicMock, sample_models: list[OllamaModel]) -> None:
        mock_console = MagicMock()
        result = select_models_interactive(mock_console, sample_models)
        assert result == []

    @patch("llmhosts.interactive.Prompt.ask", side_effect=["1", "ignored"])
    def test_single_selection(self, _mock_ask: MagicMock, sample_models: list[OllamaModel]) -> None:
        mock_console = MagicMock()
        result = select_models_interactive(mock_console, sample_models)
        assert len(result) == 1
        assert result[0].name == "llama3.2:8b"

    @patch("llmhosts.interactive.Prompt.ask", side_effect=["filter llama", "all"])
    def test_filter_then_select(self, _mock_ask: MagicMock, sample_models: list[OllamaModel]) -> None:
        mock_console = MagicMock()
        result = select_models_interactive(mock_console, sample_models)
        # After filtering for "llama", should get llama3.2:8b and codellama:13b
        assert len(result) >= 2
        names = [m.name for m in result]
        assert "llama3.2:8b" in names
        assert "codellama:13b" in names

    @patch("llmhosts.interactive.Prompt.ask", side_effect=["reset", "q"])
    def test_reset_filter(self, _mock_ask: MagicMock, sample_models: list[OllamaModel]) -> None:
        mock_console = MagicMock()
        result = select_models_interactive(mock_console, sample_models)
        assert result == []

    @patch("llmhosts.interactive.Prompt.ask", return_value="99")
    def test_invalid_index_loops(self, mock_ask: MagicMock, sample_models: list[OllamaModel]) -> None:
        # Will print a warning, then loop. Set side_effect to exit after one try.
        mock_ask.side_effect = ["99", "q"]
        mock_console = MagicMock()
        result = select_models_interactive(mock_console, sample_models)
        assert result == []


# ---------------------------------------------------------------------------
# TTY detection
# ---------------------------------------------------------------------------


class TestIsInteractive:
    """Verify TTY detection."""

    @patch("sys.stdin")
    def test_tty_stdin(self, mock_stdin: MagicMock) -> None:
        mock_stdin.isatty.return_value = True
        assert is_interactive() is True

    @patch("sys.stdin")
    def test_non_tty_stdin(self, mock_stdin: MagicMock) -> None:
        mock_stdin.isatty.return_value = False
        assert is_interactive() is False


# ---------------------------------------------------------------------------
# Discover and select (async)
# ---------------------------------------------------------------------------


class TestDiscoverAndSelect:
    """Verify async discovery + selection flow."""

    @pytest.mark.asyncio
    async def test_ollama_unavailable(self) -> None:
        mock_console = MagicMock()
        with patch("llmhosts.discovery.ollama.OllamaDiscovery") as mock_discovery:
            instance = AsyncMock()
            instance.is_available.return_value = False
            mock_discovery.return_value.__aenter__ = AsyncMock(return_value=instance)
            mock_discovery.return_value.__aexit__ = AsyncMock(return_value=False)

            result = await discover_and_select(mock_console)
            assert result == []

    @pytest.mark.asyncio
    async def test_no_models_installed(self) -> None:
        mock_console = MagicMock()
        with patch("llmhosts.discovery.ollama.OllamaDiscovery") as mock_discovery:
            instance = AsyncMock()
            instance.is_available.return_value = True
            instance.list_models.return_value = []
            mock_discovery.return_value.__aenter__ = AsyncMock(return_value=instance)
            mock_discovery.return_value.__aexit__ = AsyncMock(return_value=False)

            result = await discover_and_select(mock_console)
            assert result == []

    @pytest.mark.asyncio
    async def test_models_found_triggers_selector(self) -> None:
        models = [_make_model("llama3.2:8b")]
        mock_console = MagicMock()
        with (
            patch("llmhosts.discovery.ollama.OllamaDiscovery") as mock_discovery,
            patch("llmhosts.interactive.select_models_interactive", return_value=models) as mock_select,
        ):
            instance = AsyncMock()
            instance.is_available.return_value = True
            instance.list_models.return_value = models
            mock_discovery.return_value.__aenter__ = AsyncMock(return_value=instance)
            mock_discovery.return_value.__aexit__ = AsyncMock(return_value=False)

            result = await discover_and_select(mock_console)
            assert result == models
            mock_select.assert_called_once_with(mock_console, models)


# ---------------------------------------------------------------------------
# CLI integration (--interactive flag)
# ---------------------------------------------------------------------------


class TestServeInteractiveFlag:
    """Verify --interactive flag is registered on serve command."""

    def test_interactive_flag_exists(self) -> None:
        from llmhosts.cli import serve

        param_names = [p.name for p in serve.params]
        assert "interactive" in param_names

    def test_interactive_flag_is_option(self) -> None:
        from llmhosts.cli import serve

        interactive_param = next(p for p in serve.params if p.name == "interactive")
        assert interactive_param.is_flag

    def test_interactive_short_flag(self) -> None:
        from llmhosts.cli import serve

        interactive_param = next(p for p in serve.params if p.name == "interactive")
        assert "-i" in interactive_param.opts


# ---------------------------------------------------------------------------
# Config integration
# ---------------------------------------------------------------------------


class TestConfigSelectedModels:
    """Verify selected_models field on LLMHostsConfig."""

    def test_selected_models_default_empty(self) -> None:
        from llmhosts.config import LLMHostsConfig

        config = LLMHostsConfig()
        assert config.selected_models == []

    def test_selected_models_can_be_set(self) -> None:
        from llmhosts.config import LLMHostsConfig

        config = LLMHostsConfig()
        config.selected_models = ["llama3.2:8b", "mistral:7b"]
        assert config.selected_models == ["llama3.2:8b", "mistral:7b"]

    def test_selected_models_excluded_from_serialization(self) -> None:
        from llmhosts.config import LLMHostsConfig

        config = LLMHostsConfig(selected_models=["llama3.2:8b"])
        dumped = config.model_dump()
        assert "selected_models" not in dumped
