"""Tests for WebSocket message schemas.

This module tests the Pydantic models for WebSocket communication, verifying
discriminated union parsing, field validation, and serialization behavior.
"""

from datetime import UTC, datetime

import pytest
from pydantic import TypeAdapter, ValidationError

from app.core.enums import GameEndReason, TurnPhase
from app.core.models.actions import AttackAction, PassAction
from app.core.visibility import VisibleGameState, VisiblePlayerState
from app.schemas.ws_messages import (
    ActionMessage,
    ActionResultMessage,
    ClientMessage,
    ConnectionStatus,
    ErrorMessage,
    GameOverMessage,
    GameStateMessage,
    HeartbeatAckMessage,
    HeartbeatMessage,
    JoinGameMessage,
    OpponentStatusMessage,
    ResignMessage,
    ServerMessage,
    TurnStartMessage,
    TurnTimeoutMessage,
    WSErrorCode,
    parse_client_message,
    parse_server_message,
)


class TestClientMessageDiscriminator:
    """Tests for client message discriminated union parsing.

    The discriminated union pattern allows automatic type resolution based on
    the 'type' field, enabling type-safe message handling without explicit
    type checking logic.
    """

    def test_parse_join_game_message(self) -> None:
        """Test parsing JoinGameMessage from dictionary.

        JoinGameMessage is the first message clients send to establish their
        presence in a game session. The discriminator should correctly resolve
        the type from the raw dictionary.
        """
        data = {
            "type": "join_game",
            "message_id": "test-123",
            "game_id": "game-456",
        }
        message = parse_client_message(data)

        assert isinstance(message, JoinGameMessage)
        assert message.game_id == "game-456"
        assert message.message_id == "test-123"
        assert message.last_event_id is None

    def test_parse_join_game_with_last_event_id(self) -> None:
        """Test JoinGameMessage with reconnection event ID.

        When reconnecting after a disconnect, clients provide last_event_id
        to receive any missed events since that point, enabling seamless
        session recovery.
        """
        data = {
            "type": "join_game",
            "message_id": "test-123",
            "game_id": "game-456",
            "last_event_id": "event-789",
        }
        message = parse_client_message(data)

        assert isinstance(message, JoinGameMessage)
        assert message.last_event_id == "event-789"

    def test_parse_action_message(self) -> None:
        """Test parsing ActionMessage with embedded game action.

        ActionMessage wraps the Action union type, so the discriminator must
        handle both the message type and the nested action type correctly.
        """
        data = {
            "type": "action",
            "message_id": "test-123",
            "game_id": "game-456",
            "action": {"type": "attack", "attack_index": 0},
        }
        message = parse_client_message(data)

        assert isinstance(message, ActionMessage)
        assert message.game_id == "game-456"
        assert isinstance(message.action, AttackAction)
        assert message.action.attack_index == 0

    def test_parse_action_message_with_pass(self) -> None:
        """Test parsing ActionMessage with PassAction.

        PassAction is the simplest action type - verifies that minimal
        action payloads are handled correctly.
        """
        data = {
            "type": "action",
            "message_id": "test-123",
            "game_id": "game-456",
            "action": {"type": "pass"},
        }
        message = parse_client_message(data)

        assert isinstance(message, ActionMessage)
        assert isinstance(message.action, PassAction)

    def test_parse_resign_message(self) -> None:
        """Test parsing ResignMessage.

        ResignMessage allows resignation at the WebSocket layer, separate from
        the game engine's ResignAction, for cases where engine interaction
        may not be possible.
        """
        data = {
            "type": "resign",
            "message_id": "test-123",
            "game_id": "game-456",
        }
        message = parse_client_message(data)

        assert isinstance(message, ResignMessage)
        assert message.game_id == "game-456"

    def test_parse_heartbeat_message(self) -> None:
        """Test parsing HeartbeatMessage.

        Heartbeat messages keep the WebSocket connection alive and should
        be the most minimal message type with just the type discriminator.
        """
        data = {
            "type": "heartbeat",
            "message_id": "test-123",
        }
        message = parse_client_message(data)

        assert isinstance(message, HeartbeatMessage)

    def test_unknown_message_type_raises_error(self) -> None:
        """Test that unknown message types raise ValidationError.

        The discriminated union should fail fast on unknown types rather
        than silently accepting invalid messages.
        """
        data = {
            "type": "unknown_type",
            "message_id": "test-123",
        }

        with pytest.raises(ValidationError):
            parse_client_message(data)

    def test_message_id_auto_generated(self) -> None:
        """Test that message_id is auto-generated if not provided.

        Clients may omit message_id in which case a UUID should be generated,
        ensuring all messages have a unique identifier for tracking.
        """
        data = {
            "type": "heartbeat",
        }
        message = parse_client_message(data)

        assert message.message_id is not None
        assert len(message.message_id) > 0

    def test_empty_message_id_rejected(self) -> None:
        """Test that empty message_id is rejected.

        An explicitly empty message_id would break idempotency tracking,
        so it should be rejected by validation.
        """
        data = {
            "type": "heartbeat",
            "message_id": "",
        }

        with pytest.raises(ValidationError):
            parse_client_message(data)

    def test_type_adapter_client_message(self) -> None:
        """Test ClientMessage union with TypeAdapter.

        TypeAdapter is the recommended way to work with discriminated unions
        in Pydantic v2 - verifies it works for both parsing and validation.
        """
        adapter = TypeAdapter(ClientMessage)

        data = {"type": "heartbeat", "message_id": "test"}
        message = adapter.validate_python(data)

        assert isinstance(message, HeartbeatMessage)


