"""
Tests for WebSocket substitution handlers.

Covers all three substitution types:
- Pinch hitter (batting substitution)
- Defensive replacement (field substitution)
- Pitching change (pitcher substitution)

Each handler follows the same pattern: validate input, acquire lock,
call SubstitutionManager, broadcast results.

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

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

from app.core.substitution_manager import SubstitutionResult


# ============================================================================
# TEST FIXTURES
# ============================================================================


@pytest.fixture
def mock_sub_result_success():
    """Create a successful substitution result."""
    return SubstitutionResult(
        success=True,
        player_out_lineup_id=1,
        player_in_card_id=999,
        new_lineup_id=100,
        new_position="CF",
        new_batting_order=1,
        error_message=None,
        error_code=None,
    )


@pytest.fixture
def mock_sub_result_failure():
    """Create a failed substitution result."""
    return SubstitutionResult(
        success=False,
        player_out_lineup_id=1,
        player_in_card_id=999,
        new_lineup_id=None,
        new_position=None,
        new_batting_order=None,
        error_message="Player not found in roster",
        error_code="PLAYER_NOT_IN_ROSTER",
    )


@pytest.fixture
def base_sub_data():
    """Base data for substitution requests."""
    return {
        "game_id": str(uuid4()),
        "player_out_lineup_id": 1,
        "player_in_card_id": 999,
        "team_id": 1,
    }


# ============================================================================
# PINCH HITTER TESTS
# ============================================================================


class TestPinchHitter:
    """Tests for request_pinch_hitter handler."""

    @pytest.mark.asyncio
    async def test_pinch_hitter_success(
        self, mock_manager, mock_game_state, mock_sub_result_success
    ):
        """
        Verify successful pinch hitter substitution.

        Should call SubstitutionManager.pinch_hit(), broadcast player_substituted,
        and confirm to requester.
        """
        from socketio import AsyncServer

        sio = AsyncServer()

        @asynccontextmanager
        async def mock_lock(game_id, timeout=30.0):
            yield

        with patch("app.websocket.handlers.state_manager") as mock_state_mgr, \
             patch("app.websocket.handlers.SubstitutionManager") as MockSubMgr:
            mock_state_mgr.get_state.return_value = mock_game_state
            mock_state_mgr.game_lock = mock_lock

            mock_sub_instance = MagicMock()
            mock_sub_instance.pinch_hit = AsyncMock(return_value=mock_sub_result_success)
            MockSubMgr.return_value = mock_sub_instance

            from app.websocket.handlers import register_handlers

            register_handlers(sio, mock_manager)
            handler = sio.handlers["/"]["request_pinch_hitter"]

            await handler(
                "test_sid",
                {
                    "game_id": str(mock_game_state.game_id),
                    "player_out_lineup_id": 1,
                    "player_in_card_id": 999,
                    "team_id": 1,
                },
            )

            # Verify SubstitutionManager called
            mock_sub_instance.pinch_hit.assert_called_once()

            # Verify broadcast
            mock_manager.broadcast_to_game.assert_called()
            broadcast_call = mock_manager.broadcast_to_game.call_args
            assert broadcast_call[0][1] == "player_substituted"
            assert broadcast_call[0][2]["type"] == "pinch_hitter"

            # Verify confirmation to requester
            mock_manager.emit_to_user.assert_called()
            confirm_call = mock_manager.emit_to_user.call_args
            assert confirm_call[0][1] == "substitution_confirmed"

    @pytest.mark.asyncio
    async def test_pinch_hitter_missing_game_id(self, mock_manager):
        """Verify error when game_id is missing."""
        from socketio import AsyncServer
        from app.websocket.handlers import register_handlers

        sio = AsyncServer()
        register_handlers(sio, mock_manager)
        handler = sio.handlers["/"]["request_pinch_hitter"]

        await handler(
            "test_sid",
            {"player_out_lineup_id": 1, "player_in_card_id": 999, "team_id": 1},
        )

        mock_manager.emit_to_user.assert_called_once()
        call_args = mock_manager.emit_to_user.call_args
        assert call_args[0][1] == "substitution_error"
        assert "MISSING_FIELD" in call_args[0][2]["code"]

    @pytest.mark.asyncio
    async def test_pinch_hitter_invalid_game_id(self, mock_manager):
        """Verify error when game_id is invalid UUID."""
        from socketio import AsyncServer
        from app.websocket.handlers import register_handlers

        sio = AsyncServer()
        register_handlers(sio, mock_manager)
        handler = sio.handlers["/"]["request_pinch_hitter"]

        await handler(
            "test_sid",
            {
                "game_id": "not-a-uuid",
                "player_out_lineup_id": 1,
                "player_in_card_id": 999,
                "team_id": 1,
            },
        )

        call_args = mock_manager.emit_to_user.call_args
        assert call_args[0][1] == "substitution_error"
        assert "INVALID_FORMAT" in call_args[0][2]["code"]

    @pytest.mark.asyncio
    async def test_pinch_hitter_game_not_found(self, mock_manager):
        """Verify error when game doesn't exist."""
        from socketio import AsyncServer

        sio = AsyncServer()

        with patch("app.websocket.handlers.state_manager") as mock_state_mgr:
            mock_state_mgr.get_state.return_value = None

            from app.websocket.handlers import register_handlers

            register_handlers(sio, mock_manager)
            handler = sio.handlers["/"]["request_pinch_hitter"]

            await handler(
                "test_sid",
                {
                    "game_id": str(uuid4()),
                    "player_out_lineup_id": 1,
                    "player_in_card_id": 999,
                    "team_id": 1,
                },
            )

            call_args = mock_manager.emit_to_user.call_args
            assert "not found" in call_args[0][2]["message"]

    @pytest.mark.asyncio
    async def test_pinch_hitter_missing_player_out(self, mock_manager, mock_game_state):
        """Verify error when player_out_lineup_id is missing."""
        from socketio import AsyncServer

        sio = AsyncServer()

        with patch("app.websocket.handlers.state_manager") as mock_state_mgr:
            mock_state_mgr.get_state.return_value = mock_game_state

            from app.websocket.handlers import register_handlers

            register_handlers(sio, mock_manager)
            handler = sio.handlers["/"]["request_pinch_hitter"]

            await handler(
                "test_sid",
                {
                    "game_id": str(mock_game_state.game_id),
                    "player_in_card_id": 999,
                    "team_id": 1,
                },
            )

            call_args = mock_manager.emit_to_user.call_args
            assert call_args[0][1] == "substitution_error"
            assert "player_out" in call_args[0][2]["message"].lower()

    @pytest.mark.asyncio
    async def test_pinch_hitter_missing_player_in(self, mock_manager, mock_game_state):
        """Verify error when player_in_card_id is missing."""
        from socketio import AsyncServer

        sio = AsyncServer()

        with patch("app.websocket.handlers.state_manager") as mock_state_mgr:
            mock_state_mgr.get_state.return_value = mock_game_state

            from app.websocket.handlers import register_handlers

            register_handlers(sio, mock_manager)
            handler = sio.handlers["/"]["request_pinch_hitter"]

            await handler(
                "test_sid",
                {
                    "game_id": str(mock_game_state.game_id),
                    "player_out_lineup_id": 1,
                    "team_id": 1,
                },
            )

            call_args = mock_manager.emit_to_user.call_args
            assert call_args[0][1] == "substitution_error"
            assert "player_in" in call_args[0][2]["message"].lower()

    @pytest.mark.asyncio
    async def test_pinch_hitter_missing_team_id(self, mock_manager, mock_game_state):
        """Verify error when team_id is missing."""
        from socketio import AsyncServer

        sio = AsyncServer()

        with patch("app.websocket.handlers.state_manager") as mock_state_mgr:
            mock_state_mgr.get_state.return_value = mock_game_state

            from app.websocket.handlers import register_handlers

            register_handlers(sio, mock_manager)
            handler = sio.handlers["/"]["request_pinch_hitter"]

            await handler(
                "test_sid",
                {
                    "game_id": str(mock_game_state.game_id),
                    "player_out_lineup_id": 1,
                    "player_in_card_id": 999,
                },
            )

            call_args = mock_manager.emit_to_user.call_args
            assert call_args[0][1] == "substitution_error"
            assert "team_id" in call_args[0][2]["message"].lower()

    @pytest.mark.asyncio
    async def test_pinch_hitter_validation_failure(
        self, mock_manager, mock_game_state, mock_sub_result_failure
    ):
        """Verify SubstitutionManager failure is propagated to user."""
        from socketio import AsyncServer

        sio = AsyncServer()

        @asynccontextmanager
        async def mock_lock(game_id, timeout=30.0):
            yield

        with patch("app.websocket.handlers.state_manager") as mock_state_mgr, \
             patch("app.websocket.handlers.SubstitutionManager") as MockSubMgr:
            mock_state_mgr.get_state.return_value = mock_game_state
            mock_state_mgr.game_lock = mock_lock

            mock_sub_instance = MagicMock()
            mock_sub_instance.pinch_hit = AsyncMock(return_value=mock_sub_result_failure)
            MockSubMgr.return_value = mock_sub_instance

            from app.websocket.handlers import register_handlers

            register_handlers(sio, mock_manager)
            handler = sio.handlers["/"]["request_pinch_hitter"]

            await handler(
                "test_sid",
                {
                    "game_id": str(mock_game_state.game_id),
                    "player_out_lineup_id": 1,
                    "player_in_card_id": 999,
                    "team_id": 1,
                },
            )

            call_args = mock_manager.emit_to_user.call_args
            assert call_args[0][1] == "substitution_error"
            assert call_args[0][2]["code"] == "PLAYER_NOT_IN_ROSTER"

    @pytest.mark.asyncio
    async def test_pinch_hitter_lock_timeout(self, mock_manager, mock_game_state):
        """Verify lock timeout returns server busy message."""
        from socketio import AsyncServer

        sio = AsyncServer()

        @asynccontextmanager
        async def timeout_lock(game_id, timeout=30.0):
            raise asyncio.TimeoutError("Lock timeout")
            yield

        with patch("app.websocket.handlers.state_manager") as mock_state_mgr:
            mock_state_mgr.get_state.return_value = mock_game_state
            mock_state_mgr.game_lock = timeout_lock

            from app.websocket.handlers import register_handlers

            register_handlers(sio, mock_manager)
            handler = sio.handlers["/"]["request_pinch_hitter"]

            await handler(
                "test_sid",
                {
                    "game_id": str(mock_game_state.game_id),
                    "player_out_lineup_id": 1,
                    "player_in_card_id": 999,
                    "team_id": 1,
                },
            )

            call_args = mock_manager.emit_to_user.call_args
            assert call_args[0][1] == "error"
            assert "busy" in call_args[0][2]["message"].lower()


