"""
Tests for WebSocket handler locking mechanism.

Verifies that state-modifying handlers properly acquire game locks to prevent
race conditions from concurrent operations on the same game.

These tests validate the fix implemented in MASTER_TRACKER task 002.

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 tests.unit.websocket.conftest import get_handler


# ============================================================================
# ROLL_DICE LOCKING TESTS
# ============================================================================


class TestRollDiceLocking:
    """Tests for locking behavior in roll_dice handler."""

    @pytest.mark.asyncio
    async def test_roll_dice_acquires_lock(
        self, mock_manager, mock_game_state, mock_ab_roll
    ):
        """
        Verify roll_dice acquires game lock before modifying state.

        The handler should call state_manager.game_lock() as an async context
        manager before setting state.pending_manual_roll.
        """
        from socketio import AsyncServer

        sio = AsyncServer()
        lock_acquired = False
        lock_game_id = None

        @asynccontextmanager
        async def tracking_lock(game_id, timeout=30.0):
            nonlocal lock_acquired, lock_game_id
            lock_acquired = True
            lock_game_id = game_id
            yield

        with patch("app.websocket.handlers.state_manager") as mock_state_mgr, \
             patch("app.websocket.handlers.dice_system") as mock_dice:
            mock_state_mgr.get_state.return_value = mock_game_state
            mock_state_mgr.update_state = MagicMock()
            mock_state_mgr.game_lock = tracking_lock
            mock_dice.roll_ab.return_value = mock_ab_roll

            from app.websocket.handlers import register_handlers

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

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

            assert lock_acquired, "Handler should acquire game lock"
            assert lock_game_id == mock_game_state.game_id, "Lock should be for correct game"

    @pytest.mark.asyncio
    async def test_roll_dice_releases_lock_on_success(
        self, mock_manager, mock_game_state, mock_ab_roll
    ):
        """
        Verify roll_dice releases lock after successful operation.

        The lock should be released even after successful state modification
        to allow subsequent operations.
        """
        from socketio import AsyncServer

        sio = AsyncServer()
        lock_released = False

        @asynccontextmanager
        async def tracking_lock(game_id, timeout=30.0):
            nonlocal lock_released
            try:
                yield
            finally:
                lock_released = True

        with patch("app.websocket.handlers.state_manager") as mock_state_mgr, \
             patch("app.websocket.handlers.dice_system") as mock_dice:
            mock_state_mgr.get_state.return_value = mock_game_state
            mock_state_mgr.update_state = MagicMock()
            mock_state_mgr.game_lock = tracking_lock
            mock_dice.roll_ab.return_value = mock_ab_roll

            from app.websocket.handlers import register_handlers

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

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

            assert lock_released, "Lock should be released after success"

    @pytest.mark.asyncio
    async def test_roll_dice_releases_lock_on_error(self, mock_manager, mock_game_state):
        """
        Verify roll_dice releases lock even when an error occurs.

        The lock must be released in the finally block to prevent deadlocks.
        """
        from socketio import AsyncServer

        sio = AsyncServer()
        lock_released = False

        @asynccontextmanager
        async def tracking_lock(game_id, timeout=30.0):
            nonlocal lock_released
            try:
                yield
            finally:
                lock_released = True

        with patch("app.websocket.handlers.state_manager") as mock_state_mgr, \
             patch("app.websocket.handlers.dice_system") as mock_dice:
            mock_state_mgr.get_state.return_value = mock_game_state
            mock_state_mgr.update_state = MagicMock()
            mock_state_mgr.game_lock = tracking_lock
            # Simulate dice system error
            mock_dice.roll_ab.side_effect = RuntimeError("Dice error")

            from app.websocket.handlers import register_handlers

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

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

            assert lock_released, "Lock should be released even on error"

    @pytest.mark.asyncio
    async def test_roll_dice_timeout_returns_error(self, mock_manager):
        """
        Verify roll_dice returns user-friendly error on lock timeout.

        When the lock cannot be acquired within the timeout period, the handler
        should emit a 'server busy' error message to the user.
        """
        from socketio import AsyncServer

        sio = AsyncServer()

        @asynccontextmanager
        async def timeout_lock(game_id, timeout=30.0):
            raise asyncio.TimeoutError(f"Lock timeout for game {game_id}")
            yield  # Never reached

        with patch("app.websocket.handlers.state_manager") as mock_state_mgr:
            mock_state_mgr.get_state.return_value = MagicMock(game_id=uuid4(), league_id="sba")
            mock_state_mgr.game_lock = timeout_lock

            from app.websocket.handlers import register_handlers

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

            await handler("test_sid", {"game_id": str(uuid4())})

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


# ============================================================================
# SUBMIT_MANUAL_OUTCOME LOCKING TESTS
# ============================================================================


class TestSubmitOutcomeLocking:
    """Tests for locking behavior in submit_manual_outcome handler."""

    @pytest.mark.asyncio
    async def test_submit_outcome_acquires_lock(
        self, mock_manager, mock_game_state, mock_ab_roll, mock_play_result
    ):
        """
        Verify submit_manual_outcome acquires lock before processing.

        The lock must be acquired before checking/clearing pending_manual_roll
        to prevent race conditions with concurrent submissions.
        """
        from socketio import AsyncServer

        sio = AsyncServer()
        lock_acquired = False
        mock_game_state.pending_manual_roll = mock_ab_roll

        @asynccontextmanager
        async def tracking_lock(game_id, timeout=30.0):
            nonlocal lock_acquired
            lock_acquired = True
            yield

        with patch("app.websocket.handlers.state_manager") as mock_state_mgr, \
             patch("app.websocket.handlers.game_engine") as mock_engine:
            mock_state_mgr.get_state.return_value = mock_game_state
            mock_state_mgr.update_state = MagicMock()
            mock_state_mgr.game_lock = tracking_lock
            mock_engine.resolve_manual_play = AsyncMock(return_value=mock_play_result)

            from app.websocket.handlers import register_handlers

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

            await handler(
                "test_sid",
                {
                    "game_id": str(mock_game_state.game_id),
                    "outcome": "groundball_c",
                    "hit_location": "SS",
                },
            )

            assert lock_acquired, "Handler should acquire game lock"

    @pytest.mark.asyncio
    async def test_submit_outcome_timeout_returns_error(self, mock_manager):
        """
        Verify submit_manual_outcome returns error on lock timeout.

        Same as roll_dice - should emit 'server busy' message.
        """
        from socketio import AsyncServer

        sio = AsyncServer()

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

        with patch("app.websocket.handlers.state_manager") as mock_state_mgr:
            mock_game_state = MagicMock()
            mock_game_state.game_id = uuid4()
            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["/"]["submit_manual_outcome"]

            await handler(
                "test_sid",
                {
                    "game_id": str(uuid4()),
                    "outcome": "groundball_c",
                },
            )

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


# ============================================================================
# SUBSTITUTION HANDLER LOCKING TESTS
# ============================================================================


class TestSubstitutionHandlerLocking:
    """Tests for locking behavior in substitution handlers."""

    @pytest.mark.asyncio
    async def test_pinch_hitter_acquires_lock(
        self, mock_manager, mock_game_state, mock_substitution_result_success
    ):
        """
        Verify request_pinch_hitter acquires lock before substitution.

        Substitutions must be serialized to prevent duplicate substitution
        requests corrupting lineup state.
        """
        from socketio import AsyncServer

        sio = AsyncServer()
        lock_acquired = False

        @asynccontextmanager
        async def tracking_lock(game_id, timeout=30.0):
            nonlocal lock_acquired
            lock_acquired = True
            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 = tracking_lock

            mock_sub_instance = MagicMock()
            mock_sub_instance.pinch_hit = AsyncMock(
                return_value=mock_substitution_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,
                },
            )

            assert lock_acquired, "Pinch hitter should acquire game lock"

    @pytest.mark.asyncio
    async def test_defensive_replacement_acquires_lock(
        self, mock_manager, mock_game_state, mock_substitution_result_success
    ):
        """Verify request_defensive_replacement acquires lock."""
        from socketio import AsyncServer

        sio = AsyncServer()
        lock_acquired = False

        @asynccontextmanager
        async def tracking_lock(game_id, timeout=30.0):
            nonlocal lock_acquired
            lock_acquired = True
            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 = tracking_lock

            mock_sub_instance = MagicMock()
            mock_sub_instance.defensive_replace = AsyncMock(
                return_value=mock_substitution_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,
                },
            )

            assert lock_acquired, "Defensive replacement should acquire game lock"

    @pytest.mark.asyncio
    async def test_pitching_change_acquires_lock(
        self, mock_manager, mock_game_state, mock_substitution_result_success
    ):
        """Verify request_pitching_change acquires lock."""
        from socketio import AsyncServer

        sio = AsyncServer()
        lock_acquired = False

        @asynccontextmanager
        async def tracking_lock(game_id, timeout=30.0):
            nonlocal lock_acquired
            lock_acquired = True
            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 = tracking_lock

            mock_sub_instance = MagicMock()
            mock_sub_instance.change_pitcher = AsyncMock(
                return_value=mock_substitution_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,
                },
            )

            assert lock_acquired, "Pitching change should acquire game lock"


# ============================================================================
# CONCURRENT ACCESS TESTS
# ============================================================================


class TestConcurrentAccess:
    """Tests for behavior under concurrent access scenarios."""

    @pytest.mark.asyncio
    async def test_different_games_do_not_block(self, mock_manager, mock_ab_roll):
        """
        Verify that handlers for different games do not block each other.

        Operations on game_1 should not wait for operations on game_2.
        """
        from socketio import AsyncServer

        sio = AsyncServer()
        game_1_id = uuid4()
        game_2_id = uuid4()
        locks_acquired = {game_1_id: False, game_2_id: False}

        @asynccontextmanager
        async def tracking_lock(game_id, timeout=30.0):
            locks_acquired[game_id] = True
            yield

        mock_state_1 = MagicMock(game_id=game_1_id, league_id="sba")
        mock_state_2 = MagicMock(game_id=game_2_id, league_id="sba")

        with patch("app.websocket.handlers.state_manager") as mock_state_mgr, \
             patch("app.websocket.handlers.dice_system") as mock_dice:

            def get_state(gid):
                if gid == game_1_id:
                    return mock_state_1
                elif gid == game_2_id:
                    return mock_state_2
                return None

            mock_state_mgr.get_state.side_effect = get_state
            mock_state_mgr.update_state = MagicMock()
            mock_state_mgr.game_lock = tracking_lock
            mock_dice.roll_ab.return_value = mock_ab_roll

            from app.websocket.handlers import register_handlers

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

            # Call handlers for both games
            await handler("sid_1", {"game_id": str(game_1_id)})
            await handler("sid_2", {"game_id": str(game_2_id)})

            # Both should have acquired their own locks
            assert locks_acquired[game_1_id], "Game 1 should acquire its lock"
            assert locks_acquired[game_2_id], "Game 2 should acquire its lock"

    @pytest.mark.asyncio
    async def test_lock_timeout_is_30_seconds(self, mock_manager, mock_game_state):
        """
        Verify that the default lock timeout is 30 seconds.

        The game_lock context manager should be called with timeout=30.0.
        """
        from socketio import AsyncServer

        sio = AsyncServer()
        timeout_used = None

        @asynccontextmanager
        async def tracking_lock(game_id, timeout=30.0):
            nonlocal timeout_used
            timeout_used = timeout
            yield

        with patch("app.websocket.handlers.state_manager") as mock_state_mgr, \
             patch("app.websocket.handlers.dice_system") as mock_dice:
            mock_state_mgr.get_state.return_value = mock_game_state
            mock_state_mgr.update_state = MagicMock()
            mock_state_mgr.game_lock = tracking_lock
            mock_dice.roll_ab.return_value = MagicMock()

            from app.websocket.handlers import register_handlers

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

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

            # The lock uses default timeout, which should be 30.0
            # Note: If timeout is not explicitly passed, it uses the default
            assert timeout_used == 30.0, f"Expected 30.0s timeout, got {timeout_used}"
