"""Unit tests for TUI model menu module.

Tests cover:
- TTY detection logic
- ModelMenu initialization and state
- Menu rendering
- Keyboard input handling (arrows, enter, escape)
- Cancellation and error handling
- Model details rendering
"""

from unittest.mock import Mock, patch

import pytest
import readchar

from mnemofy.tui.model_menu import (
    is_interactive_environment,
    ModelMenu,
)
from mnemofy.model_selector import ModelSpec


@pytest.fixture
def sample_models() -> list[ModelSpec]:
    """Create sample models for testing."""
    return [
        ModelSpec(
            name="tiny",
            min_ram_gb=1.0,
            min_vram_gb=1.0,
            speed_rating=5,
            quality_rating=2,
            description="Smallest model for fast transcription.",
        ),
        ModelSpec(
            name="base",
            min_ram_gb=1.5,
            min_vram_gb=1.5,
            speed_rating=5,
            quality_rating=3,
            description="Balanced model recommended for most users.",
        ),
        ModelSpec(
            name="medium",
            min_ram_gb=5.0,
            min_vram_gb=4.0,
            speed_rating=3,
            quality_rating=4,
            description="Larger model for better accuracy.",
        ),
    ]


@pytest.fixture
def mock_resources():
    """Create mock SystemResources."""
    resources = Mock()
    resources.cpu_cores = 4
    resources.cpu_arch = "arm64"
    resources.total_ram_gb = 8.0
    resources.available_ram_gb = 6.0
    resources.has_gpu = True
    resources.gpu_type = "metal"
    resources.available_vram_gb = None  # Unified memory
    return resources


class TestIsInteractiveEnvironment:
    """Tests for is_interactive_environment function."""

    def test_interactive_environment_both_tty(self):
        """Test returns True when both stdin and stdout are TTY."""
        with patch("sys.stdin.isatty", return_value=True), \
             patch("sys.stdout.isatty", return_value=True):
            assert is_interactive_environment() is True

    def test_stdin_not_tty(self):
        """Test returns False when stdin is not TTY."""
        with patch("sys.stdin.isatty", return_value=False), \
             patch("sys.stdout.isatty", return_value=True):
            assert is_interactive_environment() is False

    def test_stdout_not_tty(self):
        """Test returns False when stdout is not TTY."""
        with patch("sys.stdin.isatty", return_value=True), \
             patch("sys.stdout.isatty", return_value=False):
            assert is_interactive_environment() is False

    def test_neither_tty(self):
        """Test returns False when neither stdin nor stdout is TTY."""
        with patch("sys.stdin.isatty", return_value=False), \
             patch("sys.stdout.isatty", return_value=False):
            assert is_interactive_environment() is False

    def test_ci_environment_piped_input(self):
        """Test detects CI environment with piped input."""
        # Simulates CI where stdin is piped from a file
        with patch("sys.stdin.isatty", return_value=False), \
             patch("sys.stdout.isatty", return_value=False):
            assert is_interactive_environment() is False


class TestModelMenuInitialization:
    """Tests for ModelMenu initialization."""

    def test_initialization_with_models(self, sample_models):
        """Test ModelMenu initializes with model list."""
        menu = ModelMenu(sample_models)
        assert menu.models == sample_models
        assert menu.selected_index == 0
        assert menu.recommended is None
        assert menu.resources is None

    def test_initialization_with_recommended(self, sample_models):
        """Test ModelMenu sets recommended model as default selection."""
        recommended = sample_models[1]  # base model
        menu = ModelMenu(sample_models, recommended=recommended)
        
        assert menu.recommended == recommended
        assert menu.selected_index == 1  # base is at index 1

    def test_initialization_with_resources(self, sample_models, mock_resources):
        """Test ModelMenu initializes with resource information."""
        menu = ModelMenu(sample_models, resources=mock_resources)
        
        assert menu.resources == mock_resources
        assert menu.resources.available_ram_gb == 6.0

    def test_empty_model_list(self):
        """Test ModelMenu handles empty model list gracefully."""
        menu = ModelMenu([])
        assert menu.models == []
        assert menu.selected_index == 0

    def test_recommended_not_in_list(self, sample_models):
        """Test ModelMenu handles recommended model not in list."""
        non_existent = ModelSpec(
            name="unknown", min_ram_gb=2.0, min_vram_gb=None,
            speed_rating=3, quality_rating=3, description="Not in list"
        )
        menu = ModelMenu(sample_models, recommended=non_existent)
        
        # Should not crash, keeps default index 0
        assert menu.selected_index == 0