class TestServerMessageDiscriminator:
    """Tests for server message discriminated union parsing.

    Server messages are more complex than client messages, often containing
    nested game state. The discriminator must handle all message types and
    their embedded data correctly.
    """

    def test_parse_game_state_message(self) -> None:
        """Test parsing GameStateMessage with full visible state.

        GameStateMessage carries the complete game state from a player's
        perspective, used for initial sync and full state updates.
        """
        visible_state = VisibleGameState(
            game_id="game-456",
            viewer_id="player-1",
            players={
                "player-1": VisiblePlayerState(player_id="player-1"),
                "player-2": VisiblePlayerState(player_id="player-2"),
            },
            current_player_id="player-1",
            turn_number=1,
            phase=TurnPhase.MAIN,
            is_my_turn=True,
        )

        message = GameStateMessage(
            game_id="game-456",
            state=visible_state,
        )

        data = message.model_dump()
        parsed = parse_server_message(data)

        assert isinstance(parsed, GameStateMessage)
        assert parsed.game_id == "game-456"
        assert parsed.state.viewer_id == "player-1"
        assert parsed.event_id is not None

    def test_parse_action_result_success(self) -> None:
        """Test parsing successful ActionResultMessage.

        Successful action results should include the changes that occurred
        as a result of the action for client-side state updates.
        """
        message = ActionResultMessage(
            game_id="game-456",
            request_message_id="action-123",
            success=True,
            action_type="attack",
            changes={"damage_dealt": 30, "defender_hp": 70},
        )

        data = message.model_dump()
        parsed = parse_server_message(data)

        assert isinstance(parsed, ActionResultMessage)
        assert parsed.success is True
        assert parsed.changes["damage_dealt"] == 30
        assert parsed.error_code is None

    def test_parse_action_result_failure(self) -> None:
        """Test parsing failed ActionResultMessage.

        Failed action results should include error code and message
        for client-side error handling and user feedback.
        """
        message = ActionResultMessage(
            game_id="game-456",
            request_message_id="action-123",
            success=False,
            action_type="attack",
            error_code=WSErrorCode.NOT_YOUR_TURN,
            error_message="It's not your turn",
        )

        data = message.model_dump()
        parsed = parse_server_message(data)

        assert isinstance(parsed, ActionResultMessage)
        assert parsed.success is False
        assert parsed.error_code == WSErrorCode.NOT_YOUR_TURN

    def test_parse_error_message(self) -> None:
        """Test parsing ErrorMessage.

        Error messages are for protocol-level errors not tied to a specific
        action, like authentication failures or invalid message format.
        """
        message = ErrorMessage(
            code=WSErrorCode.AUTHENTICATION_FAILED,
            message="Invalid token",
            details={"reason": "expired"},
        )

        data = message.model_dump()
        parsed = parse_server_message(data)

        assert isinstance(parsed, ErrorMessage)
        assert parsed.code == WSErrorCode.AUTHENTICATION_FAILED
        assert parsed.details["reason"] == "expired"

    def test_parse_turn_start_message(self) -> None:
        """Test parsing TurnStartMessage.

        TurnStartMessage notifies both players when a turn begins,
        enabling UI updates like starting a turn timer.
        """
        message = TurnStartMessage(
            game_id="game-456",
            player_id="player-1",
            turn_number=5,
        )

        data = message.model_dump()
        parsed = parse_server_message(data)

        assert isinstance(parsed, TurnStartMessage)
        assert parsed.turn_number == 5
        assert parsed.player_id == "player-1"

    def test_parse_turn_timeout_warning(self) -> None:
        """Test parsing TurnTimeoutMessage as warning.

        Timeout warnings give players a chance to complete their action
        before the turn is forcibly ended.
        """
        message = TurnTimeoutMessage(
            game_id="game-456",
            remaining_seconds=30,
            is_warning=True,
            player_id="player-1",
        )

        data = message.model_dump()
        parsed = parse_server_message(data)

        assert isinstance(parsed, TurnTimeoutMessage)
        assert parsed.is_warning is True
        assert parsed.remaining_seconds == 30

    def test_parse_turn_timeout_expired(self) -> None:
        """Test parsing TurnTimeoutMessage as expired.

        When is_warning is False, the timeout has occurred and the game
        engine will force the turn to end.
        """
        message = TurnTimeoutMessage(
            game_id="game-456",
            remaining_seconds=0,
            is_warning=False,
            player_id="player-1",
        )

        data = message.model_dump()
        parsed = parse_server_message(data)

        assert isinstance(parsed, TurnTimeoutMessage)
        assert parsed.is_warning is False
        assert parsed.remaining_seconds == 0

    def test_parse_game_over_with_winner(self) -> None:
        """Test parsing GameOverMessage with a winner.

        Most games end with a winner - the final state is included so
        clients can display the final board position.
        """
        final_state = VisibleGameState(
            game_id="game-456",
            viewer_id="player-1",
            winner_id="player-1",
            end_reason=GameEndReason.PRIZES_TAKEN,
        )

        message = GameOverMessage(
            game_id="game-456",
            winner_id="player-1",
            end_reason=GameEndReason.PRIZES_TAKEN,
            final_state=final_state,
        )

        data = message.model_dump()
        parsed = parse_server_message(data)

        assert isinstance(parsed, GameOverMessage)
        assert parsed.winner_id == "player-1"
        assert parsed.end_reason == GameEndReason.PRIZES_TAKEN

    def test_parse_game_over_draw(self) -> None:
        """Test parsing GameOverMessage as a draw.

        Games can end in a draw (e.g., timeout with equal scores),
        in which case winner_id is None.
        """
        final_state = VisibleGameState(
            game_id="game-456",
            viewer_id="player-1",
            end_reason=GameEndReason.DRAW,
        )

        message = GameOverMessage(
            game_id="game-456",
            winner_id=None,
            end_reason=GameEndReason.DRAW,
            final_state=final_state,
        )

        data = message.model_dump()
        parsed = parse_server_message(data)

        assert isinstance(parsed, GameOverMessage)
        assert parsed.winner_id is None
        assert parsed.end_reason == GameEndReason.DRAW

    def test_parse_opponent_status_connected(self) -> None:
        """Test parsing OpponentStatusMessage for connection.

        Notifies players when their opponent connects, enabling
        connection status display in the UI.
        """
        message = OpponentStatusMessage(
            game_id="game-456",
            opponent_id="player-2",
            status=ConnectionStatus.CONNECTED,
        )

        data = message.model_dump()
        parsed = parse_server_message(data)

        assert isinstance(parsed, OpponentStatusMessage)
        assert parsed.status == ConnectionStatus.CONNECTED

    def test_parse_opponent_status_disconnected(self) -> None:
        """Test parsing OpponentStatusMessage for disconnection.

        When an opponent disconnects, the UI can show a waiting indicator
        and possibly pause the game timer.
        """
        message = OpponentStatusMessage(
            game_id="game-456",
            opponent_id="player-2",
            status=ConnectionStatus.DISCONNECTED,
        )

        data = message.model_dump()
        parsed = parse_server_message(data)

        assert isinstance(parsed, OpponentStatusMessage)
        assert parsed.status == ConnectionStatus.DISCONNECTED

    def test_parse_heartbeat_ack(self) -> None:
        """Test parsing HeartbeatAckMessage.

        HeartbeatAck confirms the connection is alive, allowing clients
        to measure round-trip latency.
        """
        message = HeartbeatAckMessage()

        data = message.model_dump()
        parsed = parse_server_message(data)

        assert isinstance(parsed, HeartbeatAckMessage)

    def test_server_message_has_timestamp(self) -> None:
        """Test that all server messages have a timestamp.

        Timestamps enable client-side latency tracking and event ordering
        in case of out-of-order message delivery.
        """
        message = HeartbeatAckMessage()

        assert message.timestamp is not None
        assert isinstance(message.timestamp, datetime)
        # Should be UTC
        assert message.timestamp.tzinfo == UTC

    def test_type_adapter_server_message(self) -> None:
        """Test ServerMessage union with TypeAdapter.

        Verifies TypeAdapter works correctly for the more complex
        server message union with nested types.
        """
        adapter = TypeAdapter(ServerMessage)

        error_data = {
            "type": "error",
            "message_id": "test",
            "timestamp": datetime.now(UTC).isoformat(),
            "code": "game_not_found",
            "message": "Game not found",
        }
        message = adapter.validate_python(error_data)

        assert isinstance(message, ErrorMessage)


