"""Tests for GameNamespaceHandler.

This module tests the WebSocket event handlers for the /game namespace.
All tests use dependency injection to mock GameService and ConnectionManager,
following the project's no-monkey-patching testing pattern.
"""

from unittest.mock import AsyncMock

import pytest

from app.core.enums import GameEndReason, TurnPhase
from app.core.models.game_state import GameState, PlayerState
from app.core.visibility import VisibleGameState
from app.schemas.ws_messages import ConnectionStatus, WSErrorCode
from app.services.connection_manager import ConnectionInfo
from app.services.game_service import (
    ForcedActionRequiredError,
    GameActionResult,
    GameAlreadyEndedError,
    GameEndResult,
    GameJoinResult,
    GameNotFoundError,
    InvalidActionError,
    NotPlayerTurnError,
    PendingForcedAction,
)
from app.socketio.game_namespace import GameNamespaceHandler


@pytest.fixture
def mock_game_service() -> AsyncMock:
    """Create a mock GameService.

    The GameService orchestrates game operations - join, execute action,
    resign, end game, and state retrieval.
    """
    service = AsyncMock()
    service.join_game = AsyncMock()
    service.execute_action = AsyncMock()
    service.resign_game = AsyncMock()
    service.end_game = AsyncMock()
    service.get_game_state = AsyncMock()
    return service