class TestModelMenuShow:
    """Tests for ModelMenu.show() method."""

    def test_show_returns_none_for_empty_models(self):
        """Test show() returns None for empty model list."""
        menu = ModelMenu([])
        result = menu.show()
        assert result is None

    def test_show_keyboard_interrupt_returns_none(self, sample_models):
        """Test show() returns None on Ctrl+C."""
        menu = ModelMenu(sample_models)
        
        with patch.object(menu, "_run_menu_loop", side_effect=KeyboardInterrupt):
            result = menu.show()
            assert result is None

    def test_show_exception_caught(self, sample_models):
        """Test show() catches and handles exceptions gracefully."""
        menu = ModelMenu(sample_models)
        
        with patch.object(menu, "_run_menu_loop", 
                         side_effect=Exception("Test error")):
            result = menu.show()
            assert result is None

    @patch("mnemofy.tui.model_menu.readchar.readchar")
    def test_show_executes_menu_loop(self, mock_read, sample_models):
        """Test show() calls _run_menu_loop."""
        menu = ModelMenu(sample_models)
        
        with patch.object(menu, "_run_menu_loop", return_value=sample_models[0]):
            result = menu.show()
            assert result == sample_models[0]


class TestModelMenuNavigation:
    """Tests for menu navigation with keyboard input."""

    @patch("mnemofy.tui.model_menu.readchar.readchar")
    @patch("mnemofy.tui.model_menu.readchar.key")
    def test_down_arrow_navigation(self, mock_key, mock_read, sample_models):
        """Test down arrow key advances selection."""
        menu = ModelMenu(sample_models)
        mock_key.DOWN = '\x1b[B'  # ANSI down arrow code
        mock_read.side_effect = ['\x1b[B', '\r']  # Down then Enter
        
        result = menu._run_menu_loop()
        # Should select base (index 1)
        assert result == sample_models[1]

    def test_navigation_wraparound_down(self, sample_models):
        """Test down arrow wraps from last to first model."""
        menu = ModelMenu(sample_models)
        menu.selected_index = len(sample_models) - 1  # Last index
        
        # Simulate down arrow
        new_index = (menu.selected_index + 1) % len(sample_models)
        assert new_index == 0  # Should wrap to beginning

    def test_navigation_wraparound_up(self, sample_models):
        """Test up arrow wraps from first to last model."""
        menu = ModelMenu(sample_models)
        menu.selected_index = 0  # First index
        
        # Simulate up arrow
        new_index = (menu.selected_index - 1) % len(sample_models)
        assert new_index == len(sample_models) - 1  # Should wrap to end


class TestModelMenuRendering:
    """Tests for menu rendering output."""

    def test_render_menu_creates_console(self, sample_models):
        """Test _render_menu initializes console."""
        menu = ModelMenu(sample_models)
        
        with patch.object(menu.console, "clear"), \
             patch.object(menu.console, "print"):
            menu._render_menu()
            menu.console.clear.assert_called_once()

    def test_render_menu_with_resources(self, sample_models, mock_resources):
        """Test _render_menu displays resource information."""
        menu = ModelMenu(sample_models, resources=mock_resources)
        
        with patch.object(menu.console, "clear"), \
             patch.object(menu.console, "print") as mock_print:
            menu._render_menu()
            
            # Should print resource info
            assert mock_print.call_count > 0

    def test_render_model_details(self, sample_models):
        """Test _render_model_details includes model info."""
        menu = ModelMenu(sample_models)
        model = sample_models[0]
        
        with patch.object(menu.console, "print"):
            menu._render_model_details(model)
            menu.console.print.assert_called()

    def test_render_details_warning_for_insufficient_ram(self, sample_models, mock_resources):
        """Test details show warning if model won't fit in RAM."""
        mock_resources.available_ram_gb = 0.5  # Only 0.5GB available
        menu = ModelMenu(sample_models, resources=mock_resources)
        
        # tiny requires 1.0GB, so won't fit in 0.5GB * 0.85 = 0.425GB
        with patch.object(menu.console, "print"):
            menu._render_model_details(sample_models[0])
            # Should include warning in rendered output


class TestModelMenuComparison:
    """Tests for model comparison table rendering."""

    def test_table_headers(self, sample_models):
        """Test model table includes expected columns."""
        menu = ModelMenu(sample_models)
        # Table should have: marker, name, speed, quality, RAM
        # Hard to test exact table without rendering, but structure is tested

    def test_speed_quality_visualization(self, sample_models):
        """Test speed and quality are visualized as bars."""
        ModelMenu(sample_models)
        
        # tiny: speed_rating=5, quality_rating=2
        # Expected: "█████" for speed, "██░░░" for quality
        assert "█" in "█" * sample_models[0].speed_rating
        assert "░" in "░" * (5 - sample_models[0].quality_rating)


