"""
Tests for idle game eviction functionality.

Verifies that the StateManager properly evicts idle games to prevent
memory leaks and OOM conditions from abandoned games.

Author: Claude
Date: 2025-01-27
"""

import pytest
from uuid import uuid4
from unittest.mock import patch, AsyncMock, MagicMock

import pendulum


# ============================================================================
# EVICT IDLE GAMES TESTS
# ============================================================================


class TestEvictIdleGames:
    """Tests for the evict_idle_games method."""

    @pytest.fixture
    def fresh_state_manager(self):
        """
        Create a fresh StateManager instance for each test.

        Using a fresh instance avoids state pollution from other tests
        that might use the singleton.
        """
        from app.core.state_manager import StateManager

        return StateManager()

    @pytest.fixture
    def mock_game_state(self):
        """Create a minimal mock game state."""
        state = MagicMock()
        state.status = "active"
        state.inning = 5
        state.half = "top"
        state.home_score = 3
        state.away_score = 2
        state.outs = 1
        return state

    @pytest.mark.asyncio
    async def test_evict_idle_games_removes_old_games(
        self, fresh_state_manager, mock_game_state
    ):
        """
        Verify games idle beyond the timeout threshold are evicted.

        Games that haven't been accessed in more than the configured idle
        timeout (default 24 hours) should be removed from memory.
        """
        game_id = uuid4()

        # Add game with old timestamp (25 hours ago)
        fresh_state_manager._states[game_id] = mock_game_state
        fresh_state_manager._last_access[game_id] = pendulum.now("UTC").subtract(
            hours=25
        )

        # Mock the settings to use 24 hour timeout
        with patch("app.config.get_settings") as mock_settings:
            settings = MagicMock()
            settings.game_idle_timeout_hours = 24
            mock_settings.return_value = settings

            # Mock _evict_game to avoid DB operations
            with patch.object(
                fresh_state_manager, "_evict_game", new_callable=AsyncMock
            ) as mock_evict:
                evicted = await fresh_state_manager.evict_idle_games()

                assert game_id in evicted
                mock_evict.assert_called_once_with(game_id)

    @pytest.mark.asyncio
    async def test_evict_idle_games_keeps_active_games(
        self, fresh_state_manager, mock_game_state
    ):
        """
        Verify recently accessed games are NOT evicted.

        Games that have been accessed within the timeout window should
        remain in memory.
        """
        game_id = uuid4()

        # Add game with recent timestamp (1 hour ago)
        fresh_state_manager._states[game_id] = mock_game_state
        fresh_state_manager._last_access[game_id] = pendulum.now("UTC").subtract(hours=1)

        with patch("app.config.get_settings") as mock_settings:
            settings = MagicMock()
            settings.game_idle_timeout_hours = 24
            mock_settings.return_value = settings

            evicted = await fresh_state_manager.evict_idle_games()

            assert game_id not in evicted
            assert game_id in fresh_state_manager._states

    @pytest.mark.asyncio
    async def test_evict_idle_games_returns_empty_when_none_idle(
        self, fresh_state_manager, mock_game_state
    ):
        """
        Verify empty list returned when no games are idle.

        When all games are actively being used, no evictions should occur.
        """
        # Add multiple active games
        for i in range(5):
            game_id = uuid4()
            fresh_state_manager._states[game_id] = mock_game_state
            fresh_state_manager._last_access[game_id] = pendulum.now("UTC").subtract(
                minutes=i * 10
            )

        with patch("app.config.get_settings") as mock_settings:
            settings = MagicMock()
            settings.game_idle_timeout_hours = 24
            mock_settings.return_value = settings

            evicted = await fresh_state_manager.evict_idle_games()

            assert evicted == []
            assert len(fresh_state_manager._states) == 5


# ============================================================================
# EVICT GAME TESTS
# ============================================================================


