"""Tests for GameService.

This module tests the game service layer that orchestrates between
WebSocket communication and the core GameEngine.

The GameService is STATELESS regarding game rules:
- No GameEngine is stored in the service
- Engine is created per-operation using rules from GameState
- Rules come from frontend at game creation, stored in GameState

Uses dependency injection pattern - no monkey patching required.
"""

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

import pytest

from app.core.engine import ActionResult
from app.core.enums import GameEndReason, TurnPhase
from app.core.models.actions import AttackAction, PassAction, ResignAction, SelectActiveAction
from app.core.models.game_state import ForcedAction, GameState, PlayerState
from app.core.win_conditions import WinResult
from app.services.game_service import (
    CannotSpectateOwnGameError,
    ForcedActionRequiredError,
    GameAlreadyEndedError,
    GameCreationError,
    GameNotFoundError,
    GameService,
    InvalidActionError,
    NotPlayerTurnError,
    PlayerNotInGameError,
)


@pytest.fixture
def mock_state_manager() -> AsyncMock:
    """Create a mock GameStateManager.

    The state manager handles persistence to Redis (cache) and
    Postgres (durable storage).
    """
    from app.db.models.game import GameType
    from app.services.game_state_manager import ArchiveResult

    manager = AsyncMock()
    manager.load_state = AsyncMock(return_value=None)
    manager.save_to_cache = AsyncMock()
    manager.persist_to_db = AsyncMock()
    manager.cache_exists = AsyncMock(return_value=False)
    manager.archive_to_history = AsyncMock(
        return_value=ArchiveResult(
            success=True,
            game_id="game-123",
            history_id="game-123",
            duration_seconds=300,
            turn_count=10,
            game_type=GameType.FREEPLAY,
        )
    )
    return manager


@pytest.fixture
def mock_card_service() -> MagicMock:
    """Create a mock CardService.

    CardService provides card definitions for game creation.
    """
    return MagicMock()


@pytest.fixture
def mock_engine() -> MagicMock:
    """Create a mock GameEngine.

    The engine handles game logic - executing actions and checking
    win conditions.
    """
    engine = MagicMock()
    engine.execute_action = AsyncMock(
        return_value=ActionResult(success=True, message="Action executed")
    )
    return engine


@pytest.fixture
def mock_timeout_service() -> AsyncMock:
    """Create a mock TurnTimeoutService.

    The timeout service manages turn timers using Redis.
    For tests, we mock all Redis interactions.
    """
    from app.services.turn_timeout_service import TurnTimeoutInfo

    service = AsyncMock()
    service.start_turn_timer = AsyncMock(
        return_value=TurnTimeoutInfo(
            game_id="game-123",
            player_id="player-1",
            deadline=0.0,
            timeout_seconds=180,
            remaining_seconds=180,
            warnings_sent=[],
            warning_thresholds=[50, 25],
        )
    )
    service.cancel_timer = AsyncMock(return_value=True)
    service.extend_timer = AsyncMock(return_value=None)
    service.get_timeout_info = AsyncMock(return_value=None)
    return service


@pytest.fixture
def game_service(
    mock_state_manager: AsyncMock,
    mock_card_service: MagicMock,
    mock_engine: MagicMock,
    mock_timeout_service: AsyncMock,
) -> GameService:
    """Create a GameService with injected mock dependencies.

    The mock engine factory is injected via the engine_factory parameter,
    eliminating the need for monkey patching in tests.
    """
    return GameService(
        state_manager=mock_state_manager,
        card_service=mock_card_service,
        timeout_service=mock_timeout_service,
        engine_factory=lambda game: mock_engine,
    )


@pytest.fixture
def sample_game_state() -> GameState:
    """Create a sample game state for testing.

    The game state includes two players and basic turn tracking.
    The rules are stored in the state itself (default RulesConfig).
    """
    player1 = PlayerState(player_id="player-1")
    player2 = PlayerState(player_id="player-2")

    return GameState(
        game_id="game-123",
        players={"player-1": player1, "player-2": player2},
        current_player_id="player-1",
        turn_number=1,
        phase=TurnPhase.MAIN,
    )


class TestGameStateAccess:
    """Tests for game state access methods."""

    @pytest.mark.asyncio
    async def test_get_game_state_returns_state(
        self,
        game_service: GameService,
        mock_state_manager: AsyncMock,
        sample_game_state: GameState,
    ) -> None:
        """Test that get_game_state returns the game state when found.

        The state manager should be called to load the state, and the
        result should be returned to the caller.
        """
        mock_state_manager.load_state.return_value = sample_game_state

        result = await game_service.get_game_state("game-123")

        assert result == sample_game_state
        mock_state_manager.load_state.assert_called_once_with("game-123")

    @pytest.mark.asyncio
    async def test_get_game_state_raises_not_found(
        self,
        game_service: GameService,
        mock_state_manager: AsyncMock,
    ) -> None:
        """Test that get_game_state raises GameNotFoundError when not found.

        When the state manager returns None, we should raise a specific
        exception rather than returning None.
        """
        mock_state_manager.load_state.return_value = None

        with pytest.raises(GameNotFoundError) as exc_info:
            await game_service.get_game_state("nonexistent")

        assert exc_info.value.game_id == "nonexistent"

    @pytest.mark.asyncio
    async def test_get_player_view_returns_visible_state(
        self,
        game_service: GameService,
        mock_state_manager: AsyncMock,
        sample_game_state: GameState,
    ) -> None:
        """Test that get_player_view returns visibility-filtered state.

        The returned state should be filtered for the requesting player,
        hiding the opponent's private information.
        """
        mock_state_manager.load_state.return_value = sample_game_state

        result = await game_service.get_player_view("game-123", "player-1")

        assert result.game_id == "game-123"
        assert result.viewer_id == "player-1"
        assert result.is_my_turn is True

    @pytest.mark.asyncio
    async def test_get_player_view_raises_not_in_game(
        self,
        game_service: GameService,
        mock_state_manager: AsyncMock,
        sample_game_state: GameState,
    ) -> None:
        """Test that get_player_view raises error for non-participants.

        Only players in the game should be able to view its state.
        """
        mock_state_manager.load_state.return_value = sample_game_state

        with pytest.raises(PlayerNotInGameError) as exc_info:
            await game_service.get_player_view("game-123", "stranger")

        assert exc_info.value.player_id == "stranger"

    @pytest.mark.asyncio
    async def test_is_player_turn_returns_true_for_current_player(
        self,
        game_service: GameService,
        mock_state_manager: AsyncMock,
        sample_game_state: GameState,
    ) -> None:
        """Test that is_player_turn returns True for the current player.

        The current player is determined by the game state's current_player_id.
        """
        mock_state_manager.load_state.return_value = sample_game_state

        result = await game_service.is_player_turn("game-123", "player-1")

        assert result is True

    @pytest.mark.asyncio
    async def test_is_player_turn_returns_false_for_other_player(
        self,
        game_service: GameService,
        mock_state_manager: AsyncMock,
        sample_game_state: GameState,
    ) -> None:
        """Test that is_player_turn returns False for non-current player.

        Players who are not the current player should get False.
        """
        mock_state_manager.load_state.return_value = sample_game_state

        result = await game_service.is_player_turn("game-123", "player-2")

        assert result is False

    @pytest.mark.asyncio
    async def test_game_exists_returns_true_when_cached(
        self,
        game_service: GameService,
        mock_state_manager: AsyncMock,
    ) -> None:
        """Test that game_exists returns True when game is in cache.

        This is a quick check that doesn't need to load the full state.
        """
        mock_state_manager.cache_exists.return_value = True

        result = await game_service.game_exists("game-123")

        assert result is True
        mock_state_manager.cache_exists.assert_called_once_with("game-123")