@pytest.fixture
def mock_connection_manager() -> AsyncMock:
    """Create a mock ConnectionManager.

    The ConnectionManager tracks WebSocket connections and their
    association with games.
    """
    cm = AsyncMock()
    cm.join_game = AsyncMock(return_value=True)
    cm.leave_game = AsyncMock()
    cm.get_connection = AsyncMock(return_value=None)
    cm.get_game_user_sids = AsyncMock(return_value={})
    cm.get_opponent_sid = AsyncMock(return_value=None)
    cm.get_user_active_game = AsyncMock(return_value=None)
    return cm


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

    The GameStateManager handles persistence to Redis/Postgres
    and provides methods to look up active games.
    """
    sm = AsyncMock()
    sm.get_player_active_games = AsyncMock(return_value=[])
    sm.load_state = AsyncMock(return_value=None)
    sm.save_to_cache = AsyncMock()
    sm.persist_to_db = AsyncMock()
    return sm


@pytest.fixture
def mock_sio() -> AsyncMock:
    """Create a mock Socket.IO AsyncServer.

    The server is used to emit events and manage rooms.
    """
    sio = AsyncMock()
    sio.emit = AsyncMock()
    sio.enter_room = AsyncMock()
    sio.leave_room = AsyncMock()
    return sio


@pytest.fixture
def handler(
    mock_game_service: AsyncMock,
    mock_connection_manager: AsyncMock,
    mock_state_manager: AsyncMock,
) -> GameNamespaceHandler:
    """Create a GameNamespaceHandler with injected mock dependencies."""
    return GameNamespaceHandler(
        game_svc=mock_game_service,
        conn_manager=mock_connection_manager,
        state_manager=mock_state_manager,
    )


@pytest.fixture
def sample_visible_state() -> VisibleGameState:
    """Create a sample visible game state for testing.

    This represents what a player would see after joining a game.
    """
    from app.core.visibility import VisiblePlayerState, VisibleZone

    return VisibleGameState(
        game_id="game-123",
        viewer_id="player-1",
        players={
            "player-1": VisiblePlayerState(
                player_id="player-1",
                is_current_player=True,
                deck_count=40,
                hand=VisibleZone(count=7, cards=[], zone_type="hand"),
            ),
            "player-2": VisiblePlayerState(
                player_id="player-2",
                is_current_player=False,
                deck_count=40,
                hand=VisibleZone(count=7, cards=[], zone_type="hand"),
            ),
        },
        current_player_id="player-1",
        turn_number=1,
        phase=TurnPhase.MAIN,
        is_my_turn=True,
    )


@pytest.fixture
def sample_game_state() -> GameState:
    """Create a sample game state for testing broadcasts."""
    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 TestHandleJoin:
    """Tests for the handle_join event handler."""

    @pytest.mark.asyncio
    async def test_join_success(
        self,
        handler: GameNamespaceHandler,
        mock_game_service: AsyncMock,
        mock_connection_manager: AsyncMock,
        mock_sio: AsyncMock,
        sample_visible_state: VisibleGameState,
    ) -> None:
        """Test successful game join returns game state.

        When a player joins a game they're a participant in, they should
        receive the filtered game state and be added to the game room.
        """
        mock_game_service.join_game.return_value = GameJoinResult(
            success=True,
            game_id="game-123",
            player_id="player-1",
            visible_state=sample_visible_state,
            is_your_turn=True,
            game_over=False,
        )

        result = await handler.handle_join(
            mock_sio,
            "sid-123",
            "player-1",
            {"game_id": "game-123", "message_id": "msg-1"},
        )

        assert result["success"] is True
        assert result["game_id"] == "game-123"
        assert result["is_your_turn"] is True
        assert "state" in result

        # Should have joined the connection manager
        mock_connection_manager.join_game.assert_called_once_with("sid-123", "game-123")

        # Should have entered the Socket.IO room
        mock_sio.enter_room.assert_called_once_with("sid-123", "game:game-123", namespace="/game")

    @pytest.mark.asyncio
    async def test_join_missing_game_id(
        self,
        handler: GameNamespaceHandler,
        mock_sio: AsyncMock,
    ) -> None:
        """Test that missing game_id returns error.

        The game_id is required to join a game.
        """
        result = await handler.handle_join(
            mock_sio,
            "sid-123",
            "player-1",
            {"message_id": "msg-1"},  # No game_id
        )

        assert result["success"] is False
        assert result["error"]["code"] == WSErrorCode.INVALID_MESSAGE.value
        assert "game_id" in result["error"]["message"].lower()

    @pytest.mark.asyncio
    async def test_join_game_not_found(
        self,
        handler: GameNamespaceHandler,
        mock_game_service: AsyncMock,
        mock_sio: AsyncMock,
    ) -> None:
        """Test joining non-existent game returns error.

        When GameService indicates the game wasn't found, we should
        return a game_not_found error.
        """
        mock_game_service.join_game.return_value = GameJoinResult(
            success=False,
            game_id="nonexistent",
            player_id="player-1",
            message="Game not found",
        )

        result = await handler.handle_join(
            mock_sio,
            "sid-123",
            "player-1",
            {"game_id": "nonexistent"},
        )

        assert result["success"] is False
        assert result["error"]["code"] == WSErrorCode.GAME_NOT_FOUND.value

    @pytest.mark.asyncio
    async def test_join_not_participant(
        self,
        handler: GameNamespaceHandler,
        mock_game_service: AsyncMock,
        mock_sio: AsyncMock,
    ) -> None:
        """Test joining as non-participant returns error.

        Only players in the game can join it.
        """
        mock_game_service.join_game.return_value = GameJoinResult(
            success=False,
            game_id="game-123",
            player_id="stranger",
            message="You are not a participant in this game",
        )

        result = await handler.handle_join(
            mock_sio,
            "sid-123",
            "stranger",
            {"game_id": "game-123"},
        )

        assert result["success"] is False
        assert result["error"]["code"] == WSErrorCode.NOT_IN_GAME.value

    @pytest.mark.asyncio
    async def test_join_includes_pending_forced_action(
        self,
        handler: GameNamespaceHandler,
        mock_game_service: AsyncMock,
        mock_connection_manager: AsyncMock,
        mock_sio: AsyncMock,
        sample_visible_state: VisibleGameState,
    ) -> None:
        """Test that pending forced action is included in join result.

        When rejoining a game with a pending forced action, the client
        needs to know what action is required.
        """
        mock_game_service.join_game.return_value = GameJoinResult(
            success=True,
            game_id="game-123",
            player_id="player-1",
            visible_state=sample_visible_state,
            is_your_turn=True,
            pending_forced_action=PendingForcedAction(
                player_id="player-1",
                action_type="select_active",
                reason="Your active Pokemon was knocked out.",
                params={"available_bench_ids": ["bench-1"]},
            ),
        )

        result = await handler.handle_join(
            mock_sio,
            "sid-123",
            "player-1",
            {"game_id": "game-123"},
        )

        assert result["success"] is True
        assert "pending_forced_action" in result
        assert result["pending_forced_action"]["action_type"] == "select_active"

    @pytest.mark.asyncio
    async def test_join_notifies_opponent(
        self,
        handler: GameNamespaceHandler,
        mock_game_service: AsyncMock,
        mock_connection_manager: AsyncMock,
        mock_sio: AsyncMock,
        sample_visible_state: VisibleGameState,
    ) -> None:
        """Test that joining notifies the opponent.

        When a player joins/rejoins, the opponent should be notified
        of their connection status.
        """
        mock_game_service.join_game.return_value = GameJoinResult(
            success=True,
            game_id="game-123",
            player_id="player-1",
            visible_state=sample_visible_state,
            is_your_turn=True,
        )
        mock_connection_manager.get_opponent_sid.return_value = "opponent-sid"

        await handler.handle_join(
            mock_sio,
            "sid-123",
            "player-1",
            {"game_id": "game-123"},
        )

        # Should have tried to notify opponent
        mock_connection_manager.get_opponent_sid.assert_called()

        # Should have emitted opponent status to opponent
        emit_calls = [
            call for call in mock_sio.emit.call_args_list if call[0][0] == "game:opponent_status"
        ]
        assert len(emit_calls) == 1


class TestHandleAction:
    """Tests for the handle_action event handler."""

    @pytest.mark.asyncio
    async def test_action_success(
        self,
        handler: GameNamespaceHandler,
        mock_game_service: AsyncMock,
        mock_connection_manager: AsyncMock,
        mock_sio: AsyncMock,
        sample_game_state: GameState,
    ) -> None:
        """Test successful action execution.

        A valid action should be executed and the state should be
        broadcast to all participants.
        """
        mock_game_service.execute_action.return_value = GameActionResult(
            success=True,
            game_id="game-123",
            action_type="pass",
            message="Turn ended",
            turn_changed=True,
            current_player_id="player-2",
        )
        mock_game_service.get_game_state.return_value = sample_game_state
        mock_connection_manager.get_game_user_sids.return_value = {
            "player-1": "sid-1",
            "player-2": "sid-2",
        }

        result = await handler.handle_action(
            mock_sio,
            "sid-123",
            "player-1",
            {
                "game_id": "game-123",
                "action": {"type": "pass"},
                "message_id": "msg-1",
            },
        )

        assert result["success"] is True
        assert result["action_type"] == "pass"
        assert result["turn_changed"] is True

        # Should have broadcast state to participants
        assert mock_sio.emit.called

    @pytest.mark.asyncio
    async def test_action_missing_game_id(
        self,
        handler: GameNamespaceHandler,
        mock_sio: AsyncMock,
    ) -> None:
        """Test that missing game_id returns error."""
        result = await handler.handle_action(
            mock_sio,
            "sid-123",
            "player-1",
            {"action": {"type": "pass"}},
        )

        assert result["success"] is False
        assert result["error"]["code"] == WSErrorCode.INVALID_MESSAGE.value

    @pytest.mark.asyncio
    async def test_action_missing_action(
        self,
        handler: GameNamespaceHandler,
        mock_sio: AsyncMock,
    ) -> None:
        """Test that missing action returns error."""
        result = await handler.handle_action(
            mock_sio,
            "sid-123",
            "player-1",
            {"game_id": "game-123"},
        )

        assert result["success"] is False
        assert result["error"]["code"] == WSErrorCode.INVALID_MESSAGE.value

    @pytest.mark.asyncio
    async def test_action_invalid_action_format(
        self,
        handler: GameNamespaceHandler,
        mock_sio: AsyncMock,
    ) -> None:
        """Test that invalid action format returns error.

        The action data must match a valid Action type.
        """
        result = await handler.handle_action(
            mock_sio,
            "sid-123",
            "player-1",
            {"game_id": "game-123", "action": {"type": "invalid_action_type"}},
        )

        assert result["success"] is False
        assert result["error"]["code"] == WSErrorCode.INVALID_ACTION.value

    @pytest.mark.asyncio
    async def test_action_game_not_found(
        self,
        handler: GameNamespaceHandler,
        mock_game_service: AsyncMock,
        mock_sio: AsyncMock,
    ) -> None:
        """Test action on non-existent game returns error."""
        mock_game_service.execute_action.side_effect = GameNotFoundError("game-123")

        result = await handler.handle_action(
            mock_sio,
            "sid-123",
            "player-1",
            {"game_id": "game-123", "action": {"type": "pass"}},
        )

        assert result["success"] is False
        assert result["error"]["code"] == WSErrorCode.GAME_NOT_FOUND.value

    @pytest.mark.asyncio
    async def test_action_not_your_turn(
        self,
        handler: GameNamespaceHandler,
        mock_game_service: AsyncMock,
        mock_sio: AsyncMock,
    ) -> None:
        """Test action when not player's turn returns error."""
        mock_game_service.execute_action.side_effect = NotPlayerTurnError(
            "game-123", "player-2", "player-1"
        )

        result = await handler.handle_action(
            mock_sio,
            "sid-123",
            "player-2",
            {"game_id": "game-123", "action": {"type": "pass"}},
        )

        assert result["success"] is False
        assert result["error"]["code"] == WSErrorCode.NOT_YOUR_TURN.value

    @pytest.mark.asyncio
    async def test_action_forced_action_required(
        self,
        handler: GameNamespaceHandler,
        mock_game_service: AsyncMock,
        mock_sio: AsyncMock,
    ) -> None:
        """Test action when forced action required returns error."""
        mock_game_service.execute_action.side_effect = ForcedActionRequiredError(
            "game-123", "player-1", "select_active", "pass"
        )

        result = await handler.handle_action(
            mock_sio,
            "sid-123",
            "player-1",
            {"game_id": "game-123", "action": {"type": "pass"}},
        )

        assert result["success"] is False
        assert result["error"]["code"] == WSErrorCode.ACTION_NOT_ALLOWED.value

    @pytest.mark.asyncio
    async def test_action_invalid_action(
        self,
        handler: GameNamespaceHandler,
        mock_game_service: AsyncMock,
        mock_sio: AsyncMock,
    ) -> None:
        """Test invalid action (rejected by engine) returns error."""
        mock_game_service.execute_action.side_effect = InvalidActionError(
            "game-123", "player-1", "Not enough energy"
        )

        result = await handler.handle_action(
            mock_sio,
            "sid-123",
            "player-1",
            {"game_id": "game-123", "action": {"type": "attack", "attack_index": 0}},
        )

        assert result["success"] is False
        assert result["error"]["code"] == WSErrorCode.INVALID_ACTION.value
        assert "Not enough energy" in result["error"]["message"]

    @pytest.mark.asyncio
    async def test_action_game_over(
        self,
        handler: GameNamespaceHandler,
        mock_game_service: AsyncMock,
        mock_connection_manager: AsyncMock,
        mock_sio: AsyncMock,
        sample_game_state: GameState,
    ) -> None:
        """Test action that ends game broadcasts game over.

        When an action results in game over, the result should include
        game_over=True and the game should be ended.
        """
        mock_game_service.execute_action.return_value = GameActionResult(
            success=True,
            game_id="game-123",
            action_type="attack",
            game_over=True,
            winner_id="player-1",
            end_reason=GameEndReason.PRIZES_TAKEN,
        )
        mock_game_service.get_game_state.return_value = sample_game_state
        mock_game_service.end_game.return_value = GameEndResult(
            success=True,
            game_id="game-123",
            winner_id="player-1",
        )
        mock_connection_manager.get_game_user_sids.return_value = {}

        result = await handler.handle_action(
            mock_sio,
            "sid-123",
            "player-1",
            {"game_id": "game-123", "action": {"type": "attack", "attack_index": 0}},
        )

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

        # Should have called end_game
        mock_game_service.end_game.assert_called_once()

    @pytest.mark.asyncio
    async def test_action_includes_pending_forced_action(
        self,
        handler: GameNamespaceHandler,
        mock_game_service: AsyncMock,
        mock_connection_manager: AsyncMock,
        mock_sio: AsyncMock,
        sample_game_state: GameState,
    ) -> None:
        """Test that action result includes pending forced action.

        When an action creates a forced action (e.g., knockout triggers
        select_active), the result should include it.
        """
        mock_game_service.execute_action.return_value = GameActionResult(
            success=True,
            game_id="game-123",
            action_type="attack",
            pending_forced_action=PendingForcedAction(
                player_id="player-2",
                action_type="select_active",
                reason="Your active was knocked out.",
            ),
        )
        mock_game_service.get_game_state.return_value = sample_game_state
        mock_connection_manager.get_game_user_sids.return_value = {}

        result = await handler.handle_action(
            mock_sio,
            "sid-123",
            "player-1",
            {"game_id": "game-123", "action": {"type": "attack", "attack_index": 0}},
        )

        assert result["success"] is True
        assert "pending_forced_action" in result
        assert result["pending_forced_action"]["action_type"] == "select_active"