# ============================================================================
# DEFENSIVE REPLACEMENT TESTS
# ============================================================================


class TestDefensiveReplacement:
    """Tests for request_defensive_replacement handler."""

    @pytest.mark.asyncio
    async def test_defensive_replacement_success(
        self, mock_manager, mock_game_state, mock_sub_result_success
    ):
        """Verify successful defensive replacement."""
        from socketio import AsyncServer

        sio = AsyncServer()

        @asynccontextmanager
        async def mock_lock(game_id, timeout=30.0):
            yield

        with patch("app.websocket.handlers.state_manager") as mock_state_mgr, \
             patch("app.websocket.handlers.SubstitutionManager") as MockSubMgr:
            mock_state_mgr.get_state.return_value = mock_game_state
            mock_state_mgr.game_lock = mock_lock

            mock_sub_instance = MagicMock()
            mock_sub_instance.defensive_replace = AsyncMock(
                return_value=mock_sub_result_success
            )
            MockSubMgr.return_value = mock_sub_instance

            from app.websocket.handlers import register_handlers

            register_handlers(sio, mock_manager)
            handler = sio.handlers["/"]["request_defensive_replacement"]

            await handler(
                "test_sid",
                {
                    "game_id": str(mock_game_state.game_id),
                    "player_out_lineup_id": 1,
                    "player_in_card_id": 999,
                    "new_position": "CF",
                    "team_id": 1,
                },
            )

            # Verify SubstitutionManager called with position
            mock_sub_instance.defensive_replace.assert_called_once()
            call_kwargs = mock_sub_instance.defensive_replace.call_args[1]
            assert call_kwargs["new_position"] == "CF"

            # Verify broadcast type
            broadcast_call = mock_manager.broadcast_to_game.call_args
            assert broadcast_call[0][2]["type"] == "defensive_replacement"

    @pytest.mark.asyncio
    async def test_defensive_replacement_missing_game_id(self, mock_manager):
        """Verify error when game_id is missing."""
        from socketio import AsyncServer
        from app.websocket.handlers import register_handlers

        sio = AsyncServer()
        register_handlers(sio, mock_manager)
        handler = sio.handlers["/"]["request_defensive_replacement"]

        await handler(
            "test_sid",
            {
                "player_out_lineup_id": 1,
                "player_in_card_id": 999,
                "new_position": "CF",
                "team_id": 1,
            },
        )

        call_args = mock_manager.emit_to_user.call_args
        assert call_args[0][1] == "substitution_error"
        assert "MISSING_FIELD" in call_args[0][2]["code"]

    @pytest.mark.asyncio
    async def test_defensive_replacement_invalid_game_id(self, mock_manager):
        """Verify error when game_id is invalid."""
        from socketio import AsyncServer
        from app.websocket.handlers import register_handlers

        sio = AsyncServer()
        register_handlers(sio, mock_manager)
        handler = sio.handlers["/"]["request_defensive_replacement"]

        await handler(
            "test_sid",
            {
                "game_id": "invalid",
                "player_out_lineup_id": 1,
                "player_in_card_id": 999,
                "new_position": "CF",
                "team_id": 1,
            },
        )

        call_args = mock_manager.emit_to_user.call_args
        assert "INVALID_FORMAT" in call_args[0][2]["code"]

    @pytest.mark.asyncio
    async def test_defensive_replacement_game_not_found(self, mock_manager):
        """Verify error when game doesn't exist."""
        from socketio import AsyncServer

        sio = AsyncServer()

        with patch("app.websocket.handlers.state_manager") as mock_state_mgr:
            mock_state_mgr.get_state.return_value = None

            from app.websocket.handlers import register_handlers

            register_handlers(sio, mock_manager)
            handler = sio.handlers["/"]["request_defensive_replacement"]

            await handler(
                "test_sid",
                {
                    "game_id": str(uuid4()),
                    "player_out_lineup_id": 1,
                    "player_in_card_id": 999,
                    "new_position": "CF",
                    "team_id": 1,
                },
            )

            assert "not found" in mock_manager.emit_to_user.call_args[0][2]["message"]

    @pytest.mark.asyncio
    async def test_defensive_replacement_missing_position(
        self, mock_manager, mock_game_state
    ):
        """Verify error when new_position is missing."""
        from socketio import AsyncServer

        sio = AsyncServer()

        with patch("app.websocket.handlers.state_manager") as mock_state_mgr:
            mock_state_mgr.get_state.return_value = mock_game_state

            from app.websocket.handlers import register_handlers

            register_handlers(sio, mock_manager)
            handler = sio.handlers["/"]["request_defensive_replacement"]

            await handler(
                "test_sid",
                {
                    "game_id": str(mock_game_state.game_id),
                    "player_out_lineup_id": 1,
                    "player_in_card_id": 999,
                    "team_id": 1,
                },
            )

            call_args = mock_manager.emit_to_user.call_args
            assert call_args[0][1] == "substitution_error"
            assert "position" in call_args[0][2]["message"].lower()

    @pytest.mark.asyncio
    async def test_defensive_replacement_validation_failure(
        self, mock_manager, mock_game_state, mock_sub_result_failure
    ):
        """Verify SubstitutionManager failure is propagated."""
        from socketio import AsyncServer

        sio = AsyncServer()

        @asynccontextmanager
        async def mock_lock(game_id, timeout=30.0):
            yield

        with patch("app.websocket.handlers.state_manager") as mock_state_mgr, \
             patch("app.websocket.handlers.SubstitutionManager") as MockSubMgr:
            mock_state_mgr.get_state.return_value = mock_game_state
            mock_state_mgr.game_lock = mock_lock

            mock_sub_instance = MagicMock()
            mock_sub_instance.defensive_replace = AsyncMock(
                return_value=mock_sub_result_failure
            )
            MockSubMgr.return_value = mock_sub_instance

            from app.websocket.handlers import register_handlers

            register_handlers(sio, mock_manager)
            handler = sio.handlers["/"]["request_defensive_replacement"]

            await handler(
                "test_sid",
                {
                    "game_id": str(mock_game_state.game_id),
                    "player_out_lineup_id": 1,
                    "player_in_card_id": 999,
                    "new_position": "CF",
                    "team_id": 1,
                },
            )

            call_args = mock_manager.emit_to_user.call_args
            assert call_args[0][1] == "substitution_error"

    @pytest.mark.asyncio
    async def test_defensive_replacement_lock_timeout(
        self, mock_manager, mock_game_state
    ):
        """Verify lock timeout returns server busy message."""
        from socketio import AsyncServer

        sio = AsyncServer()

        @asynccontextmanager
        async def timeout_lock(game_id, timeout=30.0):
            raise asyncio.TimeoutError("Lock timeout")
            yield

        with patch("app.websocket.handlers.state_manager") as mock_state_mgr:
            mock_state_mgr.get_state.return_value = mock_game_state
            mock_state_mgr.game_lock = timeout_lock

            from app.websocket.handlers import register_handlers

            register_handlers(sio, mock_manager)
            handler = sio.handlers["/"]["request_defensive_replacement"]

            await handler(
                "test_sid",
                {
                    "game_id": str(mock_game_state.game_id),
                    "player_out_lineup_id": 1,
                    "player_in_card_id": 999,
                    "new_position": "CF",
                    "team_id": 1,
                },
            )

            call_args = mock_manager.emit_to_user.call_args
            assert "busy" in call_args[0][2]["message"].lower()

    @pytest.mark.asyncio
    async def test_defensive_replacement_missing_player_out(
        self, mock_manager, mock_game_state
    ):
        """
        Verify error when player_out_lineup_id is missing.

        Defensive replacement requires specifying which player is being replaced.
        """
        from socketio import AsyncServer

        sio = AsyncServer()

        with patch("app.websocket.handlers.state_manager") as mock_state_mgr:
            mock_state_mgr.get_state.return_value = mock_game_state

            from app.websocket.handlers import register_handlers

            register_handlers(sio, mock_manager)
            handler = sio.handlers["/"]["request_defensive_replacement"]

            await handler(
                "test_sid",
                {
                    "game_id": str(mock_game_state.game_id),
                    "player_in_card_id": 999,
                    "new_position": "CF",
                    "team_id": 1,
                },
            )

            call_args = mock_manager.emit_to_user.call_args
            assert call_args[0][1] == "substitution_error"
            assert "player_out" in call_args[0][2]["message"].lower()

    @pytest.mark.asyncio
    async def test_defensive_replacement_missing_player_in(
        self, mock_manager, mock_game_state
    ):
        """
        Verify error when player_in_card_id is missing.

        Defensive replacement requires specifying which player is entering.
        """
        from socketio import AsyncServer

        sio = AsyncServer()

        with patch("app.websocket.handlers.state_manager") as mock_state_mgr:
            mock_state_mgr.get_state.return_value = mock_game_state

            from app.websocket.handlers import register_handlers

            register_handlers(sio, mock_manager)
            handler = sio.handlers["/"]["request_defensive_replacement"]

            await handler(
                "test_sid",
                {
                    "game_id": str(mock_game_state.game_id),
                    "player_out_lineup_id": 1,
                    "new_position": "CF",
                    "team_id": 1,
                },
            )

            call_args = mock_manager.emit_to_user.call_args
            assert call_args[0][1] == "substitution_error"
            assert "player_in" in call_args[0][2]["message"].lower()

    @pytest.mark.asyncio
    async def test_defensive_replacement_missing_team_id(
        self, mock_manager, mock_game_state
    ):
        """
        Verify error when team_id is missing.

        Defensive replacement must specify which team is making the substitution.
        """
        from socketio import AsyncServer

        sio = AsyncServer()

        with patch("app.websocket.handlers.state_manager") as mock_state_mgr:
            mock_state_mgr.get_state.return_value = mock_game_state

            from app.websocket.handlers import register_handlers

            register_handlers(sio, mock_manager)
            handler = sio.handlers["/"]["request_defensive_replacement"]

            await handler(
                "test_sid",
                {
                    "game_id": str(mock_game_state.game_id),
                    "player_out_lineup_id": 1,
                    "player_in_card_id": 999,
                    "new_position": "CF",
                },
            )

            call_args = mock_manager.emit_to_user.call_args
            assert call_args[0][1] == "substitution_error"
            assert "team_id" in call_args[0][2]["message"].lower()