class TestJoinGame:
    """Tests for the join_game method."""

    @pytest.mark.asyncio
    async def test_join_game_success(
        self,
        game_service: GameService,
        mock_state_manager: AsyncMock,
        sample_game_state: GameState,
    ) -> None:
        """Test successful game join returns visible state.

        When a player joins their game, they should receive the
        visibility-filtered state and know if it's their turn.
        """
        mock_state_manager.load_state.return_value = sample_game_state

        result = await game_service.join_game("game-123", "player-1")

        assert result.success is True
        assert result.game_id == "game-123"
        assert result.player_id == "player-1"
        assert result.visible_state is not None
        assert result.is_your_turn is True

    @pytest.mark.asyncio
    async def test_join_game_not_your_turn(
        self,
        game_service: GameService,
        mock_state_manager: AsyncMock,
        sample_game_state: GameState,
    ) -> None:
        """Test that is_your_turn is False when it's opponent's turn.

        The second player should see is_your_turn=False when the first
        player is the current player.
        """
        mock_state_manager.load_state.return_value = sample_game_state

        result = await game_service.join_game("game-123", "player-2")

        assert result.success is True
        assert result.is_your_turn is False

    @pytest.mark.asyncio
    async def test_join_game_not_found(
        self,
        game_service: GameService,
        mock_state_manager: AsyncMock,
    ) -> None:
        """Test join_game returns failure for non-existent game.

        Rather than raising an exception, join_game returns a failed
        result for better WebSocket error handling.
        """
        mock_state_manager.load_state.return_value = None

        result = await game_service.join_game("nonexistent", "player-1")

        assert result.success is False
        assert "not found" in result.message.lower()

    @pytest.mark.asyncio
    async def test_join_game_not_participant(
        self,
        game_service: GameService,
        mock_state_manager: AsyncMock,
        sample_game_state: GameState,
    ) -> None:
        """Test join_game returns failure for non-participants.

        Players who are not in the game should not be able to join.
        """
        mock_state_manager.load_state.return_value = sample_game_state

        result = await game_service.join_game("game-123", "stranger")

        assert result.success is False
        assert "not a participant" in result.message.lower()

    @pytest.mark.asyncio
    async def test_join_ended_game(
        self,
        game_service: GameService,
        mock_state_manager: AsyncMock,
        sample_game_state: GameState,
    ) -> None:
        """Test joining an ended game still succeeds but indicates game over.

        Players should be able to rejoin ended games to see the final
        state, but is_your_turn should be False and game_over should be True.
        """
        sample_game_state.winner_id = "player-1"
        sample_game_state.end_reason = GameEndReason.PRIZES_TAKEN
        mock_state_manager.load_state.return_value = sample_game_state

        result = await game_service.join_game("game-123", "player-2")

        assert result.success is True
        assert result.is_your_turn is False
        assert result.game_over is True
        assert "ended" in result.message.lower()

    @pytest.mark.asyncio
    async def test_join_game_includes_pending_forced_action(
        self,
        game_service: GameService,
        mock_state_manager: AsyncMock,
        sample_game_state: GameState,
    ) -> None:
        """Test that join_game includes pending forced action in result.

        When a player rejoins mid-game and there's a pending forced action
        (e.g., select new active after KO), the result should include that
        information so the client knows what action is required.
        """
        # Add forced action to the game state
        sample_game_state.forced_actions = [
            ForcedAction(
                player_id="player-1",
                action_type="select_active",
                reason="Your active Pokemon was knocked out. Select a new active.",
                params={"available_bench_ids": ["bench-1", "bench-2"]},
            )
        ]
        mock_state_manager.load_state.return_value = sample_game_state

        result = await game_service.join_game("game-123", "player-1")

        assert result.success is True
        assert result.pending_forced_action is not None
        assert result.pending_forced_action.player_id == "player-1"
        assert result.pending_forced_action.action_type == "select_active"

    @pytest.mark.asyncio
    async def test_join_game_forced_action_sets_is_your_turn(
        self,
        game_service: GameService,
        mock_state_manager: AsyncMock,
        sample_game_state: GameState,
    ) -> None:
        """Test that is_your_turn is True when player has forced action.

        Even if it's technically the opponent's turn, if this player has
        a forced action pending, is_your_turn should be True so they know
        they must act.
        """
        # Set current player to player-2, but forced action is for player-1
        sample_game_state.current_player_id = "player-2"
        sample_game_state.forced_actions = [
            ForcedAction(
                player_id="player-1",
                action_type="select_active",
                reason="Your active Pokemon was knocked out.",
            )
        ]
        mock_state_manager.load_state.return_value = sample_game_state

        result = await game_service.join_game("game-123", "player-1")

        assert result.success is True
        # Player-1 has forced action, so it's their turn to act
        assert result.is_your_turn is True
        assert result.pending_forced_action is not None

    @pytest.mark.asyncio
    async def test_join_game_active_game_not_over(
        self,
        game_service: GameService,
        mock_state_manager: AsyncMock,
        sample_game_state: GameState,
    ) -> None:
        """Test that game_over is False for active games.

        When joining an ongoing game, game_over should be False.
        """
        mock_state_manager.load_state.return_value = sample_game_state

        result = await game_service.join_game("game-123", "player-1")

        assert result.success is True
        assert result.game_over is False


class TestExecuteAction:
    """Tests for the execute_action method.

    These tests verify action execution through GameService. The mock engine
    is injected via the engine_factory parameter in the game_service fixture.
    """

    @pytest.mark.asyncio
    async def test_execute_action_success(
        self,
        game_service: GameService,
        mock_state_manager: AsyncMock,
        mock_engine: MagicMock,
        sample_game_state: GameState,
    ) -> None:
        """Test successful action execution.

        A valid action by the current player should be executed and
        the state should be saved to cache.
        """
        mock_state_manager.load_state.return_value = sample_game_state
        mock_engine.execute_action.return_value = ActionResult(
            success=True,
            message="Attack executed",
            state_changes=[{"type": "damage", "amount": 30}],
        )

        action = AttackAction(attack_index=0)
        result = await game_service.execute_action("game-123", "player-1", action)

        assert result.success is True
        assert result.action_type == "attack"
        mock_state_manager.save_to_cache.assert_called_once()

    @pytest.mark.asyncio
    async def test_execute_action_game_not_found(
        self,
        game_service: GameService,
        mock_state_manager: AsyncMock,
    ) -> None:
        """Test execute_action raises error when game not found.

        Missing games should raise GameNotFoundError for proper
        error handling in the WebSocket layer.
        """
        mock_state_manager.load_state.return_value = None

        with pytest.raises(GameNotFoundError):
            await game_service.execute_action("nonexistent", "player-1", PassAction())

    @pytest.mark.asyncio
    async def test_execute_action_not_in_game(
        self,
        game_service: GameService,
        mock_state_manager: AsyncMock,
        sample_game_state: GameState,
    ) -> None:
        """Test execute_action raises error for non-participants.

        Only players in the game can execute actions.
        """
        mock_state_manager.load_state.return_value = sample_game_state

        with pytest.raises(PlayerNotInGameError):
            await game_service.execute_action("game-123", "stranger", PassAction())

    @pytest.mark.asyncio
    async def test_execute_action_game_ended(
        self,
        game_service: GameService,
        mock_state_manager: AsyncMock,
        sample_game_state: GameState,
    ) -> None:
        """Test execute_action raises error on ended games.

        No actions should be allowed once a game has ended.
        """
        sample_game_state.winner_id = "player-1"
        sample_game_state.end_reason = GameEndReason.RESIGNATION
        mock_state_manager.load_state.return_value = sample_game_state

        with pytest.raises(GameAlreadyEndedError):
            await game_service.execute_action("game-123", "player-1", PassAction())

    @pytest.mark.asyncio
    async def test_execute_action_not_your_turn(
        self,
        game_service: GameService,
        mock_state_manager: AsyncMock,
        sample_game_state: GameState,
    ) -> None:
        """Test execute_action raises error when not player's turn.

        Only the current player can execute actions.
        """
        mock_state_manager.load_state.return_value = sample_game_state

        with pytest.raises(NotPlayerTurnError) as exc_info:
            await game_service.execute_action("game-123", "player-2", PassAction())

        assert exc_info.value.player_id == "player-2"
        assert exc_info.value.current_player_id == "player-1"

    @pytest.mark.asyncio
    async def test_execute_action_resign_allowed_out_of_turn(
        self,
        game_service: GameService,
        mock_state_manager: AsyncMock,
        mock_engine: MagicMock,
        sample_game_state: GameState,
    ) -> None:
        """Test that resignation is allowed even when not your turn.

        Resignation is a special action that can be executed anytime
        by either player.
        """
        mock_state_manager.load_state.return_value = sample_game_state
        mock_engine.execute_action.return_value = ActionResult(
            success=True,
            message="Player resigned",
            win_result=WinResult(
                winner_id="player-1",
                loser_id="player-2",
                end_reason=GameEndReason.RESIGNATION,
                reason="Player resigned",
            ),
        )

        # player-2 resigns even though it's player-1's turn
        result = await game_service.execute_action("game-123", "player-2", ResignAction())

        assert result.success is True
        assert result.game_over is True
        assert result.winner_id == "player-1"

    @pytest.mark.asyncio
    async def test_execute_action_invalid_action(
        self,
        game_service: GameService,
        mock_state_manager: AsyncMock,
        mock_engine: MagicMock,
        sample_game_state: GameState,
    ) -> None:
        """Test execute_action raises error for invalid actions.

        When the GameEngine rejects an action, we should raise
        InvalidActionError with the reason.
        """
        mock_state_manager.load_state.return_value = sample_game_state
        mock_engine.execute_action.return_value = ActionResult(
            success=False,
            message="Not enough energy to attack",
        )

        with pytest.raises(InvalidActionError) as exc_info:
            await game_service.execute_action("game-123", "player-1", AttackAction(attack_index=0))

        assert "Not enough energy" in exc_info.value.reason

    @pytest.mark.asyncio
    async def test_execute_action_game_over(
        self,
        game_service: GameService,
        mock_state_manager: AsyncMock,
        mock_engine: MagicMock,
        sample_game_state: GameState,
    ) -> None:
        """Test execute_action detects game over and persists to DB.

        When an action results in a win, the state should be persisted
        to the database for durability.
        """
        mock_state_manager.load_state.return_value = sample_game_state
        mock_engine.execute_action.return_value = ActionResult(
            success=True,
            message="Final prize taken!",
            win_result=WinResult(
                winner_id="player-1",
                loser_id="player-2",
                end_reason=GameEndReason.PRIZES_TAKEN,
                reason="All prizes taken",
            ),
        )

        result = await game_service.execute_action(
            "game-123", "player-1", AttackAction(attack_index=0)
        )

        assert result.game_over is True
        assert result.winner_id == "player-1"
        assert result.end_reason == GameEndReason.PRIZES_TAKEN
        mock_state_manager.persist_to_db.assert_called_once()