class TestHandleResign:
    """Tests for the handle_resign event handler."""

    @pytest.mark.asyncio
    async def test_resign_success(
        self,
        handler: GameNamespaceHandler,
        mock_game_service: AsyncMock,
        mock_connection_manager: AsyncMock,
        mock_sio: AsyncMock,
        sample_game_state: GameState,
    ) -> None:
        """Test successful resignation.

        Resignation should end the game with the opponent as winner.
        """
        mock_game_service.resign_game.return_value = GameActionResult(
            success=True,
            game_id="game-123",
            action_type="resign",
            game_over=True,
            winner_id="player-2",
            end_reason=GameEndReason.RESIGNATION,
        )
        mock_game_service.get_game_state.return_value = sample_game_state
        mock_game_service.end_game.return_value = GameEndResult(
            success=True,
            game_id="game-123",
            winner_id="player-2",
        )
        mock_connection_manager.get_game_user_sids.return_value = {}

        result = await handler.handle_resign(
            mock_sio,
            "sid-123",
            "player-1",
            {"game_id": "game-123"},
        )

        assert result["success"] is True
        assert result["game_over"] is True
        assert result["winner_id"] == "player-2"

        # Should have called end_game
        mock_game_service.end_game.assert_called_once()

    @pytest.mark.asyncio
    async def test_resign_missing_game_id(
        self,
        handler: GameNamespaceHandler,
        mock_sio: AsyncMock,
    ) -> None:
        """Test resignation with missing game_id returns error."""
        result = await handler.handle_resign(
            mock_sio,
            "sid-123",
            "player-1",
            {},
        )

        assert result["success"] is False
        assert result["error"]["code"] == WSErrorCode.INVALID_MESSAGE.value

    @pytest.mark.asyncio
    async def test_resign_game_not_found(
        self,
        handler: GameNamespaceHandler,
        mock_game_service: AsyncMock,
        mock_sio: AsyncMock,
    ) -> None:
        """Test resignation from non-existent game returns error."""
        mock_game_service.resign_game.side_effect = GameNotFoundError("game-123")

        result = await handler.handle_resign(
            mock_sio,
            "sid-123",
            "player-1",
            {"game_id": "game-123"},
        )

        assert result["success"] is False
        assert result["error"]["code"] == WSErrorCode.GAME_NOT_FOUND.value

    @pytest.mark.asyncio
    async def test_resign_game_already_ended(
        self,
        handler: GameNamespaceHandler,
        mock_game_service: AsyncMock,
        mock_sio: AsyncMock,
    ) -> None:
        """Test resignation from ended game returns error."""
        mock_game_service.resign_game.side_effect = GameAlreadyEndedError("game-123")

        result = await handler.handle_resign(
            mock_sio,
            "sid-123",
            "player-1",
            {"game_id": "game-123"},
        )

        assert result["success"] is False
        assert result["error"]["code"] == WSErrorCode.GAME_ENDED.value