class TestEvictGame:
    """Tests for the _evict_game method."""

    @pytest.fixture
    def fresh_state_manager(self):
        """Create a fresh StateManager instance."""
        from app.core.state_manager import StateManager

        return StateManager()

    @pytest.fixture
    def mock_game_state(self):
        """Create a minimal mock game state."""
        state = MagicMock()
        state.status = "active"
        state.inning = 5
        state.half = "top"
        state.home_score = 3
        state.away_score = 2
        state.outs = 1
        return state

    @pytest.mark.asyncio
    async def test_evict_game_persists_state_before_removal(
        self, fresh_state_manager, mock_game_state
    ):
        """
        Verify game state is persisted to database before eviction.

        This ensures no data loss when games are evicted from memory.
        """
        game_id = uuid4()
        fresh_state_manager._states[game_id] = mock_game_state
        fresh_state_manager._last_access[game_id] = pendulum.now("UTC")

        # Mock the DB operations
        fresh_state_manager.db_ops.update_game_state = AsyncMock()

        await fresh_state_manager._evict_game(game_id)

        # Verify DB update was called with correct values
        fresh_state_manager.db_ops.update_game_state.assert_called_once_with(
            game_id=game_id,
            status=mock_game_state.status,
            current_inning=mock_game_state.inning,
            current_half=mock_game_state.half,
            home_score=mock_game_state.home_score,
            away_score=mock_game_state.away_score,
            outs=mock_game_state.outs,
        )

    @pytest.mark.asyncio
    async def test_evict_game_removes_from_all_dicts(
        self, fresh_state_manager, mock_game_state
    ):
        """
        Verify eviction removes game from all tracking dictionaries.

        Game should be removed from _states, _lineups, _last_access, and _game_locks.
        """
        game_id = uuid4()
        fresh_state_manager._states[game_id] = mock_game_state
        fresh_state_manager._lineups[game_id] = {}
        fresh_state_manager._last_access[game_id] = pendulum.now("UTC")
        fresh_state_manager._game_locks[game_id] = MagicMock()

        # Mock DB operations
        fresh_state_manager.db_ops.update_game_state = AsyncMock()

        await fresh_state_manager._evict_game(game_id)

        assert game_id not in fresh_state_manager._states
        assert game_id not in fresh_state_manager._lineups
        assert game_id not in fresh_state_manager._last_access
        assert game_id not in fresh_state_manager._game_locks

    @pytest.mark.asyncio
    async def test_evict_game_continues_on_db_failure(
        self, fresh_state_manager, mock_game_state
    ):
        """
        Verify eviction proceeds even if database persist fails.

        Memory cleanup should happen even if DB write fails - data can be
        recovered from last successful DB write.
        """
        game_id = uuid4()
        fresh_state_manager._states[game_id] = mock_game_state
        fresh_state_manager._last_access[game_id] = pendulum.now("UTC")

        # Mock DB operations to raise exception
        fresh_state_manager.db_ops.update_game_state = AsyncMock(
            side_effect=Exception("DB connection lost")
        )

        # Should not raise
        await fresh_state_manager._evict_game(game_id)

        # Game should still be removed from memory
        assert game_id not in fresh_state_manager._states


# ============================================================================
# ENFORCE MEMORY LIMIT TESTS
# ============================================================================