# ============================================================================
# PITCHING CHANGE TESTS
# ============================================================================


class TestPitchingChange:
    """Tests for request_pitching_change handler."""

    @pytest.mark.asyncio
    async def test_pitching_change_success(
        self, mock_manager, mock_game_state, mock_sub_result_success
    ):
        """Verify successful pitching change."""
        from socketio import AsyncServer

        sio = AsyncServer()
        mock_sub_result_success.new_position = "P"  # Override for pitcher

        @asynccontextmanager
        async def mock_lock(game_id, timeout=30.0):
            yield

        with patch("app.websocket.handlers.state_manager") as mock_state_mgr, \
             patch("app.websocket.handlers.SubstitutionManager") as MockSubMgr:
            mock_state_mgr.get_state.return_value = mock_game_state
            mock_state_mgr.game_lock = mock_lock

            mock_sub_instance = MagicMock()
            mock_sub_instance.change_pitcher = AsyncMock(
                return_value=mock_sub_result_success
            )
            MockSubMgr.return_value = mock_sub_instance

            from app.websocket.handlers import register_handlers

            register_handlers(sio, mock_manager)
            handler = sio.handlers["/"]["request_pitching_change"]

            await handler(
                "test_sid",
                {
                    "game_id": str(mock_game_state.game_id),
                    "player_out_lineup_id": 1,
                    "player_in_card_id": 999,
                    "team_id": 1,
                },
            )

            mock_sub_instance.change_pitcher.assert_called_once()

            broadcast_call = mock_manager.broadcast_to_game.call_args
            assert broadcast_call[0][2]["type"] == "pitching_change"

    @pytest.mark.asyncio
    async def test_pitching_change_missing_game_id(self, mock_manager):
        """Verify error when game_id is missing."""
        from socketio import AsyncServer
        from app.websocket.handlers import register_handlers

        sio = AsyncServer()
        register_handlers(sio, mock_manager)
        handler = sio.handlers["/"]["request_pitching_change"]

        await handler(
            "test_sid",
            {"player_out_lineup_id": 1, "player_in_card_id": 999, "team_id": 1},
        )

        call_args = mock_manager.emit_to_user.call_args
        assert "MISSING_FIELD" in call_args[0][2]["code"]

    @pytest.mark.asyncio
    async def test_pitching_change_invalid_game_id(self, mock_manager):
        """Verify error when game_id is invalid."""
        from socketio import AsyncServer
        from app.websocket.handlers import register_handlers

        sio = AsyncServer()
        register_handlers(sio, mock_manager)
        handler = sio.handlers["/"]["request_pitching_change"]

        await handler(
            "test_sid",
            {
                "game_id": "bad-uuid",
                "player_out_lineup_id": 1,
                "player_in_card_id": 999,
                "team_id": 1,
            },
        )

        call_args = mock_manager.emit_to_user.call_args
        assert "INVALID_FORMAT" in call_args[0][2]["code"]

    @pytest.mark.asyncio
    async def test_pitching_change_game_not_found(self, mock_manager):
        """Verify error when game doesn't exist."""
        from socketio import AsyncServer

        sio = AsyncServer()

        with patch("app.websocket.handlers.state_manager") as mock_state_mgr:
            mock_state_mgr.get_state.return_value = None

            from app.websocket.handlers import register_handlers

            register_handlers(sio, mock_manager)
            handler = sio.handlers["/"]["request_pitching_change"]

            await handler(
                "test_sid",
                {
                    "game_id": str(uuid4()),
                    "player_out_lineup_id": 1,
                    "player_in_card_id": 999,
                    "team_id": 1,
                },
            )

            assert "not found" in mock_manager.emit_to_user.call_args[0][2]["message"]

    @pytest.mark.asyncio
    async def test_pitching_change_missing_player_out(
        self, mock_manager, mock_game_state
    ):
        """Verify error when player_out_lineup_id is missing."""
        from socketio import AsyncServer

        sio = AsyncServer()

        with patch("app.websocket.handlers.state_manager") as mock_state_mgr:
            mock_state_mgr.get_state.return_value = mock_game_state

            from app.websocket.handlers import register_handlers

            register_handlers(sio, mock_manager)
            handler = sio.handlers["/"]["request_pitching_change"]

            await handler(
                "test_sid",
                {
                    "game_id": str(mock_game_state.game_id),
                    "player_in_card_id": 999,
                    "team_id": 1,
                },
            )

            call_args = mock_manager.emit_to_user.call_args
            assert call_args[0][1] == "substitution_error"

    @pytest.mark.asyncio
    async def test_pitching_change_missing_player_in(
        self, mock_manager, mock_game_state
    ):
        """Verify error when player_in_card_id is missing."""
        from socketio import AsyncServer

        sio = AsyncServer()

        with patch("app.websocket.handlers.state_manager") as mock_state_mgr:
            mock_state_mgr.get_state.return_value = mock_game_state

            from app.websocket.handlers import register_handlers

            register_handlers(sio, mock_manager)
            handler = sio.handlers["/"]["request_pitching_change"]

            await handler(
                "test_sid",
                {
                    "game_id": str(mock_game_state.game_id),
                    "player_out_lineup_id": 1,
                    "team_id": 1,
                },
            )

            call_args = mock_manager.emit_to_user.call_args
            assert call_args[0][1] == "substitution_error"

    @pytest.mark.asyncio
    async def test_pitching_change_missing_team_id(self, mock_manager, mock_game_state):
        """Verify error when team_id is missing."""
        from socketio import AsyncServer

        sio = AsyncServer()

        with patch("app.websocket.handlers.state_manager") as mock_state_mgr:
            mock_state_mgr.get_state.return_value = mock_game_state

            from app.websocket.handlers import register_handlers

            register_handlers(sio, mock_manager)
            handler = sio.handlers["/"]["request_pitching_change"]

            await handler(
                "test_sid",
                {
                    "game_id": str(mock_game_state.game_id),
                    "player_out_lineup_id": 1,
                    "player_in_card_id": 999,
                },
            )

            call_args = mock_manager.emit_to_user.call_args
            assert call_args[0][1] == "substitution_error"

    @pytest.mark.asyncio
    async def test_pitching_change_validation_failure(
        self, mock_manager, mock_game_state, mock_sub_result_failure
    ):
        """Verify SubstitutionManager failure is propagated."""
        from socketio import AsyncServer

        sio = AsyncServer()

        @asynccontextmanager
        async def mock_lock(game_id, timeout=30.0):
            yield

        with patch("app.websocket.handlers.state_manager") as mock_state_mgr, \
             patch("app.websocket.handlers.SubstitutionManager") as MockSubMgr:
            mock_state_mgr.get_state.return_value = mock_game_state
            mock_state_mgr.game_lock = mock_lock

            mock_sub_instance = MagicMock()
            mock_sub_instance.change_pitcher = AsyncMock(
                return_value=mock_sub_result_failure
            )
            MockSubMgr.return_value = mock_sub_instance

            from app.websocket.handlers import register_handlers

            register_handlers(sio, mock_manager)
            handler = sio.handlers["/"]["request_pitching_change"]

            await handler(
                "test_sid",
                {
                    "game_id": str(mock_game_state.game_id),
                    "player_out_lineup_id": 1,
                    "player_in_card_id": 999,
                    "team_id": 1,
                },
            )

            call_args = mock_manager.emit_to_user.call_args
            assert call_args[0][1] == "substitution_error"

    @pytest.mark.asyncio
    async def test_pitching_change_lock_timeout(self, mock_manager, mock_game_state):
        """Verify lock timeout returns server busy message."""
        from socketio import AsyncServer

        sio = AsyncServer()

        @asynccontextmanager
        async def timeout_lock(game_id, timeout=30.0):
            raise asyncio.TimeoutError("Lock timeout")
            yield

        with patch("app.websocket.handlers.state_manager") as mock_state_mgr:
            mock_state_mgr.get_state.return_value = mock_game_state
            mock_state_mgr.game_lock = timeout_lock

            from app.websocket.handlers import register_handlers

            register_handlers(sio, mock_manager)
            handler = sio.handlers["/"]["request_pitching_change"]

            await handler(
                "test_sid",
                {
                    "game_id": str(mock_game_state.game_id),
                    "player_out_lineup_id": 1,
                    "player_in_card_id": 999,
                    "team_id": 1,
                },
            )

            call_args = mock_manager.emit_to_user.call_args
            assert "busy" in call_args[0][2]["message"].lower()