class TestHandleDisconnect:
    """Tests for the handle_disconnect event handler."""

    @pytest.mark.asyncio
    async def test_disconnect_notifies_opponent(
        self,
        handler: GameNamespaceHandler,
        mock_connection_manager: AsyncMock,
        mock_sio: AsyncMock,
    ) -> None:
        """Test that disconnect notifies opponent.

        When a player disconnects from a game, the opponent should
        be notified of the disconnection.
        """
        from datetime import UTC, datetime

        mock_connection_manager.get_connection.return_value = ConnectionInfo(
            sid="sid-123",
            user_id="player-1",
            game_id="game-123",
            connected_at=datetime.now(UTC),
            last_seen=datetime.now(UTC),
        )
        mock_connection_manager.get_opponent_sid.return_value = "opponent-sid"

        await handler.handle_disconnect(mock_sio, "sid-123", "player-1")

        # Should have looked up connection to find game
        mock_connection_manager.get_connection.assert_called_once_with("sid-123")

        # Should have emitted opponent status to opponent
        emit_calls = [
            call for call in mock_sio.emit.call_args_list if call[0][0] == "game:opponent_status"
        ]
        assert len(emit_calls) == 1
        assert emit_calls[0][1]["to"] == "opponent-sid"

    @pytest.mark.asyncio
    async def test_disconnect_no_game(
        self,
        handler: GameNamespaceHandler,
        mock_connection_manager: AsyncMock,
        mock_sio: AsyncMock,
    ) -> None:
        """Test disconnect when not in a game does nothing.

        If the player wasn't in a game, there's no opponent to notify.
        """
        mock_connection_manager.get_connection.return_value = None

        await handler.handle_disconnect(mock_sio, "sid-123", "player-1")

        # Should not emit anything
        mock_sio.emit.assert_not_called()

    @pytest.mark.asyncio
    async def test_disconnect_no_game_id_in_connection(
        self,
        handler: GameNamespaceHandler,
        mock_connection_manager: AsyncMock,
        mock_sio: AsyncMock,
    ) -> None:
        """Test disconnect when connection exists but not in game.

        The connection may exist but game_id may be None if they
        haven't joined a game yet.
        """
        from datetime import UTC, datetime

        mock_connection_manager.get_connection.return_value = ConnectionInfo(
            sid="sid-123",
            user_id="player-1",
            game_id=None,  # Not in a game
            connected_at=datetime.now(UTC),
            last_seen=datetime.now(UTC),
        )

        await handler.handle_disconnect(mock_sio, "sid-123", "player-1")

        # Should not emit anything
        mock_sio.emit.assert_not_called()