class TestForcedActions:
    """Tests for forced action enforcement in execute_action.

    When a forced action is pending (e.g., select new active after KO),
    only the specified player can act and only with the specified action type.
    """

    @pytest.fixture
    def game_state_with_forced_action(self) -> GameState:
        """Create a game state with a pending forced action.

        The forced action requires player-1 to select a new active Pokemon,
        simulating the situation after their active was knocked out.
        """
        player1 = PlayerState(player_id="player-1")
        player2 = PlayerState(player_id="player-2")

        state = GameState(
            game_id="game-123",
            players={"player-1": player1, "player-2": player2},
            current_player_id="player-2",  # It's player-2's turn
            turn_number=3,
            phase=TurnPhase.MAIN,
        )

        # Add forced action for player-1 (select new active after KO)
        state.forced_actions = [
            ForcedAction(
                player_id="player-1",
                action_type="select_active",
                reason="Your active Pokemon was knocked out. Select a new active.",
                params={"available_bench_ids": ["bench-pokemon-1", "bench-pokemon-2"]},
            )
        ]

        return state

    @pytest.mark.asyncio
    async def test_forced_action_wrong_player_raises_error(
        self,
        game_service: GameService,
        mock_state_manager: AsyncMock,
        game_state_with_forced_action: GameState,
    ) -> None:
        """Test that wrong player attempting action during forced action raises error.

        When player-1 has a forced action pending, player-2 cannot act
        (even though it's player-2's normal turn) except for resignation.
        """
        mock_state_manager.load_state.return_value = game_state_with_forced_action

        # player-2 tries to pass, but player-1 has forced action pending
        with pytest.raises(NotPlayerTurnError) as exc_info:
            await game_service.execute_action("game-123", "player-2", PassAction())

        # Error should indicate player-1 must act (forced action player)
        assert exc_info.value.player_id == "player-2"
        assert exc_info.value.current_player_id == "player-1"

    @pytest.mark.asyncio
    async def test_forced_action_wrong_action_type_raises_error(
        self,
        game_service: GameService,
        mock_state_manager: AsyncMock,
        game_state_with_forced_action: GameState,
    ) -> None:
        """Test that wrong action type during forced action raises error.

        When a forced action requires 'select_active', attempting any other
        action type (like 'pass') should raise ForcedActionRequiredError.
        """
        mock_state_manager.load_state.return_value = game_state_with_forced_action

        # player-1 tries to pass instead of selecting active
        with pytest.raises(ForcedActionRequiredError) as exc_info:
            await game_service.execute_action("game-123", "player-1", PassAction())

        assert exc_info.value.required_action_type == "select_active"
        assert exc_info.value.attempted_action_type == "pass"

    @pytest.mark.asyncio
    async def test_forced_action_correct_action_succeeds(
        self,
        game_service: GameService,
        mock_state_manager: AsyncMock,
        mock_engine: MagicMock,
        game_state_with_forced_action: GameState,
    ) -> None:
        """Test that correct action during forced action succeeds.

        When player-1 submits the required select_active action,
        it should be executed successfully.
        """
        mock_state_manager.load_state.return_value = game_state_with_forced_action
        mock_engine.execute_action.return_value = ActionResult(
            success=True,
            message="New active Pokemon selected",
        )

        action = SelectActiveAction(pokemon_id="bench-pokemon-1")
        result = await game_service.execute_action("game-123", "player-1", action)

        assert result.success is True
        assert result.action_type == "select_active"

    @pytest.mark.asyncio
    async def test_resign_allowed_during_forced_action(
        self,
        game_service: GameService,
        mock_state_manager: AsyncMock,
        mock_engine: MagicMock,
        game_state_with_forced_action: GameState,
    ) -> None:
        """Test that resignation is always allowed, even during forced action.

        A player should always be able to resign, regardless of forced
        action state. This is a special exception to the forced action rule.
        """
        mock_state_manager.load_state.return_value = game_state_with_forced_action
        mock_engine.execute_action.return_value = ActionResult(
            success=True,
            message="Player resigned",
            win_result=WinResult(
                winner_id="player-2",
                loser_id="player-1",
                end_reason=GameEndReason.RESIGNATION,
                reason="Player resigned",
            ),
        )

        # player-1 resigns instead of selecting active
        result = await game_service.execute_action("game-123", "player-1", ResignAction())

        assert result.success is True
        assert result.game_over is True

    @pytest.mark.asyncio
    async def test_opponent_resign_allowed_during_forced_action(
        self,
        game_service: GameService,
        mock_state_manager: AsyncMock,
        mock_engine: MagicMock,
        game_state_with_forced_action: GameState,
    ) -> None:
        """Test that opponent can resign while other player has forced action.

        When player-1 has a forced action pending (select active), player-2
        should still be able to resign. Resignation is always allowed for
        any player at any time.
        """
        mock_state_manager.load_state.return_value = game_state_with_forced_action
        mock_engine.execute_action.return_value = ActionResult(
            success=True,
            message="Player resigned",
            win_result=WinResult(
                winner_id="player-1",
                loser_id="player-2",
                end_reason=GameEndReason.RESIGNATION,
                reason="Player resigned",
            ),
        )

        # player-2 resigns while player-1 has forced action pending
        result = await game_service.execute_action("game-123", "player-2", ResignAction())

        assert result.success is True
        assert result.game_over is True
        assert result.winner_id == "player-1"


class TestTurnBoundaryPersistence:
    """Tests for turn boundary detection and database persistence.

    The game state should be persisted to the database when the turn
    changes (either turn number or current player changes).
    """

    @pytest.mark.asyncio
    async def test_turn_change_triggers_db_persistence(
        self,
        game_service: GameService,
        mock_state_manager: AsyncMock,
        mock_engine: MagicMock,
        sample_game_state: GameState,
    ) -> None:
        """Test that turn change triggers database persistence.

        When an action causes the turn to change (e.g., pass action
        ends turn), the state should be persisted to the database
        for durability.
        """
        mock_state_manager.load_state.return_value = sample_game_state

        def mock_execute(state, player_id, action):
            # Simulate turn change: pass action ends turn
            state.current_player_id = "player-2"
            state.turn_number = 2
            return ActionResult(success=True, message="Turn ended")

        mock_engine.execute_action = AsyncMock(side_effect=mock_execute)

        result = await game_service.execute_action("game-123", "player-1", PassAction())

        assert result.success is True
        assert result.turn_changed is True
        assert result.current_player_id == "player-2"

        # DB persistence should have been called due to turn change
        mock_state_manager.persist_to_db.assert_called_once()

    @pytest.mark.asyncio
    async def test_no_turn_change_skips_db_persistence(
        self,
        game_service: GameService,
        mock_state_manager: AsyncMock,
        mock_engine: MagicMock,
        sample_game_state: GameState,
    ) -> None:
        """Test that actions without turn change don't persist to DB.

        For performance, we only persist to DB at turn boundaries.
        Actions within the same turn only update the cache.
        """
        mock_state_manager.load_state.return_value = sample_game_state
        mock_engine.execute_action.return_value = ActionResult(
            success=True,
            message="Attack executed",
            state_changes=[{"type": "damage", "amount": 30}],
        )

        result = await game_service.execute_action(
            "game-123", "player-1", AttackAction(attack_index=0)
        )

        assert result.success is True
        assert result.turn_changed is False

        # Cache should be updated, but DB should NOT be persisted
        mock_state_manager.save_to_cache.assert_called_once()
        mock_state_manager.persist_to_db.assert_not_called()

    @pytest.mark.asyncio
    async def test_current_player_change_is_turn_change(
        self,
        game_service: GameService,
        mock_state_manager: AsyncMock,
        mock_engine: MagicMock,
        sample_game_state: GameState,
    ) -> None:
        """Test that current player change counts as turn change.

        Turn boundaries are detected by either turn number change or
        current player change (e.g., forced action completion might
        return control to another player).
        """
        mock_state_manager.load_state.return_value = sample_game_state

        def mock_execute(state, player_id, action):
            # Only current player changes, turn number stays same
            state.current_player_id = "player-2"
            return ActionResult(success=True, message="Player changed")

        mock_engine.execute_action = AsyncMock(side_effect=mock_execute)

        result = await game_service.execute_action("game-123", "player-1", PassAction())

        assert result.turn_changed is True
        mock_state_manager.persist_to_db.assert_called_once()


