"""
Tests for WebSocket connection and room management handlers.

Verifies authentication (connect), disconnection (disconnect), room joining
(join_game, leave_game), heartbeat, and game state request handlers.

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

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

from tests.unit.websocket.conftest import get_handler


# ============================================================================
# CONNECT HANDLER TESTS
# ============================================================================


class TestConnectHandler:
    """Tests for the connect event handler."""

    @pytest.mark.asyncio
    async def test_connect_with_valid_cookie(self, mock_manager):
        """
        Verify connect() accepts connection with valid cookie token.

        Cookie-based auth is the primary auth method when using HttpOnly cookies
        set by the OAuth flow. Handler should extract token from HTTP_COOKIE.
        """
        from socketio import AsyncServer

        sio = AsyncServer()

        # Mock verify_token to return valid user data
        with patch("app.websocket.handlers.verify_token") as mock_verify:
            mock_verify.return_value = {"user_id": "user_123", "username": "testuser"}

            from app.websocket.handlers import register_handlers

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

            # Simulate environ with cookie
            environ = {"HTTP_COOKIE": "pd_access_token=valid_token_here; other=value"}
            auth = None

            result = await handler("test_sid", environ, auth)

            assert result is True
            mock_manager.connect.assert_called_once_with("test_sid", "user_123", ip_address=None)

    @pytest.mark.asyncio
    async def test_connect_with_auth_object(self, mock_manager):
        """
        Verify connect() accepts connection with auth object token.

        Auth object is used by direct JS clients that can't use cookies.
        Handler should fall back to auth.token when cookie not present.
        """
        from socketio import AsyncServer

        sio = AsyncServer()

        with patch("app.websocket.handlers.verify_token") as mock_verify:
            mock_verify.return_value = {"user_id": "user_456", "username": "jsuser"}

            from app.websocket.handlers import register_handlers

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

            # Simulate environ without cookie, but with auth object
            environ = {"HTTP_COOKIE": ""}
            auth = {"token": "auth_object_token"}

            result = await handler("test_sid", environ, auth)

            assert result is True
            mock_verify.assert_called_once_with("auth_object_token")
            mock_manager.connect.assert_called_once_with("test_sid", "user_456", ip_address=None)

    @pytest.mark.asyncio
    async def test_connect_missing_credentials_rejected(self, mock_manager):
        """
        Verify connect() rejects connection when no token provided.

        Handler should return False to reject the Socket.io connection
        when neither cookie nor auth object contains a token.
        """
        from socketio import AsyncServer

        sio = AsyncServer()

        from app.websocket.handlers import register_handlers

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

        # No cookie, no auth
        environ = {"HTTP_COOKIE": ""}
        auth = None

        result = await handler("test_sid", environ, auth)

        assert result is False
        mock_manager.connect.assert_not_called()

    @pytest.mark.asyncio
    async def test_connect_invalid_token_rejected(self, mock_manager):
        """
        Verify connect() rejects connection when token is invalid.

        When verify_token returns no user_id, the connection should be rejected.
        """
        from socketio import AsyncServer

        sio = AsyncServer()

        with patch("app.websocket.handlers.verify_token") as mock_verify:
            mock_verify.return_value = {}  # No user_id in response

            from app.websocket.handlers import register_handlers

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

            environ = {"HTTP_COOKIE": "pd_access_token=invalid_token"}
            auth = None

            result = await handler("test_sid", environ, auth)

            assert result is False
            mock_manager.connect.assert_not_called()

    @pytest.mark.asyncio
    async def test_connect_exception_rejected(self, mock_manager):
        """
        Verify connect() rejects connection when exception occurs.

        Any exception during auth processing should result in connection rejection,
        not a server crash.
        """
        from socketio import AsyncServer

        sio = AsyncServer()

        with patch("app.websocket.handlers.verify_token") as mock_verify:
            mock_verify.side_effect = Exception("Token verification failed")

            from app.websocket.handlers import register_handlers

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

            environ = {"HTTP_COOKIE": "pd_access_token=error_token"}
            auth = None

            result = await handler("test_sid", environ, auth)

            assert result is False

    @pytest.mark.asyncio
    async def test_connect_extracts_ip_address(self, mock_manager):
        """
        Verify connect() extracts IP address from environ.

        IP address is used for logging and rate limiting. Handler should
        extract REMOTE_ADDR from the WSGI environ dict.
        """
        from socketio import AsyncServer

        sio = AsyncServer()

        with patch("app.websocket.handlers.verify_token") as mock_verify:
            mock_verify.return_value = {"user_id": "user_123"}

            from app.websocket.handlers import register_handlers

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

            environ = {
                "HTTP_COOKIE": "pd_access_token=valid_token",
                "REMOTE_ADDR": "192.168.1.100",
            }

            result = await handler("test_sid", environ, None)

            assert result is True
            mock_manager.connect.assert_called_once_with(
                "test_sid", "user_123", ip_address="192.168.1.100"
            )

    @pytest.mark.asyncio
    async def test_connect_emits_connected_event(self, mock_manager):
        """
        Verify connect() emits connected event with user_id.

        After successful authentication, the handler should emit a 'connected'
        event to the client with their user_id for client-side tracking.
        """
        from socketio import AsyncServer

        sio = AsyncServer()
        sio.emit = AsyncMock()

        with patch("app.websocket.handlers.verify_token") as mock_verify:
            mock_verify.return_value = {"user_id": "user_789"}

            from app.websocket.handlers import register_handlers

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

            environ = {"HTTP_COOKIE": "pd_access_token=valid_token"}

            await handler("test_sid", environ, None)

            # Verify connected event emitted
            sio.emit.assert_called_once()
            call_args = sio.emit.call_args
            assert call_args[0][0] == "connected"
            assert call_args[0][1]["user_id"] == "user_789"

    @pytest.mark.asyncio
    async def test_connect_value_error_rejected(self, mock_manager):
        """
        Verify connect() handles ValueError during token parsing.

        If verify_token raises ValueError (malformed token), the connection
        should be rejected without crashing.
        """
        from socketio import AsyncServer

        sio = AsyncServer()

        with patch("app.websocket.handlers.verify_token") as mock_verify:
            mock_verify.side_effect = ValueError("Invalid token format")

            from app.websocket.handlers import register_handlers

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

            environ = {"HTTP_COOKIE": "pd_access_token=malformed"}
            result = await handler("test_sid", environ, None)

            assert result is False
            mock_manager.connect.assert_not_called()

    @pytest.mark.asyncio
    async def test_connect_key_error_rejected(self, mock_manager):
        """
        Verify connect() handles KeyError during token parsing.

        If verify_token raises KeyError (missing required field), the connection
        should be rejected without crashing.
        """
        from socketio import AsyncServer

        sio = AsyncServer()

        with patch("app.websocket.handlers.verify_token") as mock_verify:
            mock_verify.side_effect = KeyError("missing_field")

            from app.websocket.handlers import register_handlers

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

            environ = {"HTTP_COOKIE": "pd_access_token=incomplete"}
            result = await handler("test_sid", environ, None)

            assert result is False

    @pytest.mark.asyncio
    async def test_connect_network_error_rejected(self, mock_manager):
        """
        Verify connect() handles network errors gracefully.

        If a ConnectionError or OSError occurs during connection setup,
        the connection should be rejected.
        """
        from socketio import AsyncServer

        sio = AsyncServer()

        with patch("app.websocket.handlers.verify_token") as mock_verify:
            mock_verify.return_value = {"user_id": "user_123"}
            mock_manager.connect = AsyncMock(side_effect=ConnectionError("Network failed"))

            from app.websocket.handlers import register_handlers

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

            environ = {"HTTP_COOKIE": "pd_access_token=valid"}
            result = await handler("test_sid", environ, None)

            assert result is False


# ============================================================================
# DISCONNECT HANDLER TESTS
# ============================================================================


class TestDisconnectHandler:
    """Tests for the disconnect event handler."""

    @pytest.mark.asyncio
    async def test_disconnect_calls_manager(self, mock_manager):
        """
        Verify disconnect() delegates to ConnectionManager.

        Handler should call manager.disconnect() with the session ID
        to clean up all session state and room memberships.
        """
        from socketio import AsyncServer

        sio = AsyncServer()

        from app.websocket.handlers import register_handlers

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

        await handler("test_sid")

        mock_manager.disconnect.assert_called_once_with("test_sid")

    @pytest.mark.asyncio
    async def test_disconnect_cleans_rate_limiter(self, mock_manager):
        """
        Verify disconnect() removes rate limiter buckets.

        Handler should call rate_limiter.remove_connection() to clean up
        rate limiting state and prevent memory leaks.
        """
        from socketio import AsyncServer

        sio = AsyncServer()

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

            from app.websocket.handlers import register_handlers

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

            await handler("test_sid")

            mock_limiter.remove_connection.assert_called_once_with("test_sid")

    @pytest.mark.asyncio
    async def test_disconnect_handles_manager_error(self, mock_manager):
        """
        Verify disconnect() handles manager errors gracefully.

        Even if manager.disconnect() raises an error, the handler should
        not crash - disconnection cleanup should be best-effort.
        """
        from socketio import AsyncServer

        sio = AsyncServer()
        mock_manager.disconnect = AsyncMock(side_effect=Exception("Cleanup failed"))

        with patch("app.websocket.handlers.rate_limiter"):
            from app.websocket.handlers import register_handlers

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

            # Should not raise even if manager fails
            # Note: The handler may or may not catch this - depends on impl
            try:
                await handler("test_sid")
            except Exception:
                pass  # Some implementations may let errors propagate

    @pytest.mark.asyncio
    async def test_disconnect_multiple_calls_idempotent(self, mock_manager):
        """
        Verify disconnect() can be called multiple times safely.

        Socket.io may call disconnect multiple times in edge cases.
        Handler should be idempotent and not crash on repeated calls.
        """
        from socketio import AsyncServer

        sio = AsyncServer()

        with patch("app.websocket.handlers.rate_limiter"):
            from app.websocket.handlers import register_handlers

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

            # First disconnect
            await handler("test_sid")
            # Second disconnect should also succeed
            await handler("test_sid")

            # Manager should be called twice
            assert mock_manager.disconnect.call_count == 2


# ============================================================================
# JOIN GAME HANDLER TESTS
# ============================================================================


class TestJoinGameHandler:
    """Tests for the join_game event handler."""

    @pytest.mark.asyncio
    async def test_join_game_success(self, mock_manager):
        """
        Verify join_game() successfully joins user to game room.

        Handler should call manager.join_game and emit game_joined confirmation.
        """
        from socketio import AsyncServer

        sio = AsyncServer()

        from app.websocket.handlers import register_handlers

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

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

        mock_manager.join_game.assert_called_once_with("test_sid", game_id, "player")
        mock_manager.emit_to_user.assert_called_once()

        # Verify confirmation event
        call_args = mock_manager.emit_to_user.call_args
        assert call_args[0][1] == "game_joined"
        assert call_args[0][2]["game_id"] == game_id

    @pytest.mark.asyncio
    async def test_join_game_missing_game_id(self, mock_manager):
        """
        Verify join_game() returns error when game_id missing.

        Handler should emit error event without attempting to join.
        """
        from socketio import AsyncServer

        sio = AsyncServer()

        from app.websocket.handlers import register_handlers

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

        await handler("test_sid", {})

        mock_manager.join_game.assert_not_called()
        mock_manager.emit_to_user.assert_called_once()

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

    @pytest.mark.asyncio
    async def test_join_game_with_role(self, mock_manager):
        """
        Verify join_game() passes role to manager.

        Role can be 'home', 'away', 'spectator', or 'player'.
        Handler should pass the specified role to manager.join_game().
        """
        from socketio import AsyncServer

        sio = AsyncServer()

        from app.websocket.handlers import register_handlers

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

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

        mock_manager.join_game.assert_called_once_with("test_sid", game_id, "spectator")


# ============================================================================
# LEAVE GAME HANDLER TESTS
# ============================================================================


class TestLeaveGameHandler:
    """Tests for the leave_game event handler."""

    @pytest.mark.asyncio
    async def test_leave_game_success(self, mock_manager):
        """
        Verify leave_game() removes user from game room.

        Handler should call manager.leave_game with game_id.
        """
        from socketio import AsyncServer

        sio = AsyncServer()

        from app.websocket.handlers import register_handlers

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

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

        mock_manager.leave_game.assert_called_once_with("test_sid", game_id)

    @pytest.mark.asyncio
    async def test_leave_game_missing_game_id_noop(self, mock_manager):
        """
        Verify leave_game() is a no-op when game_id missing.

        Handler should not call leave_game if no game_id provided.
        """
        from socketio import AsyncServer

        sio = AsyncServer()

        from app.websocket.handlers import register_handlers

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

        await handler("test_sid", {})

        mock_manager.leave_game.assert_not_called()


# ============================================================================
# HEARTBEAT HANDLER TESTS
# ============================================================================


class TestHeartbeatHandler:
    """Tests for the heartbeat event handler."""

    @pytest.mark.asyncio
    async def test_heartbeat_acknowledges(self, mock_manager):
        """
        Verify heartbeat() emits acknowledgment.

        Handler should emit heartbeat_ack directly to the client socket.
        This keeps the connection alive and verifies client is responsive.
        """
        from socketio import AsyncServer

        sio = AsyncServer()
        sio.emit = AsyncMock()

        from app.websocket.handlers import register_handlers

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

        await handler("test_sid")

        sio.emit.assert_called_once_with("heartbeat_ack", {}, room="test_sid")


# ============================================================================
# REQUEST GAME STATE HANDLER TESTS
# ============================================================================


class TestRequestGameStateHandler:
    """Tests for the request_game_state event handler."""

    @pytest.mark.asyncio
    async def test_request_game_state_from_memory(self, mock_manager, mock_game_state):
        """
        Verify request_game_state() returns state from memory with play history.

        When game state is in StateManager memory, handler should return it
        along with play history via game_state_sync event.
        """
        from socketio import AsyncServer

        sio = AsyncServer()

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

            # Mock DatabaseOperations.get_plays() to return empty list
            mock_db_ops = AsyncMock()
            mock_db_ops.get_plays = AsyncMock(return_value=[])
            mock_db_ops_class.return_value = mock_db_ops

            from app.websocket.handlers import register_handlers

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

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

            mock_state_mgr.get_state.assert_called_once()
            mock_state_mgr.recover_game.assert_not_called()
            mock_db_ops.get_plays.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_state_sync"
            # Verify payload structure
            payload = call_args[0][2]
            assert "state" in payload
            assert "recent_plays" in payload
            assert "timestamp" in payload

    @pytest.mark.asyncio
    async def test_request_game_state_from_db(self, mock_manager, mock_game_state):
        """
        Verify request_game_state() recovers from database when not in memory.

        When game state is not in memory, handler should call recover_game()
        to load and replay from database (recovery path), then return state
        with play history via game_state_sync event.
        """
        from socketio import AsyncServer

        sio = AsyncServer()

        with patch("app.websocket.handlers.state_manager") as mock_state_mgr, \
             patch("app.websocket.handlers.DatabaseOperations") as mock_db_ops_class:
            mock_state_mgr.get_state.return_value = None  # Not in memory
            mock_state_mgr.recover_game = AsyncMock(return_value=mock_game_state)

            # Mock DatabaseOperations.get_plays() to return empty list
            mock_db_ops = AsyncMock()
            mock_db_ops.get_plays = AsyncMock(return_value=[])
            mock_db_ops_class.return_value = mock_db_ops

            from app.websocket.handlers import register_handlers

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

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

            mock_state_mgr.get_state.assert_called_once()
            mock_state_mgr.recover_game.assert_called_once()
            mock_db_ops.get_plays.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_state_sync"
            # Verify payload structure
            payload = call_args[0][2]
            assert "state" in payload
            assert "recent_plays" in payload
            assert "timestamp" in payload

    @pytest.mark.asyncio
    async def test_request_game_state_missing_game_id(self, mock_manager):
        """
        Verify request_game_state() returns error when game_id missing.
        """
        from socketio import AsyncServer

        sio = AsyncServer()

        from app.websocket.handlers import register_handlers

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

        await handler("test_sid", {})

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

    @pytest.mark.asyncio
    async def test_request_game_state_invalid_format(self, mock_manager):
        """
        Verify request_game_state() returns error for invalid UUID format.

        Handler should validate game_id is a valid UUID before attempting
        to fetch state.
        """
        from socketio import AsyncServer

        sio = AsyncServer()

        from app.websocket.handlers import register_handlers

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

        await handler("test_sid", {"game_id": "not-a-valid-uuid"})

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

    @pytest.mark.asyncio
    async def test_request_game_state_not_found(self, mock_manager):
        """
        Verify request_game_state() returns error when game not found anywhere.

        When game is neither in memory nor database, handler should emit
        an error event indicating the game was not found.
        """
        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
            mock_state_mgr.recover_game = AsyncMock(return_value=None)

            from app.websocket.handlers import register_handlers

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

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

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