class TestMessageSerialization:
    """Tests for message JSON serialization and round-trip behavior.

    Messages must serialize cleanly to JSON for WebSocket transmission
    and deserialize back to the same logical structure.
    """

    def test_client_message_round_trip(self) -> None:
        """Test client message JSON round-trip.

        Messages should survive serialization to JSON and back without
        losing data or changing types.
        """
        original = ActionMessage(
            message_id="test-123",
            game_id="game-456",
            action=AttackAction(attack_index=1, targets=["target-1"]),
        )

        json_str = original.model_dump_json()
        import json

        data = json.loads(json_str)
        restored = parse_client_message(data)

        assert isinstance(restored, ActionMessage)
        assert restored.message_id == original.message_id
        assert isinstance(restored.action, AttackAction)
        assert restored.action.attack_index == 1
        assert restored.action.targets == ["target-1"]

    def test_server_message_round_trip(self) -> None:
        """Test server message JSON round-trip.

        Server messages with nested state should serialize and deserialize
        correctly, preserving all game state information.
        """
        visible_state = VisibleGameState(
            game_id="game-456",
            viewer_id="player-1",
            turn_number=3,
            phase=TurnPhase.ATTACK,
        )

        original = GameStateMessage(
            message_id="test-123",
            game_id="game-456",
            state=visible_state,
        )

        json_str = original.model_dump_json()
        import json

        data = json.loads(json_str)
        restored = parse_server_message(data)

        assert isinstance(restored, GameStateMessage)
        assert restored.state.turn_number == 3
        assert restored.state.phase == TurnPhase.ATTACK

    def test_error_code_serializes_as_string(self) -> None:
        """Test that WSErrorCode serializes as string value.

        Error codes should serialize to their string values for clean
        JSON output that clients can easily handle.
        """
        message = ErrorMessage(
            code=WSErrorCode.GAME_NOT_FOUND,
            message="Game not found",
        )

        json_str = message.model_dump_json()
        import json

        data = json.loads(json_str)

        assert data["code"] == "game_not_found"