class TestPendingForcedActionInResult:
    """Tests for pending forced action inclusion in GameActionResult.

    After executing an action, if there's a new forced action pending,
    it should be included in the result so the client knows what's next.
    """

    @pytest.mark.asyncio
    async def test_pending_forced_action_included_in_result(
        self,
        game_service: GameService,
        mock_state_manager: AsyncMock,
        mock_engine: MagicMock,
        sample_game_state: GameState,
    ) -> None:
        """Test that pending forced action is included in result.

        When an action results in a forced action being queued (e.g.,
        knockout triggers select_active), the result should include
        the pending forced action details for the client.
        """
        mock_state_manager.load_state.return_value = sample_game_state

        def mock_execute(state, player_id, action):
            # Action execution adds a forced action (simulating KO)
            state.forced_actions = [
                ForcedAction(
                    player_id="player-2",
                    action_type="select_active",
                    reason="Your active Pokemon was knocked out.",
                    params={"available_bench_ids": ["bench-1", "bench-2"]},
                )
            ]
            return ActionResult(
                success=True,
                message="Attack knocked out opponent's Pokemon",
                state_changes=[{"type": "knockout"}],
            )

        mock_engine.execute_action = AsyncMock(side_effect=mock_execute)

        result = await game_service.execute_action(
            "game-123", "player-1", AttackAction(attack_index=0)
        )

        assert result.success is True
        assert result.pending_forced_action is not None
        assert result.pending_forced_action.player_id == "player-2"
        assert result.pending_forced_action.action_type == "select_active"
        assert result.pending_forced_action.reason == "Your active Pokemon was knocked out."
        assert "bench-1" in result.pending_forced_action.params["available_bench_ids"]

    @pytest.mark.asyncio
    async def test_no_pending_forced_action_when_queue_empty(
        self,
        game_service: GameService,
        mock_state_manager: AsyncMock,
        mock_engine: MagicMock,
        sample_game_state: GameState,
    ) -> None:
        """Test that pending_forced_action is None when no forced action.

        Normal actions that don't trigger forced actions should return
        None for the pending_forced_action field.
        """
        mock_state_manager.load_state.return_value = sample_game_state
        mock_engine.execute_action.return_value = ActionResult(
            success=True,
            message="Action executed normally",
        )

        result = await game_service.execute_action("game-123", "player-1", PassAction())

        assert result.pending_forced_action is None


class TestResignGame:
    """Tests for the resign_game convenience method."""

    @pytest.mark.asyncio
    async def test_resign_game_executes_resign_action(
        self,
        game_service: GameService,
        mock_state_manager: AsyncMock,
        mock_engine: MagicMock,
        sample_game_state: GameState,
    ) -> None:
        """Test that resign_game is a convenience wrapper for execute_action.

        The resign_game method should internally create a ResignAction
        and call execute_action.
        """
        mock_state_manager.load_state.return_value = sample_game_state
        mock_engine.execute_action.return_value = ActionResult(
            success=True,
            message="Player resigned",
            win_result=WinResult(
                winner_id="player-2",
                loser_id="player-1",
                end_reason=GameEndReason.RESIGNATION,
                reason="Player resigned",
            ),
        )

        result = await game_service.resign_game("game-123", "player-1")

        assert result.success is True
        assert result.action_type == "resign"
        assert result.game_over is True


class TestEndGame:
    """Tests for the end_game method.

    The end_game method handles complete game ending:
    - Sets winner and end reason on game state
    - Archives game to history via state manager
    - Returns GameEndResult with final state
    """

    @pytest.mark.asyncio
    async def test_end_game_returns_result_with_winner(
        self,
        game_service: GameService,
        mock_state_manager: AsyncMock,
        sample_game_state: GameState,
    ) -> None:
        """Test that end_game returns GameEndResult with winner info.

        The result should include winner, loser, end reason, and
        game statistics from the archive operation.
        """
        mock_state_manager.load_state.return_value = sample_game_state

        result = await game_service.end_game(
            "game-123",
            winner_id="player-1",
            end_reason=GameEndReason.TIMEOUT,
        )

        # Verify state was modified
        assert sample_game_state.winner_id == "player-1"
        assert sample_game_state.end_reason == GameEndReason.TIMEOUT

        # Verify result
        assert result.success is True
        assert result.game_id == "game-123"
        assert result.winner_id == "player-1"
        assert result.loser_id == "player-2"
        assert result.end_reason == GameEndReason.TIMEOUT
        assert result.duration_seconds == 300
        assert result.turn_count == 10
        assert result.history_id == "game-123"

        # Verify archive was called
        mock_state_manager.archive_to_history.assert_called_once()

    @pytest.mark.asyncio
    async def test_end_game_includes_final_player_views(
        self,
        game_service: GameService,
        mock_state_manager: AsyncMock,
        sample_game_state: GameState,
    ) -> None:
        """Test that end_game result includes visibility-filtered final states.

        Each player should receive their own view of the final game state
        for displaying the game-over screen.
        """
        mock_state_manager.load_state.return_value = sample_game_state

        result = await game_service.end_game(
            "game-123",
            winner_id="player-1",
            end_reason=GameEndReason.PRIZES_TAKEN,
        )

        # Both players should have final views
        assert result.player1_final_view is not None
        assert result.player2_final_view is not None
        assert result.player1_final_view.viewer_id == "player-1"
        assert result.player2_final_view.viewer_id == "player-2"

    @pytest.mark.asyncio
    async def test_end_game_draw(
        self,
        game_service: GameService,
        mock_state_manager: AsyncMock,
        sample_game_state: GameState,
    ) -> None:
        """Test ending a game as a draw (no winner).

        Some scenarios (timeout with equal scores) can result in a draw.
        In this case, winner_id and loser_id should both be None.
        """
        mock_state_manager.load_state.return_value = sample_game_state

        result = await game_service.end_game(
            "game-123",
            winner_id=None,
            end_reason=GameEndReason.DRAW,
        )

        assert sample_game_state.winner_id is None
        assert sample_game_state.end_reason == GameEndReason.DRAW
        assert result.winner_id is None
        assert result.loser_id is None
        assert result.end_reason == GameEndReason.DRAW

    @pytest.mark.asyncio
    async def test_end_game_not_found(
        self,
        game_service: GameService,
        mock_state_manager: AsyncMock,
    ) -> None:
        """Test end_game raises error when game not found."""
        mock_state_manager.load_state.return_value = None

        with pytest.raises(GameNotFoundError):
            await game_service.end_game(
                "nonexistent",
                winner_id="player-1",
                end_reason=GameEndReason.TIMEOUT,
            )

    @pytest.mark.asyncio
    async def test_end_game_archives_with_replay_data(
        self,
        game_service: GameService,
        mock_state_manager: AsyncMock,
        sample_game_state: GameState,
    ) -> None:
        """Test that end_game passes replay data to archive_to_history.

        Replay data includes the action log, final turn number, and
        rules configuration for future replay functionality.
        """
        from app.db.models.game import EndReason

        sample_game_state.action_log = [{"type": "attack", "index": 0}]
        mock_state_manager.load_state.return_value = sample_game_state

        await game_service.end_game(
            "game-123",
            winner_id="player-1",
            end_reason=GameEndReason.PRIZES_TAKEN,
        )

        # Verify archive was called with replay data
        call_kwargs = mock_state_manager.archive_to_history.call_args.kwargs
        assert call_kwargs["game_id"] == "game-123"
        assert call_kwargs["end_reason"] == EndReason.PRIZES_TAKEN
        assert call_kwargs["replay_data"] is not None
        assert "action_log" in call_kwargs["replay_data"]
        assert call_kwargs["replay_data"]["action_log"] == [{"type": "attack", "index": 0}]