class TestBroadcastGameState:
    """Tests for the _broadcast_game_state helper method."""

    @pytest.mark.asyncio
    async def test_broadcast_sends_to_each_player(
        self,
        handler: GameNamespaceHandler,
        mock_game_service: AsyncMock,
        mock_connection_manager: AsyncMock,
        mock_sio: AsyncMock,
        sample_game_state: GameState,
    ) -> None:
        """Test that broadcast sends filtered state to each player.

        Each player should receive their own visibility-filtered
        view of the game state.
        """
        mock_game_service.get_game_state.return_value = sample_game_state
        mock_connection_manager.get_game_user_sids.return_value = {
            "player-1": "sid-1",
            "player-2": "sid-2",
        }

        await handler._broadcast_game_state(mock_sio, "game-123")

        # Should have emitted to both players
        assert mock_sio.emit.call_count == 2

        # Verify each emission was to a different player
        emit_calls = mock_sio.emit.call_args_list
        to_sids = {call[1]["to"] for call in emit_calls}
        assert to_sids == {"sid-1", "sid-2"}

        # All emissions should be game:state events
        for call in emit_calls:
            assert call[0][0] == "game:state"

    @pytest.mark.asyncio
    async def test_broadcast_handles_game_not_found(
        self,
        handler: GameNamespaceHandler,
        mock_game_service: AsyncMock,
        mock_sio: AsyncMock,
    ) -> None:
        """Test that broadcast handles missing game gracefully.

        If the game can't be found (e.g., already archived), the
        broadcast should log a warning but not raise an error.
        """
        mock_game_service.get_game_state.side_effect = GameNotFoundError("game-123")

        # Should not raise
        await handler._broadcast_game_state(mock_sio, "game-123")

        # Should not have emitted anything
        mock_sio.emit.assert_not_called()