class TestMessageValidation:
    """Tests for message field validation rules.

    Validation ensures messages contain valid data before processing,
    preventing invalid state from propagating through the system.
    """

    def test_join_game_requires_game_id(self) -> None:
        """Test that JoinGameMessage requires game_id.

        game_id is mandatory - clients must specify which game to join.
        """
        data = {
            "type": "join_game",
            "message_id": "test-123",
            # missing game_id
        }

        with pytest.raises(ValidationError):
            parse_client_message(data)

    def test_action_message_requires_action(self) -> None:
        """Test that ActionMessage requires action field.

        The action field is mandatory - messages without an action
        cannot be processed.
        """
        data = {
            "type": "action",
            "message_id": "test-123",
            "game_id": "game-456",
            # missing action
        }

        with pytest.raises(ValidationError):
            parse_client_message(data)

    def test_turn_number_must_be_positive(self) -> None:
        """Test that turn_number must be >= 1.

        Turn numbers start at 1 - zero or negative values are invalid.
        """
        with pytest.raises(ValidationError):
            TurnStartMessage(
                game_id="game-456",
                player_id="player-1",
                turn_number=0,  # Invalid
            )

    def test_remaining_seconds_non_negative(self) -> None:
        """Test that remaining_seconds must be >= 0.

        Negative timeout values are meaningless and should be rejected.
        """
        with pytest.raises(ValidationError):
            TurnTimeoutMessage(
                game_id="game-456",
                remaining_seconds=-1,  # Invalid
                is_warning=True,
                player_id="player-1",
            )

    def test_action_result_requires_request_id(self) -> None:
        """Test that ActionResultMessage requires request_message_id.

        Results must link back to the original request for client-side
        correlation of actions and responses.
        """
        with pytest.raises(ValidationError):
            ActionResultMessage(
                game_id="game-456",
                # missing request_message_id
                success=True,
                action_type="attack",
            )