class TestCreateGame:
    """Tests for the create_game method."""

    @pytest.fixture
    def mock_deck_service(self) -> AsyncMock:
        """Create a mock DeckService for game creation tests."""
        from unittest.mock import MagicMock

        from app.core.enums import CardType, EnergyType, PokemonStage
        from app.core.models.card import Attack, CardDefinition
        from app.repositories.protocols import DeckEntry

        # Create sample card definitions - need enough for a valid deck (40 cards)
        pikachu = CardDefinition(
            id="pikachu-001",
            name="Pikachu",
            card_type=CardType.POKEMON,
            stage=PokemonStage.BASIC,
            hp=60,
            pokemon_type=EnergyType.LIGHTNING,
            attacks=[Attack(name="Thunder Shock", damage=20)],
        )

        service = AsyncMock()
        # get_deck_for_game returns expanded list of CardDefinitions (40 cards)
        service.get_deck_for_game = AsyncMock(return_value=[pikachu] * 40)

        # get_deck returns DeckEntry with energy_cards (20 energy for energy deck)
        deck_entry = MagicMock(spec=DeckEntry)
        deck_entry.energy_cards = {"lightning": 20}
        service.get_deck = AsyncMock(return_value=deck_entry)

        return service

    @pytest.fixture
    def mock_card_service_with_energy(self) -> MagicMock:
        """CardService that can resolve energy card IDs."""
        from app.core.enums import CardType, EnergyType
        from app.core.models.card import CardDefinition

        energy = CardDefinition(
            id="energy-basic-lightning",
            name="Lightning Energy",
            card_type=CardType.ENERGY,
            energy_type=EnergyType.LIGHTNING,
        )

        service = MagicMock()
        service.get_card = MagicMock(return_value=energy)
        service.get_all_cards = MagicMock(return_value={})
        return service

    @pytest.fixture
    def mock_engine_for_create(self) -> MagicMock:
        """GameEngine mock that returns a successful GameCreationResult.

        The mock dynamically creates a game with the player IDs that were
        passed to create_game, ensuring visibility checks pass.
        """
        from app.core.engine import GameCreationResult
        from app.core.models.game_state import GameState, PlayerState

        def create_game_side_effect(player_ids, decks, card_registry, **kwargs):
            """Create a game with the provided player IDs."""
            p1, p2 = player_ids[0], player_ids[1]
            game = GameState(
                game_id=kwargs.get("game_id") or "test-game-123",
                players={
                    p1: PlayerState(player_id=p1),
                    p2: PlayerState(player_id=p2),
                },
                current_player_id=p1,
                card_registry=card_registry,
            )
            return GameCreationResult(success=True, game=game)

        engine = MagicMock()
        engine.create_game = MagicMock(side_effect=create_game_side_effect)
        return engine

    @pytest.mark.asyncio
    async def test_create_game_success(
        self,
        mock_state_manager: AsyncMock,
        mock_card_service_with_energy: MagicMock,
        mock_engine_for_create: MagicMock,
        mock_deck_service: AsyncMock,
    ) -> None:
        """Test successful game creation.

        Verifies that create_game:
        1. Loads decks via DeckService
        2. Converts cards to instances
        3. Calls GameEngine.create_game
        4. Persists to cache and database
        5. Returns GameCreateResult with player views
        """
        from app.core.config import RulesConfig
        from app.services.game_service import GameService

        service = GameService(
            state_manager=mock_state_manager,
            card_service=mock_card_service_with_energy,
            engine_factory=lambda game: mock_engine_for_create,
            creation_engine_factory=lambda rules: mock_engine_for_create,
        )

        player1_id = uuid4()
        player2_id = uuid4()
        deck1_id = uuid4()
        deck2_id = uuid4()

        result = await service.create_game(
            player1_id=player1_id,
            player2_id=player2_id,
            deck1_id=deck1_id,
            deck2_id=deck2_id,
            rules_config=RulesConfig(),
            deck_service=mock_deck_service,
        )

        assert result.success is True
        assert result.game_id == "test-game-123"
        assert result.starting_player_id is not None
        assert result.player1_view is not None
        assert result.player2_view is not None

        # Verify decks were loaded
        assert mock_deck_service.get_deck_for_game.call_count == 2
        assert mock_deck_service.get_deck.call_count == 2

        # Verify state was persisted
        mock_state_manager.save_to_cache.assert_called_once()
        mock_state_manager.persist_to_db.assert_called_once()

    @pytest.mark.asyncio
    async def test_create_game_deck_load_failure(
        self,
        mock_state_manager: AsyncMock,
        mock_card_service: MagicMock,
        mock_engine: MagicMock,
    ) -> None:
        """Test that deck loading failure raises GameCreationError.

        If DeckService fails to load a deck (not found, not owned, etc.),
        create_game should raise a clear error.
        """
        from app.core.config import RulesConfig
        from app.services.game_service import GameService

        service = GameService(
            state_manager=mock_state_manager,
            card_service=mock_card_service,
            engine_factory=lambda game: mock_engine,
        )

        # Mock DeckService that fails
        mock_deck_service = AsyncMock()
        mock_deck_service.get_deck_for_game = AsyncMock(side_effect=ValueError("Deck not found"))

        with pytest.raises(GameCreationError) as exc_info:
            await service.create_game(
                player1_id=uuid4(),
                player2_id=uuid4(),
                deck1_id=uuid4(),
                deck2_id=uuid4(),
                rules_config=RulesConfig(),
                deck_service=mock_deck_service,
            )

        assert "Failed to load decks" in str(exc_info.value)

    @pytest.mark.asyncio
    async def test_create_game_engine_failure(
        self,
        mock_state_manager: AsyncMock,
        mock_card_service_with_energy: MagicMock,
        mock_deck_service: AsyncMock,
    ) -> None:
        """Test that GameEngine failure raises GameCreationError.

        If GameEngine.create_game fails (invalid decks, rule violations),
        create_game should raise a clear error.
        """
        from app.core.config import RulesConfig
        from app.core.engine import GameCreationResult
        from app.services.game_service import GameService

        # Engine that fails to create game
        mock_engine = MagicMock()
        mock_engine.create_game = MagicMock(
            return_value=GameCreationResult(
                success=False, game=None, message="No basic Pokemon in deck"
            )
        )

        service = GameService(
            state_manager=mock_state_manager,
            card_service=mock_card_service_with_energy,
            creation_engine_factory=lambda rules: mock_engine,
        )

        with pytest.raises(GameCreationError) as exc_info:
            await service.create_game(
                player1_id=uuid4(),
                player2_id=uuid4(),
                deck1_id=uuid4(),
                deck2_id=uuid4(),
                rules_config=RulesConfig(),
                deck_service=mock_deck_service,
            )

        assert "No basic Pokemon" in str(exc_info.value)

    @pytest.mark.asyncio
    async def test_create_game_missing_energy_card_raises_error(
        self,
        mock_state_manager: AsyncMock,
        mock_deck_service: AsyncMock,
    ) -> None:
        """Test that missing energy card definition raises GameCreationError.

        If an energy card definition cannot be found (e.g., 'energy-basic-fire'
        is not in the card registry), game creation should fail immediately
        with a clear error rather than silently creating a broken game.
        """
        from app.core.config import RulesConfig
        from app.services.game_service import GameService

        # CardService that returns None for energy cards (simulating missing data)
        mock_card_service = MagicMock()
        mock_card_service.get_card = MagicMock(return_value=None)

        service = GameService(
            state_manager=mock_state_manager,
            card_service=mock_card_service,
        )

        with pytest.raises(GameCreationError) as exc_info:
            await service.create_game(
                player1_id=uuid4(),
                player2_id=uuid4(),
                deck1_id=uuid4(),
                deck2_id=uuid4(),
                rules_config=RulesConfig(),
                deck_service=mock_deck_service,
            )

        assert "Energy card definition not found" in str(exc_info.value)
        assert "energy-basic-" in str(exc_info.value)


class TestDefaultEngineFactory:
    """Tests for the _default_engine_factory method.

    This method is responsible for creating a GameEngine configured
    with the rules from a specific game's state.
    """

    def test_default_engine_uses_game_rules(
        self,
        mock_state_manager: AsyncMock,
        mock_card_service: MagicMock,
        sample_game_state: GameState,
    ) -> None:
        """Test that default engine is created with the game's rules.

        The engine should use the RulesConfig stored in the game state,
        not any default configuration.
        """
        # Create service without mock engine to test default factory
        service = GameService(
            state_manager=mock_state_manager,
            card_service=mock_card_service,
        )

        engine = service._default_engine_factory(sample_game_state)

        # Engine should have the game's rules
        assert engine.rules == sample_game_state.rules

    def test_default_engine_with_rng_seed(
        self,
        mock_state_manager: AsyncMock,
        mock_card_service: MagicMock,
        sample_game_state: GameState,
    ) -> None:
        """Test that engine uses seeded RNG when game has rng_seed.

        When a game has an rng_seed set, the engine should use a
        deterministic RNG for replay support.
        """
        service = GameService(
            state_manager=mock_state_manager,
            card_service=mock_card_service,
        )
        sample_game_state.rng_seed = 12345

        engine = service._default_engine_factory(sample_game_state)

        # Engine should have been created successfully
        assert engine is not None

    def test_default_engine_without_rng_seed(
        self,
        mock_state_manager: AsyncMock,
        mock_card_service: MagicMock,
        sample_game_state: GameState,
    ) -> None:
        """Test that engine uses secure RNG when no seed is set.

        Without an rng_seed, the engine should use cryptographically
        secure random number generation.
        """
        service = GameService(
            state_manager=mock_state_manager,
            card_service=mock_card_service,
        )
        sample_game_state.rng_seed = None

        engine = service._default_engine_factory(sample_game_state)

        assert engine is not None

    def test_default_engine_derives_unique_seed_per_action(
        self,
        mock_state_manager: AsyncMock,
        mock_card_service: MagicMock,
        sample_game_state: GameState,
    ) -> None:
        """Test that different action counts produce different RNG sequences.

        For deterministic replay, each action needs a unique but
        reproducible RNG seed based on game seed + action count.
        """
        service = GameService(
            state_manager=mock_state_manager,
            card_service=mock_card_service,
        )
        sample_game_state.rng_seed = 12345

        # Simulate first action (action_log is empty)
        sample_game_state.action_log = []
        engine1 = service._default_engine_factory(sample_game_state)

        # Simulate second action (one action in log)
        sample_game_state.action_log = [{"type": "pass"}]
        engine2 = service._default_engine_factory(sample_game_state)

        # Both engines should be created successfully
        # (They will have different seeds due to action count)
        assert engine1 is not None
        assert engine2 is not None


class TestExceptionMessages:
    """Tests for exception message formatting."""

    def test_game_not_found_error_message(self) -> None:
        """Test GameNotFoundError has descriptive message."""
        error = GameNotFoundError("game-123")
        assert "game-123" in str(error)
        assert error.game_id == "game-123"

    def test_not_player_turn_error_message(self) -> None:
        """Test NotPlayerTurnError has descriptive message."""
        error = NotPlayerTurnError("game-123", "player-2", "player-1")
        assert "player-2" in str(error)
        assert "player-1" in str(error)
        assert error.game_id == "game-123"

    def test_invalid_action_error_message(self) -> None:
        """Test InvalidActionError has descriptive message."""
        error = InvalidActionError("game-123", "player-1", "Not enough energy")
        assert "Not enough energy" in str(error)
        assert error.reason == "Not enough energy"

    def test_player_not_in_game_error_message(self) -> None:
        """Test PlayerNotInGameError has descriptive message."""
        error = PlayerNotInGameError("game-123", "stranger")
        assert "stranger" in str(error)
        assert "game-123" in str(error)

    def test_game_already_ended_error_message(self) -> None:
        """Test GameAlreadyEndedError has descriptive message."""
        error = GameAlreadyEndedError("game-123")
        assert "game-123" in str(error)
        assert "ended" in str(error).lower()

    def test_forced_action_required_error_message(self) -> None:
        """Test ForcedActionRequiredError has descriptive message.

        The error should clearly indicate what action was required
        and what action was incorrectly attempted.
        """
        error = ForcedActionRequiredError("game-123", "player-1", "select_active", "pass")
        assert "select_active" in str(error)
        assert "pass" in str(error)
        assert error.required_action_type == "select_active"
        assert error.attempted_action_type == "pass"