class TestNotifyOpponentStatus:
    """Tests for the _notify_opponent_status helper method."""

    @pytest.mark.asyncio
    async def test_notify_sends_to_opponent(
        self,
        handler: GameNamespaceHandler,
        mock_connection_manager: AsyncMock,
        mock_sio: AsyncMock,
    ) -> None:
        """Test that notify sends status to opponent.

        The opponent's connection should receive a status update
        about the player's connection state.
        """
        mock_connection_manager.get_opponent_sid.return_value = "opponent-sid"

        await handler._notify_opponent_status(
            mock_sio,
            "sender-sid",
            "game-123",
            "player-1",
            ConnectionStatus.CONNECTED,
        )

        mock_sio.emit.assert_called_once()
        call = mock_sio.emit.call_args
        assert call[0][0] == "game:opponent_status"
        assert call[1]["to"] == "opponent-sid"
        message = call[0][1]
        assert message["opponent_id"] == "player-1"
        assert message["status"] == ConnectionStatus.CONNECTED.value

    @pytest.mark.asyncio
    async def test_notify_no_opponent_connected(
        self,
        handler: GameNamespaceHandler,
        mock_connection_manager: AsyncMock,
        mock_sio: AsyncMock,
    ) -> None:
        """Test that notify does nothing when opponent not connected.

        If the opponent isn't connected, we can't notify them.
        """
        mock_connection_manager.get_opponent_sid.return_value = None

        await handler._notify_opponent_status(
            mock_sio,
            "sender-sid",
            "game-123",
            "player-1",
            ConnectionStatus.CONNECTED,
        )

        mock_sio.emit.assert_not_called()


class TestErrorResponse:
    """Tests for the _error_response helper method."""

    def test_error_response_format(
        self,
        handler: GameNamespaceHandler,
    ) -> None:
        """Test that error response has correct format.

        Error responses should include success=False, error code,
        message, and the original request's message_id.
        """
        result = handler._error_response(
            WSErrorCode.GAME_NOT_FOUND,
            "Game not found",
            "msg-123",
        )

        assert result["success"] is False
        assert result["error"]["code"] == WSErrorCode.GAME_NOT_FOUND.value
        assert result["error"]["message"] == "Game not found"
        assert result["request_message_id"] == "msg-123"


