"""
Tests for rate limiting in WebSocket handlers.

Verifies that rate limiting is properly applied at both connection and
game levels for all handlers that implement rate limiting. This is critical
for DOS protection.

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

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


# ============================================================================
# CONNECTION-LEVEL RATE LIMITING TESTS
# ============================================================================


class TestConnectionRateLimiting:
    """
    Tests for connection-level rate limiting across handlers.

    Connection-level rate limiting prevents any single connection from
    overwhelming the server with requests, regardless of the request type.
    """

    @pytest.mark.asyncio
    async def test_join_game_rate_limited_returns_error(self, mock_manager):
        """
        Verify join_game returns error when connection is rate limited.

        When a connection exceeds its rate limit, the handler should emit
        an error with code RATE_LIMITED and not process the join request.
        """
        from socketio import AsyncServer
        from app.websocket.handlers import register_handlers

        sio = AsyncServer()

        with patch("app.websocket.handlers.rate_limiter") as mock_limiter:
            mock_limiter.check_websocket_limit = AsyncMock(return_value=False)

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

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

            # Verify error emitted
            mock_manager.emit_to_user.assert_called_once()
            call_args = mock_manager.emit_to_user.call_args
            assert call_args[0][1] == "error"
            assert "RATE_LIMITED" in call_args[0][2]["code"]

            # Verify join not attempted
            mock_manager.join_game.assert_not_called()

    @pytest.mark.asyncio
    async def test_request_game_state_rate_limited(self, mock_manager):
        """
        Verify request_game_state returns error when rate limited.

        Game state requests can be expensive (database recovery), so rate
        limiting is essential to prevent abuse.
        """
        from socketio import AsyncServer
        from app.websocket.handlers import register_handlers

        sio = AsyncServer()

        with patch("app.websocket.handlers.rate_limiter") as mock_limiter:
            mock_limiter.check_websocket_limit = AsyncMock(return_value=False)

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

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

            # Verify rate limit error
            mock_manager.emit_to_user.assert_called_once()
            call_args = mock_manager.emit_to_user.call_args
            assert call_args[0][1] == "error"
            assert "RATE_LIMITED" in call_args[0][2]["code"]

    @pytest.mark.asyncio
    async def test_roll_dice_rate_limited_at_connection_level(self, mock_manager):
        """
        Verify roll_dice checks connection-level rate limit first.

        Roll dice has both connection-level and game-level limits. The
        connection limit is checked first.
        """
        from socketio import AsyncServer
        from app.websocket.handlers import register_handlers

        sio = AsyncServer()

        with patch("app.websocket.handlers.rate_limiter") as mock_limiter:
            mock_limiter.check_websocket_limit = AsyncMock(return_value=False)

            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 "RATE_LIMITED" in call_args[0][2]["code"]

    @pytest.mark.asyncio
    async def test_submit_manual_outcome_rate_limited(self, mock_manager):
        """
        Verify submit_manual_outcome returns error when rate limited.

        Manual outcome submissions must be rate limited to prevent flooding.
        """
        from socketio import AsyncServer
        from app.websocket.handlers import register_handlers

        sio = AsyncServer()

        with patch("app.websocket.handlers.rate_limiter") as mock_limiter:
            mock_limiter.check_websocket_limit = AsyncMock(return_value=False)

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

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

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

    @pytest.mark.asyncio
    async def test_get_lineup_rate_limited(self, mock_manager):
        """
        Verify get_lineup returns error when rate limited.

        Lineup queries can hit the database, so rate limiting prevents abuse.
        """
        from socketio import AsyncServer
        from app.websocket.handlers import register_handlers

        sio = AsyncServer()

        with patch("app.websocket.handlers.rate_limiter") as mock_limiter:
            mock_limiter.check_websocket_limit = AsyncMock(return_value=False)

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

            await handler("test_sid", {"game_id": str(uuid4()), "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] == "error"
            assert "RATE_LIMITED" in call_args[0][2]["code"]

    @pytest.mark.asyncio
    async def test_get_box_score_rate_limited(self, mock_manager):
        """
        Verify get_box_score returns error when rate limited.

        Box score queries aggregate data, so they're expensive and need limiting.
        """
        from socketio import AsyncServer
        from app.websocket.handlers import register_handlers

        sio = AsyncServer()

        with patch("app.websocket.handlers.rate_limiter") as mock_limiter:
            mock_limiter.check_websocket_limit = AsyncMock(return_value=False)

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

            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 "RATE_LIMITED" in call_args[0][2]["code"]


# ============================================================================
# GAME-LEVEL RATE LIMITING TESTS
# ============================================================================


class TestGameRateLimiting:
    """
    Tests for game-level rate limiting.

    Game-level rate limiting prevents any single game from being flooded
    with actions, protecting the game state integrity and server resources.
    """

    @pytest.mark.asyncio
    async def test_roll_dice_game_rate_limited(self, mock_manager, mock_game_state):
        """
        Verify roll_dice returns error when game roll limit exceeded.

        Dice rolls have a per-game limit to prevent spamming. When the limit
        is exceeded, the handler should emit a GAME_RATE_LIMITED error.
        """
        from socketio import AsyncServer
        from app.websocket.handlers import register_handlers

        sio = AsyncServer()

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

        with patch("app.websocket.handlers.rate_limiter") as mock_limiter, \
             patch("app.websocket.handlers.state_manager") as mock_state_mgr:
            # Connection limit passes, game limit fails
            mock_limiter.check_websocket_limit = AsyncMock(return_value=True)
            mock_limiter.check_game_limit = AsyncMock(return_value=False)
            mock_state_mgr.get_state.return_value = mock_game_state
            mock_state_mgr.game_lock = mock_lock

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

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

            # Verify game rate limit error
            mock_manager.emit_to_user.assert_called()
            call_args = mock_manager.emit_to_user.call_args
            assert call_args[0][1] == "error"
            assert "GAME_RATE_LIMITED" in call_args[0][2]["code"]

    @pytest.mark.asyncio
    async def test_submit_manual_outcome_game_rate_limited(
        self, mock_manager, mock_game_state
    ):
        """
        Verify submit_manual_outcome returns error when game decision limit exceeded.

        Decision submissions have per-game limits to prevent rapid-fire outcomes.
        """
        from socketio import AsyncServer
        from app.websocket.handlers import register_handlers

        sio = AsyncServer()

        with patch("app.websocket.handlers.rate_limiter") as mock_limiter, \
             patch("app.websocket.handlers.state_manager") as mock_state_mgr:
            mock_limiter.check_websocket_limit = AsyncMock(return_value=True)
            mock_limiter.check_game_limit = AsyncMock(return_value=False)
            mock_state_mgr.get_state.return_value = mock_game_state

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

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

            mock_manager.emit_to_user.assert_called()
            call_args = mock_manager.emit_to_user.call_args
            assert call_args[0][1] == "error"
            assert "GAME_RATE_LIMITED" in call_args[0][2]["code"]

    @pytest.mark.asyncio
    async def test_submit_defensive_decision_game_rate_limited(
        self, mock_manager, mock_game_state
    ):
        """
        Verify submit_defensive_decision returns error when game decision limit exceeded.

        Defensive decisions use the 'decision' action type for rate limiting.
        """
        from socketio import AsyncServer
        from app.websocket.handlers import register_handlers

        sio = AsyncServer()

        with patch("app.websocket.handlers.rate_limiter") as mock_limiter, \
             patch("app.websocket.handlers.state_manager") as mock_state_mgr:
            mock_limiter.check_websocket_limit = AsyncMock(return_value=True)
            mock_limiter.check_game_limit = AsyncMock(return_value=False)
            mock_state_mgr.get_state.return_value = mock_game_state

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

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

            mock_manager.emit_to_user.assert_called()
            call_args = mock_manager.emit_to_user.call_args
            assert call_args[0][1] == "error"
            assert "GAME_RATE_LIMITED" in call_args[0][2]["code"]

    @pytest.mark.asyncio
    async def test_submit_offensive_decision_game_rate_limited(
        self, mock_manager, mock_game_state
    ):
        """
        Verify submit_offensive_decision returns error when game decision limit exceeded.

        Offensive decisions use the 'decision' action type for rate limiting.
        """
        from socketio import AsyncServer
        from app.websocket.handlers import register_handlers

        sio = AsyncServer()

        with patch("app.websocket.handlers.rate_limiter") as mock_limiter, \
             patch("app.websocket.handlers.state_manager") as mock_state_mgr:
            mock_limiter.check_websocket_limit = AsyncMock(return_value=True)
            mock_limiter.check_game_limit = AsyncMock(return_value=False)
            mock_state_mgr.get_state.return_value = mock_game_state

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

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

            mock_manager.emit_to_user.assert_called()
            call_args = mock_manager.emit_to_user.call_args
            assert call_args[0][1] == "error"
            assert "GAME_RATE_LIMITED" in call_args[0][2]["code"]


# ============================================================================
# SUBSTITUTION RATE LIMITING TESTS
# ============================================================================


class TestSubstitutionRateLimiting:
    """
    Tests for rate limiting on substitution handlers.

    Substitution actions have their own rate limit category to prevent
    rapid lineup changes which could cause state inconsistencies.
    """

    @pytest.mark.asyncio
    async def test_pinch_hitter_connection_rate_limited(self, mock_manager):
        """
        Verify pinch_hitter returns error when connection rate limited.

        Connection-level limits are checked before game-level limits.
        """
        from socketio import AsyncServer
        from app.websocket.handlers import register_handlers

        sio = AsyncServer()

        with patch("app.websocket.handlers.rate_limiter") as mock_limiter:
            mock_limiter.check_websocket_limit = AsyncMock(return_value=False)

            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,
                },
            )

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

    @pytest.mark.asyncio
    async def test_pinch_hitter_game_rate_limited(self, mock_manager, mock_game_state):
        """
        Verify pinch_hitter returns error when substitution limit exceeded.

        The substitution rate limit is per-game to prevent lineup abuse.
        """
        from socketio import AsyncServer
        from app.websocket.handlers import register_handlers

        sio = AsyncServer()

        with patch("app.websocket.handlers.rate_limiter") as mock_limiter, \
             patch("app.websocket.handlers.state_manager") as mock_state_mgr:
            mock_limiter.check_websocket_limit = AsyncMock(return_value=True)
            mock_limiter.check_game_limit = AsyncMock(return_value=False)
            mock_state_mgr.get_state.return_value = mock_game_state

            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,
                },
            )

            mock_manager.emit_to_user.assert_called()
            call_args = mock_manager.emit_to_user.call_args
            assert call_args[0][1] == "error"
            assert "GAME_RATE_LIMITED" in call_args[0][2]["code"]

    @pytest.mark.asyncio
    async def test_defensive_replacement_connection_rate_limited(self, mock_manager):
        """
        Verify defensive_replacement returns error when connection rate limited.
        """
        from socketio import AsyncServer
        from app.websocket.handlers import register_handlers

        sio = AsyncServer()

        with patch("app.websocket.handlers.rate_limiter") as mock_limiter:
            mock_limiter.check_websocket_limit = AsyncMock(return_value=False)

            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,
                },
            )

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

    @pytest.mark.asyncio
    async def test_defensive_replacement_game_rate_limited(
        self, mock_manager, mock_game_state
    ):
        """
        Verify defensive_replacement returns error when substitution limit exceeded.
        """
        from socketio import AsyncServer
        from app.websocket.handlers import register_handlers

        sio = AsyncServer()

        with patch("app.websocket.handlers.rate_limiter") as mock_limiter, \
             patch("app.websocket.handlers.state_manager") as mock_state_mgr:
            mock_limiter.check_websocket_limit = AsyncMock(return_value=True)
            mock_limiter.check_game_limit = AsyncMock(return_value=False)
            mock_state_mgr.get_state.return_value = mock_game_state

            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,
                },
            )

            mock_manager.emit_to_user.assert_called()
            call_args = mock_manager.emit_to_user.call_args
            assert call_args[0][1] == "error"
            assert "GAME_RATE_LIMITED" in call_args[0][2]["code"]

    @pytest.mark.asyncio
    async def test_pitching_change_connection_rate_limited(self, mock_manager):
        """
        Verify pitching_change returns error when connection rate limited.
        """
        from socketio import AsyncServer
        from app.websocket.handlers import register_handlers

        sio = AsyncServer()

        with patch("app.websocket.handlers.rate_limiter") as mock_limiter:
            mock_limiter.check_websocket_limit = AsyncMock(return_value=False)

            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,
                },
            )

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

    @pytest.mark.asyncio
    async def test_pitching_change_game_rate_limited(
        self, mock_manager, mock_game_state
    ):
        """
        Verify pitching_change returns error when substitution limit exceeded.
        """
        from socketio import AsyncServer
        from app.websocket.handlers import register_handlers

        sio = AsyncServer()

        with patch("app.websocket.handlers.rate_limiter") as mock_limiter, \
             patch("app.websocket.handlers.state_manager") as mock_state_mgr:
            mock_limiter.check_websocket_limit = AsyncMock(return_value=True)
            mock_limiter.check_game_limit = AsyncMock(return_value=False)
            mock_state_mgr.get_state.return_value = mock_game_state

            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_manager.emit_to_user.assert_called()
            call_args = mock_manager.emit_to_user.call_args
            assert call_args[0][1] == "error"
            assert "GAME_RATE_LIMITED" in call_args[0][2]["code"]


# ============================================================================
# RATE LIMITER CLEANUP TESTS
# ============================================================================


class TestRateLimiterCleanup:
    """
    Tests for rate limiter cleanup during disconnect.

    When a connection closes, its rate limiter buckets should be cleaned up
    to prevent memory leaks.
    """

    @pytest.mark.asyncio
    async def test_disconnect_removes_rate_limiter_bucket(self, mock_manager):
        """
        Verify disconnect removes connection's rate limiter bucket.

        When a client disconnects, the rate limiter should clean up the
        bucket for that connection to prevent memory leaks.
        """
        from socketio import AsyncServer
        from app.websocket.handlers import register_handlers

        sio = AsyncServer()

        with patch("app.websocket.handlers.rate_limiter") as mock_limiter:
            mock_limiter.remove_connection = MagicMock()

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

            await handler("test_sid")

            # Verify rate limiter cleanup called
            mock_limiter.remove_connection.assert_called_once_with("test_sid")
            # Verify manager disconnect also called
            mock_manager.disconnect.assert_called_once_with("test_sid")

    @pytest.mark.asyncio
    async def test_disconnect_cleanup_happens_before_manager(self, mock_manager):
        """
        Verify rate limiter cleanup happens during disconnect.

        The rate limiter bucket removal should happen regardless of
        other disconnect processing.
        """
        from socketio import AsyncServer
        from app.websocket.handlers import register_handlers

        sio = AsyncServer()

        cleanup_order = []

        def track_limiter_cleanup(sid):
            cleanup_order.append(("limiter", sid))

        async def track_manager_disconnect(sid):
            cleanup_order.append(("manager", sid))

        with patch("app.websocket.handlers.rate_limiter") as mock_limiter:
            mock_limiter.remove_connection = track_limiter_cleanup
            mock_manager.disconnect = track_manager_disconnect

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

            await handler("test_sid")

            # Both cleanup steps should have happened
            assert ("limiter", "test_sid") in cleanup_order
            assert ("manager", "test_sid") in cleanup_order


# ============================================================================
# RATE LIMITING ALLOWS WHEN UNDER LIMIT
# ============================================================================


class TestRateLimitingAllows:
    """
    Tests verifying that rate limiting allows requests when under limit.

    These tests ensure the rate limiter doesn't accidentally block
    legitimate requests.
    """

    @pytest.mark.asyncio
    async def test_join_game_allowed_when_under_limit(self, mock_manager):
        """
        Verify join_game proceeds normally when rate limit not exceeded.

        The handler should process the request and emit game_joined when
        the connection is under its rate limit.
        """
        from socketio import AsyncServer
        from app.websocket.handlers import register_handlers

        sio = AsyncServer()

        with patch("app.websocket.handlers.rate_limiter") as mock_limiter:
            mock_limiter.check_websocket_limit = AsyncMock(return_value=True)

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

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

            # Should proceed with join
            mock_manager.join_game.assert_called_once()
            mock_manager.emit_to_user.assert_called_once()
            call_args = mock_manager.emit_to_user.call_args
            assert call_args[0][1] == "game_joined"

    @pytest.mark.asyncio
    async def test_roll_dice_allowed_when_under_limits(
        self, mock_manager, mock_game_state, mock_ab_roll
    ):
        """
        Verify roll_dice proceeds when both connection and game limits allow.

        Both rate limit checks must pass for the handler to process the roll.
        """
        from socketio import AsyncServer
        from app.websocket.handlers import register_handlers

        sio = AsyncServer()

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

        with patch("app.websocket.handlers.rate_limiter") as mock_limiter, \
             patch("app.websocket.handlers.state_manager") as mock_state_mgr, \
             patch("app.websocket.handlers.dice_system") as mock_dice:
            mock_limiter.check_websocket_limit = AsyncMock(return_value=True)
            mock_limiter.check_game_limit = AsyncMock(return_value=True)
            mock_state_mgr.get_state.return_value = mock_game_state
            mock_state_mgr.game_lock = mock_lock
            mock_state_mgr.update_state = MagicMock()
            mock_dice.roll_ab.return_value = mock_ab_roll

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

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

            # Should process the roll and broadcast
            mock_dice.roll_ab.assert_called_once()
            mock_manager.broadcast_to_game.assert_called_once()
            call_args = mock_manager.broadcast_to_game.call_args
            assert call_args[0][1] == "dice_rolled"