class TestTurnTimerIntegration:
    """Tests for turn timer integration in GameService.

    The turn timer should:
    - NOT start during SETUP phase (when selecting basic pokemon)
    - Start when SETUP phase ends (first real turn begins)
    - Start when turn changes during normal play
    - Be canceled when game ends
    """

    @pytest.fixture
    def game_state_in_setup(self) -> GameState:
        """Create a game state in SETUP phase.

        During SETUP, players are selecting their basic pokemon.
        Timer should NOT be running yet.
        """
        player1 = PlayerState(player_id="player-1")
        player2 = PlayerState(player_id="player-2")

        return GameState(
            game_id="game-123",
            players={"player-1": player1, "player-2": player2},
            current_player_id="player-1",
            turn_number=0,  # Setup phase
            phase=TurnPhase.SETUP,
        )

    @pytest.mark.asyncio
    async def test_timer_starts_when_setup_ends(
        self,
        game_service: GameService,
        mock_state_manager: AsyncMock,
        mock_engine: MagicMock,
        mock_timeout_service: AsyncMock,
        game_state_in_setup: GameState,
    ) -> None:
        """Test that turn timer starts when SETUP phase ends.

        When an action causes the phase to transition from SETUP to
        a real game phase (DRAW/MAIN), the turn timer should start.
        This ensures players have unlimited time for initial pokemon
        selection but are timed once actual gameplay begins.
        """
        # Enable turn timer in rules
        game_state_in_setup.rules.win_conditions.turn_timer_enabled = True
        game_state_in_setup.rules.win_conditions.turn_timer_seconds = 180

        mock_state_manager.load_state.return_value = game_state_in_setup

        def mock_execute(state, player_id, action):
            # Simulate SETUP ending - phase transitions to MAIN
            state.phase = TurnPhase.MAIN
            state.turn_number = 1
            return ActionResult(success=True, message="Setup complete, game started")

        mock_engine.execute_action = AsyncMock(side_effect=mock_execute)

        result = await game_service.execute_action(
            "game-123", "player-1", SelectActiveAction(pokemon_id="basic-1")
        )

        assert result.success is True
        # Timer should have been started
        mock_timeout_service.start_turn_timer.assert_called_once_with(
            game_id="game-123",
            player_id="player-1",
            timeout_seconds=180,
            warning_thresholds=[50, 25],
        )
        assert result.turn_timeout_seconds == 180

    @pytest.mark.asyncio
    async def test_timer_not_started_during_setup(
        self,
        game_service: GameService,
        mock_state_manager: AsyncMock,
        mock_engine: MagicMock,
        mock_timeout_service: AsyncMock,
        game_state_in_setup: GameState,
    ) -> None:
        """Test that turn timer is NOT started during SETUP phase actions.

        When actions are executed during SETUP (before both players
        have selected basic pokemon), the timer should not start.
        """
        # Enable turn timer in rules
        game_state_in_setup.rules.win_conditions.turn_timer_enabled = True

        mock_state_manager.load_state.return_value = game_state_in_setup

        def mock_execute(state, player_id, action):
            # Action during SETUP - phase stays in SETUP (e.g., first player selected)
            # Phase does NOT change
            return ActionResult(success=True, message="First player selected basic")

        mock_engine.execute_action = AsyncMock(side_effect=mock_execute)

        await game_service.execute_action(
            "game-123", "player-1", SelectActiveAction(pokemon_id="basic-1")
        )

        # Timer should NOT have been started (still in SETUP)
        mock_timeout_service.start_turn_timer.assert_not_called()

    @pytest.mark.asyncio
    async def test_timer_starts_on_turn_change(
        self,
        game_service: GameService,
        mock_state_manager: AsyncMock,
        mock_engine: MagicMock,
        mock_timeout_service: AsyncMock,
        sample_game_state: GameState,
    ) -> None:
        """Test that turn timer starts when turn changes during normal play.

        When a player ends their turn (e.g., via pass action), the
        timer should start for the next player.
        """
        # Enable turn timer in rules
        sample_game_state.rules.win_conditions.turn_timer_enabled = True
        sample_game_state.rules.win_conditions.turn_timer_seconds = 180

        mock_state_manager.load_state.return_value = sample_game_state

        def mock_execute(state, player_id, action):
            # Pass action ends turn - next player's turn
            state.current_player_id = "player-2"
            state.turn_number = 2
            return ActionResult(success=True, message="Turn ended")

        mock_engine.execute_action = AsyncMock(side_effect=mock_execute)

        result = await game_service.execute_action("game-123", "player-1", PassAction())

        assert result.success is True
        assert result.turn_changed is True
        # Timer should have been started for the new current player
        mock_timeout_service.start_turn_timer.assert_called_once_with(
            game_id="game-123",
            player_id="player-2",  # New current player
            timeout_seconds=180,
            warning_thresholds=[50, 25],
        )

    @pytest.mark.asyncio
    async def test_timer_not_started_when_disabled(
        self,
        game_service: GameService,
        mock_state_manager: AsyncMock,
        mock_engine: MagicMock,
        mock_timeout_service: AsyncMock,
        sample_game_state: GameState,
    ) -> None:
        """Test that timer is not started when turn timer is disabled.

        If turn_timer_enabled is False in rules, no timer operations
        should be performed even on turn changes.
        """
        # Disable turn timer (default)
        sample_game_state.rules.win_conditions.turn_timer_enabled = False

        mock_state_manager.load_state.return_value = sample_game_state

        def mock_execute(state, player_id, action):
            state.current_player_id = "player-2"
            state.turn_number = 2
            return ActionResult(success=True, message="Turn ended")

        mock_engine.execute_action = AsyncMock(side_effect=mock_execute)

        result = await game_service.execute_action("game-123", "player-1", PassAction())

        assert result.success is True
        # Timer should NOT have been started (disabled in rules)
        mock_timeout_service.start_turn_timer.assert_not_called()
        assert result.turn_timeout_seconds is None
        assert result.turn_deadline is None

    @pytest.mark.asyncio
    async def test_timer_canceled_on_game_over(
        self,
        game_service: GameService,
        mock_state_manager: AsyncMock,
        mock_engine: MagicMock,
        mock_timeout_service: AsyncMock,
        sample_game_state: GameState,
    ) -> None:
        """Test that turn timer is canceled when game ends.

        When an action results in game over (win condition met),
        the timer should be canceled to prevent spurious timeout events.
        """
        sample_game_state.rules.win_conditions.turn_timer_enabled = True
        mock_state_manager.load_state.return_value = sample_game_state

        mock_engine.execute_action.return_value = ActionResult(
            success=True,
            message="Game over",
            win_result=WinResult(
                winner_id="player-1",
                loser_id="player-2",
                end_reason=GameEndReason.PRIZES_TAKEN,
                reason="All prizes taken",
            ),
        )

        result = await game_service.execute_action(
            "game-123", "player-1", AttackAction(attack_index=0)
        )

        assert result.success is True
        assert result.game_over is True
        # Timer should have been canceled
        mock_timeout_service.cancel_timer.assert_called_once_with("game-123")