class TestWSErrorCode:
    """Tests for WebSocket error code enumeration.

    Error codes provide machine-readable error classification for
    programmatic error handling on the client.
    """

    def test_all_error_codes_are_strings(self) -> None:
        """Test that all error codes are string values.

        StrEnum ensures all values serialize as strings for JSON
        compatibility.
        """
        for code in WSErrorCode:
            assert isinstance(code.value, str)

    def test_error_codes_are_lowercase_snake_case(self) -> None:
        """Test error code naming convention.

        Error codes follow lowercase_snake_case for consistency with
        other API identifiers and JSON conventions.
        """
        for code in WSErrorCode:
            assert code.value == code.value.lower()
            assert " " not in code.value


class TestConnectionStatus:
    """Tests for connection status enumeration."""

    def test_all_statuses_are_strings(self) -> None:
        """Test that all connection statuses are string values."""
        for status in ConnectionStatus:
            assert isinstance(status.value, str)

    def test_expected_statuses_exist(self) -> None:
        """Test that expected connection statuses are defined.

        The three key states (connected, disconnected, reconnecting)
        cover all connection lifecycle phases.
        """
        assert ConnectionStatus.CONNECTED
        assert ConnectionStatus.DISCONNECTED
        assert ConnectionStatus.RECONNECTING