class TestEnforceMemoryLimit:
    """Tests for the enforce_memory_limit method."""

    @pytest.fixture
    def fresh_state_manager(self):
        """Create a fresh StateManager instance."""
        from app.core.state_manager import StateManager

        return StateManager()

    @pytest.fixture
    def mock_game_state(self):
        """Create a minimal mock game state."""
        state = MagicMock()
        state.status = "active"
        state.inning = 1
        state.half = "top"
        state.home_score = 0
        state.away_score = 0
        state.outs = 0
        return state

    @pytest.mark.asyncio
    async def test_enforce_memory_limit_evicts_oldest_games(
        self, fresh_state_manager, mock_game_state
    ):
        """
        Verify oldest games are evicted when memory limit is exceeded.

        Games are evicted in order of last access (oldest first) until
        the count is at or below the limit.
        """
        game_ids = []

        # Create 10 games at different times
        for i in range(10):
            game_id = uuid4()
            game_ids.append(game_id)
            fresh_state_manager._states[game_id] = mock_game_state
            fresh_state_manager._last_access[game_id] = pendulum.now("UTC").subtract(
                hours=i
            )

        # Mock settings with limit of 5
        with patch("app.config.get_settings") as mock_settings:
            settings = MagicMock()
            settings.game_max_in_memory = 5
            mock_settings.return_value = settings

            # Mock _evict_game to just remove from dicts
            async def mock_evict(game_id):
                fresh_state_manager._states.pop(game_id, None)
                fresh_state_manager._last_access.pop(game_id, None)

            with patch.object(
                fresh_state_manager, "_evict_game", side_effect=mock_evict
            ):
                evicted = await fresh_state_manager.enforce_memory_limit()

                # Should evict 5 oldest games
                assert len(evicted) == 5
                assert len(fresh_state_manager._states) == 5

    @pytest.mark.asyncio
    async def test_enforce_memory_limit_noop_when_under_limit(
        self, fresh_state_manager, mock_game_state
    ):
        """
        Verify no evictions when game count is under the limit.
        """
        # Create 3 games
        for i in range(3):
            game_id = uuid4()
            fresh_state_manager._states[game_id] = mock_game_state
            fresh_state_manager._last_access[game_id] = pendulum.now("UTC")

        # Mock settings with limit of 10
        with patch("app.config.get_settings") as mock_settings:
            settings = MagicMock()
            settings.game_max_in_memory = 10
            mock_settings.return_value = settings

            evicted = await fresh_state_manager.enforce_memory_limit()

            assert evicted == []
            assert len(fresh_state_manager._states) == 3


# ============================================================================
# MEMORY STATS TESTS
# ============================================================================


class TestGetMemoryStats:
    """Tests for the get_memory_stats method."""

    @pytest.fixture
    def fresh_state_manager(self):
        """Create a fresh StateManager instance."""
        from app.core.state_manager import StateManager

        return StateManager()

    @pytest.fixture
    def mock_game_state(self):
        """Create a minimal mock game state."""
        return MagicMock()

    def test_get_memory_stats_returns_correct_structure(self, fresh_state_manager):
        """
        Verify get_memory_stats returns expected dictionary structure.
        """
        with patch("app.config.get_settings") as mock_settings:
            settings = MagicMock()
            settings.game_max_in_memory = 500
            mock_settings.return_value = settings

            stats = fresh_state_manager.get_memory_stats()

            assert "active_games" in stats
            assert "max_games" in stats
            assert "oldest_game_hours" in stats
            assert "total_lineups_cached" in stats
            assert "total_locks" in stats

    def test_get_memory_stats_reports_correct_counts(
        self, fresh_state_manager, mock_game_state
    ):
        """
        Verify stats accurately reflect current state.
        """
        # Add some games
        for i in range(5):
            game_id = uuid4()
            fresh_state_manager._states[game_id] = mock_game_state
            fresh_state_manager._lineups[game_id] = {1: MagicMock(), 2: MagicMock()}
            fresh_state_manager._last_access[game_id] = pendulum.now("UTC")
            fresh_state_manager._game_locks[game_id] = MagicMock()

        with patch("app.config.get_settings") as mock_settings:
            settings = MagicMock()
            settings.game_max_in_memory = 500
            mock_settings.return_value = settings

            stats = fresh_state_manager.get_memory_stats()

            assert stats["active_games"] == 5
            assert stats["max_games"] == 500
            assert stats["total_lineups_cached"] == 10  # 5 games × 2 lineups
            assert stats["total_locks"] == 5

    def test_get_oldest_game_age_hours_returns_zero_when_empty(
        self, fresh_state_manager
    ):
        """
        Verify 0.0 is returned when no games exist.
        """
        age = fresh_state_manager._get_oldest_game_age_hours()
        assert age == 0.0

    def test_get_oldest_game_age_hours_calculates_correctly(
        self, fresh_state_manager, mock_game_state
    ):
        """
        Verify oldest game age is calculated correctly.
        """
        game_id = uuid4()
        fresh_state_manager._states[game_id] = mock_game_state
        fresh_state_manager._last_access[game_id] = pendulum.now("UTC").subtract(hours=5)

        age = fresh_state_manager._get_oldest_game_age_hours()

        # Should be approximately 5 hours (with small tolerance for test execution time)
        assert 4.9 < age < 5.1