class TestSpectateGame:
    """Tests for the spectate_game method.

    Spectator mode allows users to watch games they are not participating in.
    Spectators receive a filtered view with no hands visible.
    """

    @pytest.mark.asyncio
    async def test_spectate_game_success(
        self,
        game_service: GameService,
        mock_state_manager: AsyncMock,
        sample_game_state: GameState,
    ) -> None:
        """Test successful game spectating returns spectator-filtered state.

        When a non-participant spectates a game, they should receive a
        SpectateResult with the game state filtered to hide all hands.
        """
        mock_state_manager.load_state.return_value = sample_game_state

        result = await game_service.spectate_game("game-123", "spectator-user")

        assert result.success is True
        assert result.game_id == "game-123"
        assert result.visible_state is not None
        assert result.game_over is False
        # Spectator view should have special viewer_id
        assert result.visible_state.viewer_id == "__spectator__"
        # Spectator should not see any hands (is_my_turn always False)
        assert result.visible_state.is_my_turn is False

    @pytest.mark.asyncio
    async def test_spectate_game_not_found(
        self,
        game_service: GameService,
        mock_state_manager: AsyncMock,
    ) -> None:
        """Test spectate_game raises GameNotFoundError when game doesn't exist.

        Spectating a non-existent game should raise an appropriate error.
        """
        mock_state_manager.load_state.return_value = None

        with pytest.raises(GameNotFoundError) as exc_info:
            await game_service.spectate_game("nonexistent", "spectator-user")

        assert exc_info.value.game_id == "nonexistent"

    @pytest.mark.asyncio
    async def test_spectate_own_game_raises_error(
        self,
        game_service: GameService,
        mock_state_manager: AsyncMock,
        sample_game_state: GameState,
    ) -> None:
        """Test that players cannot spectate their own game.

        A player who is participating in the game should not be able to
        spectate it - they should use join_game instead.
        """
        mock_state_manager.load_state.return_value = sample_game_state

        with pytest.raises(CannotSpectateOwnGameError) as exc_info:
            await game_service.spectate_game("game-123", "player-1")

        assert exc_info.value.game_id == "game-123"
        assert exc_info.value.player_id == "player-1"

    @pytest.mark.asyncio
    async def test_spectate_ended_game(
        self,
        game_service: GameService,
        mock_state_manager: AsyncMock,
        sample_game_state: GameState,
    ) -> None:
        """Test spectating an ended game succeeds but indicates game_over.

        Users should be able to spectate completed games to view the
        final state, but game_over should be True.
        """
        sample_game_state.winner_id = "player-1"
        sample_game_state.end_reason = GameEndReason.PRIZES_TAKEN
        mock_state_manager.load_state.return_value = sample_game_state

        result = await game_service.spectate_game("game-123", "spectator-user")

        assert result.success is True
        assert result.game_over is True
        assert "ended" in result.message.lower()

    @pytest.mark.asyncio
    async def test_spectate_game_hides_both_hands(
        self,
        game_service: GameService,
        mock_state_manager: AsyncMock,
        sample_game_state: GameState,
    ) -> None:
        """Test that spectator view hides both players' hands.

        Unlike player views where you can see your own hand, spectators
        cannot see either player's hand.
        """
        mock_state_manager.load_state.return_value = sample_game_state

        result = await game_service.spectate_game("game-123", "spectator-user")

        assert result.visible_state is not None
        # Both players' hands should show count only, no cards
        for _player_id, player_state in result.visible_state.players.items():
            assert player_state.hand.cards == []
            # is_current_player should be False for all players from spectator view
            assert player_state.is_current_player is False


class TestCannotSpectateOwnGameError:
    """Tests for CannotSpectateOwnGameError exception."""

    def test_error_message_contains_game_and_player(self) -> None:
        """Test CannotSpectateOwnGameError has descriptive message.

        The error message should clearly indicate which game and player
        triggered the error.
        """
        error = CannotSpectateOwnGameError("game-123", "player-1")

        assert "game-123" in str(error)
        assert error.game_id == "game-123"
        assert error.player_id == "player-1"
        assert "spectate" in str(error).lower()


class TestHandleTimeout:
    """Tests for the handle_timeout method.

    handle_timeout is called by the background timeout polling task when
    a player's turn timer expires. It should end the game with the
    timed-out player as the loser.
    """

    @pytest.mark.asyncio
    async def test_handle_timeout_declares_opponent_winner(
        self,
        game_service: GameService,
        mock_state_manager: AsyncMock,
        sample_game_state: GameState,
    ) -> None:
        """Test that handle_timeout declares the opponent as winner.

        When a player times out, their opponent should win by timeout.
        """
        mock_state_manager.load_state.return_value = sample_game_state

        result = await game_service.handle_timeout("game-123", "player-1")

        assert result.success is True
        assert result.game_id == "game-123"
        assert result.winner_id == "player-2"  # Opponent wins
        assert result.loser_id == "player-1"  # Timed out player loses
        assert result.end_reason == GameEndReason.TIMEOUT

    @pytest.mark.asyncio
    async def test_handle_timeout_calls_end_game(
        self,
        game_service: GameService,
        mock_state_manager: AsyncMock,
        sample_game_state: GameState,
    ) -> None:
        """Test that handle_timeout archives the game to history.

        The game should be properly archived with timeout as the end reason.
        """
        mock_state_manager.load_state.return_value = sample_game_state

        result = await game_service.handle_timeout("game-123", "player-2")

        # Should archive to history
        mock_state_manager.archive_to_history.assert_called_once()
        call_kwargs = mock_state_manager.archive_to_history.call_args.kwargs
        assert call_kwargs["game_id"] == "game-123"
        assert call_kwargs["end_reason"].value == "timeout"

        # Winner should be the non-timed-out player
        assert result.winner_id == "player-1"

    @pytest.mark.asyncio
    async def test_handle_timeout_game_not_found(
        self,
        game_service: GameService,
        mock_state_manager: AsyncMock,
    ) -> None:
        """Test handle_timeout raises error when game doesn't exist.

        If the game was already cleaned up or never existed, we should
        get a GameNotFoundError.
        """
        mock_state_manager.load_state.return_value = None

        with pytest.raises(GameNotFoundError) as exc_info:
            await game_service.handle_timeout("nonexistent", "player-1")

        assert exc_info.value.game_id == "nonexistent"

    @pytest.mark.asyncio
    async def test_handle_timeout_cancels_timer(
        self,
        game_service: GameService,
        mock_state_manager: AsyncMock,
        mock_timeout_service: AsyncMock,
        sample_game_state: GameState,
    ) -> None:
        """Test that handle_timeout cancels the turn timer.

        The timer should be canceled to prevent any further timeout events.
        """
        mock_state_manager.load_state.return_value = sample_game_state

        await game_service.handle_timeout("game-123", "player-1")

        # Timer should have been canceled via end_game
        mock_timeout_service.cancel_timer.assert_called_with("game-123")


class TestJoinGameTimerExtension:
    """Tests for timer extension during reconnection in join_game.

    When a player reconnects mid-turn with an active timer, the timer
    should be extended by the grace period to give them time to act.
    """

    @pytest.mark.asyncio
    async def test_join_game_extends_timer_on_reconnect(
        self,
        game_service: GameService,
        mock_state_manager: AsyncMock,
        mock_timeout_service: AsyncMock,
        sample_game_state: GameState,
    ) -> None:
        """Test that joining a game extends turn timer on reconnect.

        When a player reconnects and it's their turn, the timer should
        be extended by the grace period (default 15 seconds).
        """
        from app.services.turn_timeout_service import TurnTimeoutInfo

        # Enable turn timer
        sample_game_state.rules.win_conditions.turn_timer_enabled = True
        sample_game_state.rules.win_conditions.turn_timer_grace_seconds = 15
        mock_state_manager.load_state.return_value = sample_game_state

        # Mock existing timer for current player
        existing_timer = TurnTimeoutInfo(
            game_id="game-123",
            player_id="player-1",
            deadline=1000.0,
            timeout_seconds=180,
            remaining_seconds=60,
            warnings_sent=[],
            warning_thresholds=[50, 25],
        )
        mock_timeout_service.get_timeout_info.return_value = existing_timer

        # Mock extended timer
        extended_timer = TurnTimeoutInfo(
            game_id="game-123",
            player_id="player-1",
            deadline=1015.0,  # Extended by grace period
            timeout_seconds=180,
            remaining_seconds=75,  # 60 + 15
            warnings_sent=[],
            warning_thresholds=[50, 25],
        )
        mock_timeout_service.extend_timer.return_value = extended_timer

        # Player-1 reconnects (it's their turn)
        result = await game_service.join_game("game-123", "player-1")

        assert result.success is True
        assert result.is_your_turn is True
        # Timer should have been extended
        mock_timeout_service.extend_timer.assert_called_once_with("game-123", 15)
        # Result should show extended timer info
        assert result.turn_timeout_seconds == 75
        assert result.turn_deadline == 1015.0

    @pytest.mark.asyncio
    async def test_join_game_no_extension_when_not_your_turn(
        self,
        game_service: GameService,
        mock_state_manager: AsyncMock,
        mock_timeout_service: AsyncMock,
        sample_game_state: GameState,
    ) -> None:
        """Test that timer is not extended when it's not your turn.

        Only the current player's reconnection should extend the timer.
        The opponent reconnecting should not affect the timer.
        """
        from app.services.turn_timeout_service import TurnTimeoutInfo

        # Enable turn timer, player-1's turn
        sample_game_state.rules.win_conditions.turn_timer_enabled = True
        sample_game_state.current_player_id = "player-1"
        mock_state_manager.load_state.return_value = sample_game_state

        # Mock timer for player-1 (current player)
        existing_timer = TurnTimeoutInfo(
            game_id="game-123",
            player_id="player-1",
            deadline=1000.0,
            timeout_seconds=180,
            remaining_seconds=60,
            warnings_sent=[],
            warning_thresholds=[50, 25],
        )
        mock_timeout_service.get_timeout_info.return_value = existing_timer

        # Player-2 reconnects (NOT their turn)
        result = await game_service.join_game("game-123", "player-2")

        assert result.success is True
        assert result.is_your_turn is False
        # Timer should NOT have been extended (not player-2's turn)
        mock_timeout_service.extend_timer.assert_not_called()
        # Result should still show timer info
        assert result.turn_timeout_seconds == 60
        assert result.turn_deadline == 1000.0

    @pytest.mark.asyncio
    async def test_join_game_no_timer_info_when_disabled(
        self,
        game_service: GameService,
        mock_state_manager: AsyncMock,
        mock_timeout_service: AsyncMock,
        sample_game_state: GameState,
    ) -> None:
        """Test that timer info is None when timer is disabled.

        If turn_timer_enabled is False, no timer operations should occur.
        """
        # Timer disabled (default)
        sample_game_state.rules.win_conditions.turn_timer_enabled = False
        mock_state_manager.load_state.return_value = sample_game_state

        result = await game_service.join_game("game-123", "player-1")

        assert result.success is True
        # No timer operations
        mock_timeout_service.get_timeout_info.assert_not_called()
        mock_timeout_service.extend_timer.assert_not_called()
        # Timer fields should be None
        assert result.turn_timeout_seconds is None
        assert result.turn_deadline is None