class TestHandleReconnect:
    """Tests for the handle_reconnect event handler.

    The reconnection handler is called after successful authentication
    to check if the user has an active game and auto-rejoin them.
    """

    @pytest.fixture
    def mock_active_game(self) -> AsyncMock:
        """Create a mock ActiveGame record.

        ActiveGame represents a game in progress stored in Postgres.
        """
        from datetime import UTC, datetime
        from uuid import UUID

        game = AsyncMock()
        game.id = UUID("12345678-1234-5678-1234-567812345678")
        game.started_at = datetime.now(UTC)
        game.last_action_at = datetime.now(UTC)
        return game

    @pytest.mark.asyncio
    async def test_reconnect_no_active_games(
        self,
        handler: GameNamespaceHandler,
        mock_state_manager: AsyncMock,
        mock_sio: AsyncMock,
    ) -> None:
        """Test that reconnect returns None when user has no active games.

        If the user isn't in any games, there's nothing to reconnect to.
        """
        mock_state_manager.get_player_active_games.return_value = []

        result = await handler.handle_reconnect(
            mock_sio,
            "sid-123",
            "12345678-1234-5678-1234-567812345678",
        )

        assert result is None
        mock_state_manager.get_player_active_games.assert_called_once()

    @pytest.mark.asyncio
    async def test_reconnect_invalid_uuid_returns_none(
        self,
        handler: GameNamespaceHandler,
        mock_state_manager: AsyncMock,
        mock_sio: AsyncMock,
    ) -> None:
        """Test that invalid user_id (non-UUID) returns None.

        NPC IDs are not valid UUIDs and shouldn't have active games.
        """
        result = await handler.handle_reconnect(
            mock_sio,
            "sid-123",
            "npc-grass-trainer-1",  # Not a valid UUID
        )

        assert result is None
        # Should not have queried for active games
        mock_state_manager.get_player_active_games.assert_not_called()

    @pytest.mark.asyncio
    async def test_reconnect_success(
        self,
        handler: GameNamespaceHandler,
        mock_game_service: AsyncMock,
        mock_connection_manager: AsyncMock,
        mock_state_manager: AsyncMock,
        mock_sio: AsyncMock,
        mock_active_game: AsyncMock,
        sample_visible_state: VisibleGameState,
    ) -> None:
        """Test successful reconnection to an active game.

        When a user connects with an active game, they should be
        automatically rejoined to that game.
        """
        mock_state_manager.get_player_active_games.return_value = [mock_active_game]
        mock_game_service.join_game.return_value = GameJoinResult(
            success=True,
            game_id=str(mock_active_game.id),
            player_id="12345678-1234-5678-1234-567812345678",
            visible_state=sample_visible_state,
            is_your_turn=True,
            game_over=False,
        )

        result = await handler.handle_reconnect(
            mock_sio,
            "sid-123",
            "12345678-1234-5678-1234-567812345678",
        )

        assert result is not None
        assert result["game_id"] == str(mock_active_game.id)
        assert result["is_your_turn"] is True
        assert "state" in result

        # Should have joined connection manager
        mock_connection_manager.join_game.assert_called_once_with(
            "sid-123", str(mock_active_game.id)
        )

        # Should have entered the room
        mock_sio.enter_room.assert_called_once()

    @pytest.mark.asyncio
    async def test_reconnect_notifies_opponent(
        self,
        handler: GameNamespaceHandler,
        mock_game_service: AsyncMock,
        mock_connection_manager: AsyncMock,
        mock_state_manager: AsyncMock,
        mock_sio: AsyncMock,
        mock_active_game: AsyncMock,
        sample_visible_state: VisibleGameState,
    ) -> None:
        """Test that reconnection notifies the opponent.

        When a player reconnects, their opponent should be notified
        that they're back online.
        """
        mock_state_manager.get_player_active_games.return_value = [mock_active_game]
        mock_game_service.join_game.return_value = GameJoinResult(
            success=True,
            game_id=str(mock_active_game.id),
            player_id="12345678-1234-5678-1234-567812345678",
            visible_state=sample_visible_state,
            is_your_turn=True,
        )
        mock_connection_manager.get_opponent_sid.return_value = "opponent-sid"

        await handler.handle_reconnect(
            mock_sio,
            "sid-123",
            "12345678-1234-5678-1234-567812345678",
        )

        # Should have tried to notify opponent
        mock_connection_manager.get_opponent_sid.assert_called()

        # Should have emitted opponent status
        emit_calls = [
            call for call in mock_sio.emit.call_args_list if call[0][0] == "game:opponent_status"
        ]
        assert len(emit_calls) == 1

    @pytest.mark.asyncio
    async def test_reconnect_includes_pending_forced_action(
        self,
        handler: GameNamespaceHandler,
        mock_game_service: AsyncMock,
        mock_connection_manager: AsyncMock,
        mock_state_manager: AsyncMock,
        mock_sio: AsyncMock,
        mock_active_game: AsyncMock,
        sample_visible_state: VisibleGameState,
    ) -> None:
        """Test that reconnect includes pending forced action.

        If the game has a pending forced action when the player
        reconnects, it should be included in the response.
        """
        mock_state_manager.get_player_active_games.return_value = [mock_active_game]
        mock_game_service.join_game.return_value = GameJoinResult(
            success=True,
            game_id=str(mock_active_game.id),
            player_id="12345678-1234-5678-1234-567812345678",
            visible_state=sample_visible_state,
            is_your_turn=True,
            pending_forced_action=PendingForcedAction(
                player_id="12345678-1234-5678-1234-567812345678",
                action_type="select_active",
                reason="Your active Pokemon was knocked out.",
                params={"available_bench_ids": ["bench-1"]},
            ),
        )

        result = await handler.handle_reconnect(
            mock_sio,
            "sid-123",
            "12345678-1234-5678-1234-567812345678",
        )

        assert result is not None
        assert "pending_forced_action" in result
        assert result["pending_forced_action"]["action_type"] == "select_active"

    @pytest.mark.asyncio
    async def test_reconnect_includes_turn_timer(
        self,
        handler: GameNamespaceHandler,
        mock_game_service: AsyncMock,
        mock_connection_manager: AsyncMock,
        mock_state_manager: AsyncMock,
        mock_sio: AsyncMock,
        mock_active_game: AsyncMock,
        sample_visible_state: VisibleGameState,
    ) -> None:
        """Test that reconnect includes turn timer information.

        If turn timers are enabled, the remaining time should be
        included in the reconnection response.
        """
        mock_state_manager.get_player_active_games.return_value = [mock_active_game]
        mock_game_service.join_game.return_value = GameJoinResult(
            success=True,
            game_id=str(mock_active_game.id),
            player_id="12345678-1234-5678-1234-567812345678",
            visible_state=sample_visible_state,
            is_your_turn=True,
            turn_timeout_seconds=120,
            turn_deadline=1700000000.0,
        )

        result = await handler.handle_reconnect(
            mock_sio,
            "sid-123",
            "12345678-1234-5678-1234-567812345678",
        )

        assert result is not None
        assert result["turn_timeout_seconds"] == 120
        assert result["turn_deadline"] == 1700000000.0

    @pytest.mark.asyncio
    async def test_reconnect_join_game_fails(
        self,
        handler: GameNamespaceHandler,
        mock_game_service: AsyncMock,
        mock_state_manager: AsyncMock,
        mock_sio: AsyncMock,
        mock_active_game: AsyncMock,
    ) -> None:
        """Test that reconnect returns None when join_game fails.

        If the GameService fails to join the game (e.g., game was archived
        between lookup and join), we should return None gracefully.
        """
        mock_state_manager.get_player_active_games.return_value = [mock_active_game]
        mock_game_service.join_game.return_value = GameJoinResult(
            success=False,
            game_id=str(mock_active_game.id),
            player_id="12345678-1234-5678-1234-567812345678",
            message="Game not found",
        )

        result = await handler.handle_reconnect(
            mock_sio,
            "sid-123",
            "12345678-1234-5678-1234-567812345678",
        )

        assert result is None

    @pytest.mark.asyncio
    async def test_reconnect_multiple_games_uses_most_recent(
        self,
        handler: GameNamespaceHandler,
        mock_game_service: AsyncMock,
        mock_connection_manager: AsyncMock,
        mock_state_manager: AsyncMock,
        mock_sio: AsyncMock,
        sample_visible_state: VisibleGameState,
    ) -> None:
        """Test that with multiple active games, the most recent is used.

        If a player has multiple active games (edge case), we should
        reconnect to the one with the most recent activity.
        """
        from datetime import UTC, datetime, timedelta
        from uuid import UUID

        older_game = AsyncMock()
        older_game.id = UUID("11111111-1111-1111-1111-111111111111")
        older_game.started_at = datetime.now(UTC) - timedelta(hours=2)
        older_game.last_action_at = datetime.now(UTC) - timedelta(hours=1)

        newer_game = AsyncMock()
        newer_game.id = UUID("22222222-2222-2222-2222-222222222222")
        newer_game.started_at = datetime.now(UTC) - timedelta(hours=1)
        newer_game.last_action_at = datetime.now(UTC) - timedelta(minutes=5)

        # Return older game first to verify sorting works
        mock_state_manager.get_player_active_games.return_value = [older_game, newer_game]
        mock_game_service.join_game.return_value = GameJoinResult(
            success=True,
            game_id=str(newer_game.id),
            player_id="12345678-1234-5678-1234-567812345678",
            visible_state=sample_visible_state,
            is_your_turn=True,
        )

        result = await handler.handle_reconnect(
            mock_sio,
            "sid-123",
            "12345678-1234-5678-1234-567812345678",
        )

        assert result is not None
        # Should have joined the newer game
        assert result["game_id"] == str(newer_game.id)

    @pytest.mark.asyncio
    async def test_reconnect_to_ended_game(
        self,
        handler: GameNamespaceHandler,
        mock_game_service: AsyncMock,
        mock_connection_manager: AsyncMock,
        mock_state_manager: AsyncMock,
        mock_sio: AsyncMock,
        mock_active_game: AsyncMock,
        sample_visible_state: VisibleGameState,
    ) -> None:
        """Test reconnection to a game that has ended.

        If the game ended while the player was disconnected, they
        should still see the final state with game_over=True.
        """
        mock_state_manager.get_player_active_games.return_value = [mock_active_game]
        mock_game_service.join_game.return_value = GameJoinResult(
            success=True,
            game_id=str(mock_active_game.id),
            player_id="12345678-1234-5678-1234-567812345678",
            visible_state=sample_visible_state,
            is_your_turn=False,
            game_over=True,
            message="Game has ended",
        )

        result = await handler.handle_reconnect(
            mock_sio,
            "sid-123",
            "12345678-1234-5678-1234-567812345678",
        )

        assert result is not None
        assert result["game_over"] is True