class TestModelMenuSelection:
    """Tests for model selection logic."""

    def test_default_selection_first_model(self, sample_models):
        """Test first model is selected by default."""
        menu = ModelMenu(sample_models)
        assert menu.selected_index == 0
        assert menu.models[menu.selected_index] == sample_models[0]

    def test_recommended_model_preselected(self, sample_models):
        """Test recommended model is preselected."""
        recommended = sample_models[2]  # medium
        menu = ModelMenu(sample_models, recommended=recommended)
        
        assert menu.selected_index == 2
        assert menu.models[menu.selected_index] == recommended

    def test_current_selection_available(self, sample_models):
        """Test current selection is always accessible."""
        menu = ModelMenu(sample_models)
        
        for i in range(len(sample_models)):
            menu.selected_index = i
            assert menu.models[menu.selected_index] is not None


class TestModelMenuResourceWarnings:
    """Tests for resource constraint warnings."""

    def test_warning_for_tight_ram_margin(self, sample_models, mock_resources):
        """Test warning shows when model has tight RAM margin."""
        mock_resources.available_ram_gb = 2.0  # 2GB available, 1.7GB with 85% margin
        menu = ModelMenu(sample_models, resources=mock_resources)
        
        # medium requires 5GB, won't fit
        # base requires 1.5GB, will fit but tight
        assert menu.models[1].min_ram_gb < mock_resources.available_ram_gb * 0.85

    def test_no_warning_for_adequate_resources(self, sample_models, mock_resources):
        """Test no warning when resources are adequate."""
        mock_resources.available_ram_gb = 10.0  # Plenty of RAM
        menu = ModelMenu(sample_models, resources=mock_resources)
        
        safe_ram = mock_resources.available_ram_gb * 0.85
        # all models should fit comfortably
        for model in sample_models:
            if model.min_ram_gb < 10.0:  # Only tiny, base fit
                assert model.min_ram_gb < safe_ram

    def test_cuda_vram_warning(self, sample_models, mock_resources):
        """Test VRAM warning for CUDA GPU models."""
        mock_resources.gpu_type = "cuda"
        mock_resources.available_vram_gb = 2.0
        menu = ModelMenu(sample_models, resources=mock_resources)
        assert menu is not None
        
        # medium requires 4GB VRAM, won't fit in 2GB
        assert sample_models[2].min_vram_gb > mock_resources.available_vram_gb


class TestModelMenuGracefulFallback:
    """Tests for graceful fallback and error handling."""

    def test_initialization_handles_missing_recommended(self, sample_models):
        """Test initialization handles recommended model not in list."""
        fake_recommended = ModelSpec(
            name="nonexistent", min_ram_gb=1.0, min_vram_gb=None,
            speed_rating=3, quality_rating=3, description="Not real"
        )
        
        # Should not crash
        menu = ModelMenu(sample_models, recommended=fake_recommended)
        assert menu.selected_index == 0  # Falls back to first

    def test_show_with_keyboard_interrupt(self, sample_models):
        """Test graceful handling of Ctrl+C during display."""
        menu = ModelMenu(sample_models)
        
        with patch.object(menu, "_run_menu_loop", side_effect=KeyboardInterrupt):
            result = menu.show()
            assert result is None

    def test_show_with_runtime_error(self, sample_models):
        """Test graceful handling of runtime errors."""
        menu = ModelMenu(sample_models)
        
        with patch.object(menu, "_run_menu_loop", 
                         side_effect=RuntimeError("Display error")):
            result = menu.show()
            assert result is None


class TestModelMenuIntegration:
    """Integration tests for complete menu workflow."""

    def test_menu_with_all_parameters(self, sample_models, mock_resources):
        """Test menu initialization with all parameters."""
        recommended = sample_models[1]
        menu = ModelMenu(sample_models, recommended=recommended, resources=mock_resources)
        
        assert menu.models == sample_models
        assert menu.recommended == recommended
        assert menu.resources == mock_resources
        assert menu.selected_index == 1

    def test_menu_state_persistence(self, sample_models):
        """Test menu state persists across operations."""
        menu = ModelMenu(sample_models)
        menu.selected_index = 2
        
        # State should persist
        assert menu.selected_index == 2
        assert menu.models[menu.selected_index] == sample_models[2]