class TestAdditionalActionTypes:
    """Tests for additional action types in execute_action.

    These tests verify that various action types are properly passed through
    to the engine and handled correctly by the service layer.
    """

    @pytest.mark.asyncio
    async def test_execute_play_pokemon_action(
        self,
        game_service: GameService,
        mock_state_manager: AsyncMock,
        mock_engine: MagicMock,
        sample_game_state: GameState,
    ) -> None:
        """Test executing a PlayPokemonAction.

        Playing a Pokemon from hand should be validated and executed
        through the engine like any other action.
        """
        from app.core.models.actions import PlayPokemonAction

        mock_state_manager.load_state.return_value = sample_game_state
        mock_engine.execute_action.return_value = ActionResult(
            success=True,
            message="Pikachu placed on bench",
            state_changes=[{"type": "play_pokemon", "zone": "bench"}],
        )

        action = PlayPokemonAction(card_instance_id="pikachu-001")
        result = await game_service.execute_action("game-123", "player-1", action)

        assert result.success is True
        assert result.action_type == "play_pokemon"
        mock_engine.execute_action.assert_called_once()

    @pytest.mark.asyncio
    async def test_execute_attach_energy_action(
        self,
        game_service: GameService,
        mock_state_manager: AsyncMock,
        mock_engine: MagicMock,
        sample_game_state: GameState,
    ) -> None:
        """Test executing an AttachEnergyAction.

        Attaching energy should update the Pokemon's energy and be
        reflected in the state changes.
        """
        from app.core.models.actions import AttachEnergyAction

        mock_state_manager.load_state.return_value = sample_game_state
        mock_engine.execute_action.return_value = ActionResult(
            success=True,
            message="Lightning energy attached to Pikachu",
            state_changes=[{"type": "attach_energy", "energy_type": "lightning"}],
        )

        action = AttachEnergyAction(energy_card_id="energy-001", target_pokemon_id="pikachu-001")
        result = await game_service.execute_action("game-123", "player-1", action)

        assert result.success is True
        assert result.action_type == "attach_energy"

    @pytest.mark.asyncio
    async def test_execute_retreat_action(
        self,
        game_service: GameService,
        mock_state_manager: AsyncMock,
        mock_engine: MagicMock,
        sample_game_state: GameState,
    ) -> None:
        """Test executing a RetreatAction.

        Retreating should switch the active Pokemon and discard energy
        equal to the retreat cost.
        """
        from app.core.models.actions import RetreatAction

        mock_state_manager.load_state.return_value = sample_game_state
        mock_engine.execute_action.return_value = ActionResult(
            success=True,
            message="Retreated to Raichu",
            state_changes=[
                {"type": "retreat", "old_active": "pikachu-001", "new_active": "raichu-001"}
            ],
        )

        action = RetreatAction(new_active_id="raichu-001", energy_to_discard=["energy-001"])
        result = await game_service.execute_action("game-123", "player-1", action)

        assert result.success is True
        assert result.action_type == "retreat"

    @pytest.mark.asyncio
    async def test_execute_evolve_pokemon_action(
        self,
        game_service: GameService,
        mock_state_manager: AsyncMock,
        mock_engine: MagicMock,
        sample_game_state: GameState,
    ) -> None:
        """Test executing an EvolvePokemonAction.

        Evolution should place the evolution card on top of the target
        Pokemon and update its stats.
        """
        from app.core.models.actions import EvolvePokemonAction

        mock_state_manager.load_state.return_value = sample_game_state
        mock_engine.execute_action.return_value = ActionResult(
            success=True,
            message="Pikachu evolved into Raichu",
            state_changes=[{"type": "evolve", "from": "pikachu-001", "to": "raichu-001"}],
        )

        action = EvolvePokemonAction(
            evolution_card_id="raichu-001", target_pokemon_id="pikachu-001"
        )
        result = await game_service.execute_action("game-123", "player-1", action)

        assert result.success is True
        assert result.action_type == "evolve"

    @pytest.mark.asyncio
    async def test_execute_play_trainer_action(
        self,
        game_service: GameService,
        mock_state_manager: AsyncMock,
        mock_engine: MagicMock,
        sample_game_state: GameState,
    ) -> None:
        """Test executing a PlayTrainerAction.

        Trainer cards should be played and their effects resolved
        through the effect handler system.
        """
        from app.core.models.actions import PlayTrainerAction

        mock_state_manager.load_state.return_value = sample_game_state
        mock_engine.execute_action.return_value = ActionResult(
            success=True,
            message="Professor Oak played - drew 7 cards",
            state_changes=[{"type": "play_trainer", "cards_drawn": 7}],
        )

        action = PlayTrainerAction(card_instance_id="prof-oak-001")
        result = await game_service.execute_action("game-123", "player-1", action)

        assert result.success is True
        assert result.action_type == "play_trainer"

    @pytest.mark.asyncio
    async def test_execute_select_prize_action(
        self,
        game_service: GameService,
        mock_state_manager: AsyncMock,
        mock_engine: MagicMock,
        sample_game_state: GameState,
    ) -> None:
        """Test executing a SelectPrizeAction.

        After a knockout, the player should be able to select a prize
        card to add to their hand.
        """
        from app.core.models.actions import SelectPrizeAction

        # Set up forced action for prize selection
        sample_game_state.forced_actions = [
            ForcedAction(
                player_id="player-1",
                action_type="select_prize",
                reason="Select a prize card",
            )
        ]
        mock_state_manager.load_state.return_value = sample_game_state
        mock_engine.execute_action.return_value = ActionResult(
            success=True,
            message="Prize card taken",
            state_changes=[{"type": "select_prize", "prize_index": 2}],
        )

        action = SelectPrizeAction(prize_index=2)
        result = await game_service.execute_action("game-123", "player-1", action)

        assert result.success is True
        assert result.action_type == "select_prize"

    @pytest.mark.asyncio
    async def test_execute_use_ability_action(
        self,
        game_service: GameService,
        mock_state_manager: AsyncMock,
        mock_engine: MagicMock,
        sample_game_state: GameState,
    ) -> None:
        """Test executing a UseAbilityAction.

        Pokemon abilities should be activated and their effects resolved.
        """
        from app.core.models.actions import UseAbilityAction

        mock_state_manager.load_state.return_value = sample_game_state
        mock_engine.execute_action.return_value = ActionResult(
            success=True,
            message="Energy Trans activated",
            state_changes=[{"type": "use_ability", "ability": "Energy Trans"}],
        )

        action = UseAbilityAction(pokemon_id="venusaur-001", ability_index=0)
        result = await game_service.execute_action("game-123", "player-1", action)

        assert result.success is True
        assert result.action_type == "use_ability"


class TestEndReasonMapping:
    """Tests for the _map_end_reason helper function.

    This function maps core GameEndReason to database EndReason,
    ensuring proper enum synchronization between modules.
    """

    def test_map_all_end_reasons(self) -> None:
        """Test that all GameEndReason values can be mapped.

        Every core end reason should have a corresponding database
        end reason to prevent runtime errors during game archival.
        """
        from app.db.models.game import EndReason
        from app.services.game_service import _map_end_reason

        # All core end reasons should be mappable
        for core_reason in GameEndReason:
            db_reason = _map_end_reason(core_reason)
            assert isinstance(db_reason, EndReason)

    def test_map_prizes_taken(self) -> None:
        """Test mapping PRIZES_TAKEN end reason."""
        from app.db.models.game import EndReason
        from app.services.game_service import _map_end_reason

        result = _map_end_reason(GameEndReason.PRIZES_TAKEN)
        assert result == EndReason.PRIZES_TAKEN

    def test_map_resignation(self) -> None:
        """Test mapping RESIGNATION end reason."""
        from app.db.models.game import EndReason
        from app.services.game_service import _map_end_reason

        result = _map_end_reason(GameEndReason.RESIGNATION)
        assert result == EndReason.RESIGNATION

    def test_map_timeout(self) -> None:
        """Test mapping TIMEOUT end reason."""
        from app.db.models.game import EndReason
        from app.services.game_service import _map_end_reason

        result = _map_end_reason(GameEndReason.TIMEOUT)
        assert result == EndReason.TIMEOUT

    def test_map_deck_empty_to_cannot_draw(self) -> None:
        """Test mapping DECK_EMPTY to CANNOT_DRAW.

        The core uses DECK_EMPTY for clarity, but the DB schema
        uses CANNOT_DRAW as the canonical name.
        """
        from app.db.models.game import EndReason
        from app.services.game_service import _map_end_reason

        result = _map_end_reason(GameEndReason.DECK_EMPTY)
        assert result == EndReason.CANNOT_DRAW
