from __future__ import annotations

# TODO:
#    protocol packet patterns should be protocol fields

import collections
import enum
import functools
import random
import sys
import typing
from abc import ABCMeta

if typing.TYPE_CHECKING:
    import asyncio
    from jj2.game.client import GameClient

from construct import (
    Byte,
    Bytes,
    Const,
    Container,
    Default,
    Enum,
    EnumIntegerString,
    evaluate,
    GreedyBytes,
    GreedyRange,
    If,
    Int,
    Int8sb as SignedByte,
    Int16ul,
    Int32ul,
    NullTerminated,
    OneOf,
    Optional,
    PaddedString,
    Padding,
    PascalString,
    Pass,
    PrefixedArray,
    Short,
    Struct,
    StringEncoded,
    Subconstruct,
    Switch,
    this,
)

from jj2 import Serializer, Protocol
from jj2.exceptions import ArgumentError

TCP_EVENTS = [
    'LATENCY_UPDATE',
    'PLAYER_DETAILS',
    'PLAYER_DETAILS_PLUS',
    'JOIN_REQUEST',
    'DOWNLOAD_REQUEST',
    'UPDATE_REQUEST',
    'UPDATE_READY',
    'SPECTATE_REQUEST',
    'PLUS_REQUEST',
    'CLIENT_DISCONNECT',
    'SERVER_DETAILS',
    'CLIENT_JOIN',
    'CLIENT_JOIN_PLUS',
    'PLAYERS_UPDATE',
    'PLAYERS_UPDATE_PLUS',
    'GAME_INITIATION',
    'DOWNLOADING_FILE',
    'DOWNLOADING_FILE_NEXT',
    'LEVEL_CYCLE',
    'END_OF_LEVEL',
    'EVENTS_UPDATE',
    'SERVER_STOPPED',
    'CONSOLE_MESSAGE',
    'PLUS_INFO',
    'CHAT_MESSAGE',
    'CHAT_MESSAGE_PLUS',
    'SPECTATORS_UPDATE',
    'SCRIPT_LIST'
]

UDP_EVENTS = [
    'GAMEPLAY',
    'PING',
    'QUERY',
    'PASSWORD',
    'KEEP_ALIVE',
    'GAME_STATE',
    'PONG',
    'QUERY_REPLY',
    'PASSWORD_REPLY',
]

__all__ = [
    'Character',
    'GameModeNonPlus',
    'Team',
    'DISCONNECT_MESSAGES',
    'VERSION_STRINGS',
    'CustomGameMode',
    'PLAYER_ARRAY',
    'PLAYER_ARRAY_PLUS',
    'SafeGreedyString',
    'VERSION_STRING',
    *TCP_EVENTS,
    *UDP_EVENTS
]

CLIENT_TO_SERVER = 'CLIENT_TO_SERVER'
SERVER_TO_CLIENT = 'SERVER_TO_CLIENT'
DEFAULT = 'DEFAULT'
PLUS_ONLY = 'PLUS_ONLY'
TCP = 'TCP'
UDP = 'UDP'


class SafeStringEncoded(StringEncoded, metaclass=ABCMeta):
    def _emitfulltype(self, _ksy, _bitwise):
        return dict(size_eos=True, type="str", encoding=self.encoding)

    def _decode(self, obj, context, path):
        return obj.decode(self.encoding, errors='ignore')


class Bool(Subconstruct):
    def __init__(self, subcon, negate=False):
        super().__init__(subcon)
        self.negate = bool(negate)

    def _parse(self, stream, context, path):
        negate = evaluate(self.negate, context)
        obj = bool(self.subcon._parsereport(stream, context, path))
        if negate:
            return not obj
        return obj

    def _build(self, obj, stream, context, path):
        negate = evaluate(self.negate, context)
        obj = self.subcon._build((not bool(obj)) if negate else bool(obj), stream, context, path)
        return obj


class IntEnumCases(dict):
    def get(self, key, default=None):
        if isinstance(key, EnumIntegerString):
            key = int(key)
        return super().get(key, default)


class PlusVersion(enum.Enum):
    # TODO: fill
    pre0 = 0x20, 0x00, 0x00, 0x00, 0x00, 0x00  # 2008-04-05
    pre1 = 0x20, 0x00, 0x00, 0x00, 0x00, 0x00  # 2008-04-09
    pre2 = 0x20, 0x00, 0x00, 0x00, 0x00, 0x00  # 2008-06-22
    v0_0 = 0x20, 0x00, 0x00, 0x00, 0x00, 0x00  # 2008-07-03
    v0_1 = 0x20, 0x00, 0x00, 0x00, 0x00, 0x00  # 2008-08-28
    v0_2 = 0x20, 0x00, 0x00, 0x00, 0x00, 0x00  # 2008-11-27
    v1_0 = 0x20, 0x00, 0x00, 0x00, 0x00, 0x00  # 2009-04-09
    v1_1 = 0x20, 0x00, 0x00, 0x00, 0x00, 0x00  # 2009-04-10
    v1_2 = 0x20, 0x00, 0x00, 0x00, 0x00, 0x00  # 2009-07-28
    v2_0 = 0x20, 0x00, 0x00, 0x00, 0x00, 0x00  # 2010-04-08
    v3_0 = 0x20, 0x01, 0x00, 0x00, 0x03, 0x00  # 2010-06-30  (not sure)
    v4_0 = 0x20, 0x00, 0x00, 0x00, 0x00, 0x00  # 2013-01-28
    v4_1 = 0x20, 0x01, 0x01, 0x00, 0x04, 0x00  # 2013-02-05  rerelease from 2013-02-04
    v4_2 = 0x20, 0x00, 0x00, 0x00, 0x00, 0x00  # 2013-09-19
    # v4_3 = OOPS DATA MISSING OOPS OOPS OOPS  # 2013-10-30
    v5_0 = 0x20, 0x00, 0x00, 0x00, 0x00, 0x00  # 2015-04-06
    v5_1 = 0x20, 0x00, 0x00, 0x00, 0x00, 0x00  # 2015-08-24
    v5_2 = 0x20, 0x00, 0x00, 0x00, 0x00, 0x00  # 2016-04-20
    v5_3 = 0x20, 0x00, 0x00, 0x00, 0x00, 0x00  # 2016-07-09
    v5_4 = 0x20, 0x00, 0x00, 0x00, 0x00, 0x00  # 2016-12-06
    v5_5 = 0x20, 0x00, 0x00, 0x00, 0x00, 0x00  # 2017-06-01
    v5_6 = 0x20, 0x00, 0x00, 0x00, 0x00, 0x00  # 2018-02-28
    v5_7 = 0x20, 0x03, 0x00, 0x00, 0x06, 0x00  # 2020-08-16
    v5_8 = 0x20, 0x00, 0x00, 0x00, 0x00, 0x00  # 2021-04-22
    v5_9 = 0x20, 0x03, 0x09, 0x00, 0x05, 0x00  # 2021-05-11

    latest = v5_9


class Character(enum.IntEnum):
    JAZZ = 0
    SPAZ = 1
    BIRD = 2
    LORI = FROG = 3


class GameModeNonPlus(enum.IntEnum):
    SINGLEPLAYER = 0
    COOP = 1
    BATTLE = 2
    RACE = 3
    TREASURE_HUNT = 4
    CTF = 5


class CustomGameMode(enum.IntEnum):
    OFF = 0
    ROAST_TAG = 1
    LRS = 2
    XLRS = 3
    PESTILENCE = 4
    TEAM_BATTLE = 11
    JAILBREAK = 12
    DEATH_CTF = 13
    FLAG_RUN = 14
    TLRS = 15
    DOMINATION = 16


class GameMode(enum.IntEnum):
    BATTLE = 1
    TREASURE = 2
    CTF = CAPTURE = 3
    RACE = 4
    COOP = 5
    ROAST_TAG = 6
    LRS = 7
    XLRS = 8
    PESTILENCE = 9
    TEAM_BATTLE = 10
    JAILBREAK = 11
    DEATH_CTF = 12
    FLAG_RUN = 13
    TLRS = 14
    DOMINATION = 15
    HEAD_HUNTERS = 16

    @property
    def pretty(self):
        return {
            self.BATTLE: 'Battle',
            self.TREASURE: 'Treasure',
            self.CTF: 'Capture The Flag',
            self.RACE: 'Race',
            self.COOP: 'Cooperative',
            self.ROAST_TAG: 'Roast Tag',
            self.LRS: 'LRS',
            self.XLRS: 'XLRS',
            self.PESTILENCE: 'Pestilence',
            self.TEAM_BATTLE: 'Team Battle',
            self.JAILBREAK: 'Jailbreak',
            self.DEATH_CTF: 'Death Capture The Flag',
            self.FLAG_RUN: 'Flagrun',
            self.TLRS: 'TLRS',
            self.DOMINATION: 'Domination',
            self.HEAD_HUNTERS: 'Head Hunters',
        }.get(self, 'Unknown')


class Team(enum.IntEnum):
    BLUE = 0
    RED = 1


DISCONNECT_MESSAGES = {
    'Unknown error': 1,
    'Version different': 2,
    'Server is full': 3,
    'Error during handshaking': 4,
    'Feature not supported in shareware': 5,
    'Error downloading level': 6,
    'Connection lost': 7,
    'Winsock error': 8,
    'Connection timed out': 9,
    'Server stopped': 10,
    'Kicked off': 11,
    'Banned': 12,
    'Denied': 13,  # Starting from 13 are JJ2+
    'Version of JJ2+ is different': 14,
    'Server kicked you for idling': 15,
    'No downloads allowed': 16,
    'Unauthorized file request': 17,
    'No splitscreeners allowed': 18,
    'Kicked for spamming': 19
}

VERSION_STRINGS = {
    '1.24': '24  ',
    '1.23': '21  ',
}

SafeGreedyString = functools.partial(SafeStringEncoded, GreedyBytes)

ENCODING = 'CP1250'
GreedyString = SafeGreedyString(ENCODING)
CString = SafeStringEncoded(NullTerminated(GreedyBytes), ENCODING)
CString._emitfulltype = lambda: dict(type='strz', encoding=ENCODING)


PLAYER_ARRAY = Struct(
    player_id=Byte,
    team_and_char=Byte,
    fur_color=Byte[4],
    rabbit_name=CString
)

PLAYER_ARRAY_PLUS = Struct(
    player_id=Byte,
    team=Byte,
    char=Byte,
    fur_color=Byte[4],
    sprite_mode=Default(Byte, 17),
    sprite_mode_param=Default(Byte, this.player_id),
    light_type=Default(Byte, 10),
    light_size=Default(Byte, 13),
    antigrav_and_nofire=Default(Byte, 0),
    unused=Default(Byte, 0),
    rabbit_name=CString
)

VERSION_STRING = Enum(PaddedString(4, 'ascii'), **VERSION_STRINGS)

#                                          TCP PACKETS                                            #

# Client -> Server
# -------------------------------------------------------------------------------------------------
PLAYER_DETAILS = Struct(
    packet_id=Const(b'\x0E'),
    players=PrefixedArray(Byte, PLAYER_ARRAY)
)

PLAYER_DETAILS_PLUS = Struct(
    packet_id=Const(b'\x0E'),
    players=PrefixedArray(Byte, PLAYER_ARRAY_PLUS)
)

JOIN_REQUEST = Struct(
    packet_id=Const(b'\x0F'),
    udp_bind=Short,
    client_version=VERSION_STRING,
    number_of_players_from_client=Byte
)

DOWNLOAD_REQUEST = Struct(
    packet_id=Const(b'\x15'),
    file_name=PascalString(Byte, ENCODING)
)

UPDATE_REQUEST = Struct(
    packet_id=Const(b'\x1A')
)

UPDATE_REQUEST_PLUS = Struct(
    packet_id=Const(b'\x1A'),
    level_challenge=Byte[4]
)

SPECTATE_REQUEST = Struct(
    packet_id=Const(b'\x42'),
    spectating=OneOf(Byte, {20, 20 + 1})
)

PLUS_REQUEST = Struct(
    packet_id=Const(b'\x3F'),
    plus_timestamp=Byte[6]
)

# Server -> Client
# -------------------------------------------------------------------------------------------------
SPECTATORS_UPDATE_BITWISE = Struct(
    unknown_data=Byte,
    states=Byte[4]
)


class SpectateTarget(enum.IntEnum):
    BOTTOM_FEEDER = -6
    RING_HOLDER = -5
    BLUE_FLAG = -4
    RED_FLAG = -3
    STOP_SPECTATING_1 = -2
    START_SPECTATING_1 = -1
    START_SPECTATING_2 = 0
    STOP_SPECTATING_2 = 33
    START_SPECTATING_3 = 34


SPECTATORS_UPDATE_BYTEWISE = Struct(
    spectators=GreedyRange(
        Struct(
            is_out=Bool(Byte),
            client_id=Byte,
            spectate_target=SignedByte
        )
    )
)

SPECTATORS_UPDATE = Struct(
    packet_id=Const(b'\x41'),
    packet_design=Byte,
    data=Switch(this.packet_design, {
        0: SPECTATORS_UPDATE_BITWISE,
        1: SPECTATORS_UPDATE_BYTEWISE
    }),
)

SCRIPT_LIST = Struct(
    packet_id=Const(b'\x5A'),
    level_challenge=Byte[4],
    script_data=Byte[5],
    scripts=GreedyRange(
        Struct(
            script_data=Byte[5],
            name=PascalString(Byte, ENCODING),
        )
    )

)

LATENCY_UPDATE = Struct(
    packet_id=Const(b'\x49'),
    latencies=GreedyRange(
        Struct(
            player_id=Byte,
            latency=Short
        )
    )
)

CLIENT_DISCONNECT = Struct(
    packet_id=Const(b'\x0D'),
    disconnect_message=Enum(Byte, **DISCONNECT_MESSAGES),
    client_id=SignedByte,
    client_version=VERSION_STRING,
    include_reason=Optional(Byte),
    reason=If(
        this.include_reason,
        PascalString(Byte, ENCODING)
    )
)

PLUS_SERVER_DETAILS = Struct(
    level_challenge=Byte[4],
    keep_alive_data=Byte[4],
    plus_version=Short[2],
    music_crc=Switch(Byte, {0: Pass, 1: PrefixedArray(Byte, Byte)}),
    scripts=Switch(
        Byte, {
            0: Pass,
            1: Struct(
                script_crc=Byte[4],
            ),
            2: Struct(
                number_of_script_files=Byte,
                number_of_required_files=Byte,
                number_of_optional_files=Byte,
                padding=Padding(1)
            )
        }
    )
)

SERVER_DETAILS = Struct(
    packet_id=Const(b'\x10'),
    client_id=Byte,
    player_id=Byte,
    level_file_name=PascalString(Byte, ENCODING),
    level_crc=Int,
    tileset_crc=Int,
    game_mode=Enum(Byte, GameModeNonPlus),
    max_score=Byte,
    plus_info=Optional(PLUS_SERVER_DETAILS)
)

UPDATE_READY = Struct(
    packet_id=Const(b'\x51')
)

CLIENT_JOIN = Struct(
    packet_id=Const(b'\x11'),
    client_id=Byte,
    players=PrefixedArray(Byte, PLAYER_ARRAY)
)

CLIENT_JOIN_PLUS = Struct(
    packet_id=Const(b'\x11'),
    client_id=Byte,
    players=PrefixedArray(Byte, PLAYER_ARRAY_PLUS)
)

PLAYERS_UPDATE = Struct(
    packet_id=Const(b'\x12'),
    players=PrefixedArray(
        Byte,
        Struct(
            'client_id' / Byte,  # type: ignore
            *PLAYER_ARRAY.subcons
        )
    )
)

PLAYERS_UPDATE_PLUS = Struct(
    packet_id=Const(b'\x12'),
    unused=Byte,
    players=GreedyRange(
        Struct(
            'client_id' / Byte,  # type: ignore
            *PLAYER_ARRAY_PLUS.subcons
        )
    )
)

GAME_INITIATION = Struct(
    packet_id=Const(b'\x13'),
)

DOWNLOADING_FILE = Struct(
    packet_id=Const(b'\x14'),
    packet_count=Int32ul,
    data=GreedyBytes
)

DOWNLOADING_FILE_OPEN = Struct(
    packet_id=Const(b'\x14'),
    packet_count=Int32ul,
    unknown_data=Byte[4],
    file_name=PascalString(Byte, ENCODING)
)

DOWNLOADING_FILE_NEXT = Struct(
    packet_id=Const(b'\x14'),
    packet_count=Int32ul,
    file_content=GreedyBytes
)

LEVEL_CYCLE = Struct(
    packet_id=Const(b'\x16'),
    level_crc=Int,
    tileset_crc=Int,
    level_file_name=PascalString(Byte, ENCODING),
    level_challenge=Byte[4],
    different=Bool(Byte),
    music=Byte,
    music_crc=Byte[4],
    script_data=Optional(Byte[5])
)

END_OF_LEVEL = Struct(
    packet_id=Const(b'\x17'),
    unknown_data=GreedyBytes
)

EVENTS_UPDATE = Struct(
    packet_id=Const(b'\x18'),
    checksum=Optional(Short),
    counter=Optional(Short),
    unknown_data=Optional(GreedyString)
)

SERVER_STOPPED = Struct(
    packet_id=Const(b'\x19')
)

PLUS_INFO = Struct(
    packet_id=Const(b'\x3F'),
    custom_gamemode=Enum(Byte, CustomGameMode),
    health_info=Byte,
    plus_data=Byte
)

CONSOLE_MESSAGE = Struct(
    packet_id=Const(b'\x40'),
    message_type=Byte,
    text=GreedyString
)

# Client -> Server
# Server -> Client
# -------------------------------------------------------------------------------------------------
CHAT_MESSAGE = Struct(
    packet_id=Const(b'\x1B'),
    from_client_id_and_team=Byte,
    padding=Padding(1, b' '),
    text=GreedyString,
)

CHAT_MESSAGE_PLUS = Struct(
    packet_id=Const(b'\x1B'),
    client_id=Byte,
    team_chat=Byte,
    text=GreedyString,
)

#                                          UDP PACKETS                                            #

# Client -> Server
# -------------------------------------------------------------------------------------------------
PING = Struct(
    packet_id=Const(b'\x03'),
    number_in_list=Byte,
    unknown_data=Byte[4],
    client_version=VERSION_STRING
)

QUERY = Struct(
    packet_id=Const(b'\x05'),
    number_in_list=Byte
)

PASSWORD = Struct(
    packet_id=Const(b'\x0A'),
    password=<PASSWORD>(Byte, ENCODING)
)

GAME_STATE = Struct(
    packet_id=Const(b'\x45'),
    game_state=Byte,
    time_left=Int  # in milliseconds
)

# Server -> Client
# -------------------------------------------------------------------------------------------------
PONG = Struct(
    packet_id=Const(b'\x04'),
    number_in_list_from_ping=Byte,
    unknown_data=Byte[4],
    game_mode_etc=Byte
)

QUERY_REPLY = Struct(
    packet_id=Const(b'\x06'),
    number_in_list=Byte,
    timer_sync=Byte,
    laps_on_timer_sync=Byte,
    unknown_data_1=Byte[2],
    client_version=VERSION_STRING,
    player_count=Byte,
    unknown_data_2=Byte,
    game_mode=Enum(Byte, GameModeNonPlus),
    player_limit=Byte,
    server_name=PascalString(Byte, ENCODING),
    unknown_data_3=Byte
)

PASSWORD_REPLY = Struct(
    packet_id=Const(b'\x0B'),
    password_ok=Byte
)

# Client -> Server
# Server -> Client
# -------------------------------------------------------------------------------------------------
MOVEMENT = Struct(
    direction=Byte,
    action=Byte,
    x_position=Byte[2],
    y_position=Byte[2],
    anim=Byte,
    shield_and_frame=Byte,
    velocities=Struct(x=Byte, y=Byte)
)

PLAYER_STATUS = Struct(
    packet_id=Const(b'\x01'),
    packet_count=Byte,
    player_mask=Enum(Byte, **{'1': 1, '3': 2, '7': 3, '15': 4}),
    timer_sync=Byte,
    movement_data=GreedyRange(MOVEMENT)
)

KEEP_ALIVE = Struct(
    packet_id=Const(b'\x09'),
    udp_count=Byte,
    keep_alive_data=GreedyBytes,
)


class GameplayEvent(enum.IntEnum):
    PLAYER_GOT_ROASTED = 10
    BULLET_SHOT = 16


BULLET_SHOT = Struct(
    sub_id=Int,
    count=Byte,
    unknown_data_1=Bytes(3),
    x_quadrant=Byte,
    unknown_byte=Byte,
    y_quadrant=Byte,
    unknown_byte_2=Byte,
    unknown_data_2=Bytes(5),
    direction=Byte,
    gun=Byte,
    ammo_left=Byte
)

PLAYER_GOT_ROASTED = Struct(
    sub_id=Int,
    victim_player_id=Byte,
    victim_roasts=Byte,
    unknown_data_1=Byte[3],
    killer_player_id=Byte,
    killer_roasts=Byte,
    unknown_data_2=Byte[3],
)

DESTROY_OBJECT = Struct(
    object_hash=Byte[2]
)

GAMEPLAY = Struct(
    packet_id=Const(b'\x07'),
    udp_count=Byte,
    event_id=Enum(Byte, GameplayEvent),
    event_data=Switch(
        this.event_id,
        IntEnumCases({
            v.value: getattr(sys.modules[__name__], v.name)
            for k, v in GameplayEvent._value2member_map_.items()
        }),
        default=GreedyBytes
    )
)


class GameSerializer(Serializer):
    def __init__(self, version_info, timestamp=PlusVersion.latest):
        self.version = version_info.version
        self.is_plus = version_info.plus_version is not None
        self.timestamp = timestamp

    @staticmethod
    def compute_checksum(buffer: bytes) -> bytes:
        c = bytes((79, 79)) + buffer
        L = R = 1
        for n in range(2, len(c)):
            L += c[n]
            R += L
        return Byte.build(L % 251) + Byte.build(R % 251)

    def build_data(self, protocol, construct, build_from_object, length_16_bit=False) -> bytes:
        packet = construct.build(build_from_object)
        prefix = bytes(0)
        assert protocol in (TCP, UDP), f'protocol must be {TCP} or {UDP}'
        if protocol == TCP:
            if length_16_bit:
                prefix = bytes(1) + Int16ul.build(len(packet))
            else:
                prefix = Byte.build(len(packet) + 1)
        if protocol == UDP:
            prefix = self.compute_checksum(packet)
        data = prefix + packet
        return data

    def build_join_request(self, connection, construct=JOIN_REQUEST) -> bytes:
        build_from_object = {
            'udp_bind': connection.udp_port,
            'client_version': self.version,
            'number_of_players_from_client': len(connection.client.rabbits)
        }
        return self.build_data(TCP, construct, build_from_object)

    def build_download_request(self, file_name, construct=DOWNLOAD_REQUEST) -> bytes:
        build_from_object = {'file_name': file_name}
        return self.build_data(TCP, construct, build_from_object)

    def build_player_details(self, connection, construct=PLAYER_DETAILS) -> bytes:
        if self.is_plus and construct == PLAYER_DETAILS:
            construct = PLAYER_DETAILS_PLUS
        build_from_object = {
            'number_of_players': len(connection.client.rabbits),
            'players': [
                {
                    'player_id': ordinal,
                    'team': player.team.value,
                    'char': player.character.value,
                    'fur_color': player.fur,
                    'rabbit_name': player.raw_nickname,
                } for ordinal, player in enumerate(connection.client.rabbits)
            ]
        }
        return self.build_data(TCP, construct, build_from_object)

    def build_chat_message(
            self, message, is_plus=False, construct=CHAT_MESSAGE
    ) -> bytes:
        if is_plus and construct == CHAT_MESSAGE:
            construct = CHAT_MESSAGE_PLUS
        build_from_object = {
            'client_id': message.author.client_id,
            'text': message.raw_text,
            'team_chat': getattr(message, 'team', None) is not None,

        }
        return self.build_data(TCP, construct, build_from_object, length_16_bit=is_plus)

    def build_plus_request(self, construct=PLUS_REQUEST) -> bytes:
        build_from_object = {'plus_timestamp': self.timestamp.value}
        return self.build_data(TCP, construct, build_from_object)

    def build_ping(self, unknown_data=bytes(4), construct=PING) -> bytes:
        build_from_object = {
            'number_in_list': random.choice(range(1, 7)),
            'unknown_data': unknown_data,
            'client_version': self.version
        }
        return self.build_data(UDP, construct, build_from_object)

    def build_keep_alive(self, data, udp_count, construct=KEEP_ALIVE) -> bytes:
        build_from_object = {
            'udp_count': udp_count if -1 < udp_count < 0xff else 0,
            'keep_alive_data': data
        }
        return self.build_data(UDP, construct, build_from_object)

    def build_update_request(self, level_challenge, construct=UPDATE_REQUEST) -> bytes:
        if self.is_plus and construct == UPDATE_REQUEST:
            construct = UPDATE_REQUEST_PLUS
        build_from_object = {'level_challenge': level_challenge}
        return self.build_data(TCP, construct, build_from_object)

    def build_spectate_request(self, spectating=True, construct=SPECTATE_REQUEST) -> bytes:
        build_from_object = {'spectating': 20 + spectating}
        return self.build_data(TCP, construct, build_from_object)

    def build_query(self, construct=QUERY) -> bytes:
        build_from_object = {'number_in_list': random.randint(1, 7)}
        return self.build_data(TCP, construct, build_from_object)


def _load_constructs(protocol) -> typing.Dict[Struct, str]:
    return {
        getattr(sys.modules[__name__], name): name
        for name in {UDP: UDP_EVENTS, TCP: TCP_EVENTS}[protocol]
    }


class GameProtocol(Protocol):
    map = {
        TCP: {
            CLIENT_TO_SERVER: {
                DEFAULT: {
                    0x0E: PLAYER_DETAILS,
                    0x0F: JOIN_REQUEST
                },
                PLUS_ONLY: {
                    0x0E: PLAYER_DETAILS_PLUS,
                    0x3F: PLUS_REQUEST,
                    0x42: SPECTATE_REQUEST
                }
            },
            SERVER_TO_CLIENT: {
                DEFAULT: {
                    0x0D: CLIENT_DISCONNECT,
                    0x10: SERVER_DETAILS,
                    0x11: CLIENT_JOIN,
                    0x12: PLAYERS_UPDATE,
                    0x13: GAME_INITIATION,
                    0x14: DOWNLOADING_FILE,
                    0x15: DOWNLOAD_REQUEST,
                    0x16: LEVEL_CYCLE,
                    0x17: END_OF_LEVEL,
                    0x18: EVENTS_UPDATE,
                    0x19: SERVER_STOPPED,
                    0x1A: UPDATE_REQUEST,
                    0x1B: CHAT_MESSAGE
                },
                PLUS_ONLY: {
                    0x11: CLIENT_JOIN_PLUS,
                    0x12: PLAYERS_UPDATE_PLUS,
                    0x1B: CHAT_MESSAGE_PLUS,
                    0x3F: PLUS_INFO,
                    0x40: CONSOLE_MESSAGE,
                    0x41: SPECTATORS_UPDATE,
                    0x49: LATENCY_UPDATE,
                    0x51: UPDATE_READY,
                    0x5A: SCRIPT_LIST
                }
            },
        },
        UDP: {
            CLIENT_TO_SERVER: {
                DEFAULT: {
                    0x03: PING,
                    0x05: QUERY,
                    0x08: GAMEPLAY,
                    0x0A: PASSWORD
                },
                PLUS_ONLY: {
                    0x09: KEEP_ALIVE
                },
            },
            SERVER_TO_CLIENT: {
                DEFAULT: {
                    0x04: PONG,
                    0x06: QUERY_REPLY,
                    0x07: GAMEPLAY,
                    0x0B: PASSWORD_REPLY
                },
                PLUS_ONLY: {
                    0x09: KEEP_ALIVE,
                    0x45: GAME_STATE
                }
            },
        }
    }
    namespace = {
        TCP: _load_constructs(TCP),
        UDP: _load_constructs(UDP)
    }

    client: GameClient

    def __init__(self, connection, waiter, eof_closure=False, ps=(TCP, UDP)):
        super().__init__(connection, waiter, eof_closure)
        self._ct: asyncio.Transport | None = None
        self._dt: asyncio.DatagramTransport | None = None
        self._ts = collections.deque()
        self._ps = tuple(ps)
        self._buf = bytearray()
        self._defic = 0

    def parse_data(
            self,
            data,
            protocol,
            turn=SERVER_TO_CLIENT,
            is_plus=False,
            default=(None, None),
            warn=False
    ) -> Container:
        packet = Struct(id=Byte, data=GreedyBytes).parse(data)
        turn = self.map[protocol][turn]
        parsers = {**turn[DEFAULT], **(turn.get(PLUS_ONLY, {}) if is_plus else {})}
        if packet.id not in parsers:
            if warn:
                print(
                    f'Received a {protocol} packet of unknown ID {packet.id}\n'
                    f'(0x{Byte.build(packet.id).hex().upper()}):\n'
                    f'{data.hex(" ").upper()}',
                    file=sys.stderr
                )
            return default
        parser = parsers[packet.id]
        name = self.namespace[protocol][parser]
        try:
            parsed = parser.parse(data)
        except Exception as exc:
            if warn:
                print(
                    f'ERROR during parsing the {protocol} {str(name).casefold()!r} packet.\n'
                    f' Given value:\n {data.hex(" ").upper()} \n {data}\n' +
                    type(exc).__name__ + ':', ' '.join(map(str, exc.args)),
                    file=sys.stderr
                )
            parsed = None
        if parsed:
            return name, parsed
        return default

    def connection_made(self, transport):
        super().connection_made(transport)
        self.connection.reset_waiter()
        self._ts.append(transport)
        protocol = self._ps[len(self._ts) - 1]
        if protocol not in self._ps:
            raise ArgumentError(
                f'{protocol!r} protocol isn\'t supported.',
                method='connection_made()'
            )
        if protocol == TCP:
            self.connection._ct = self._ct = transport
        if protocol == UDP:
            self.connection._dt = self._dt = transport
        self.client.dispatch(self.connection, f'{protocol.casefold()}_connect')
        if len(self._ts) == len(self._ps):
            self.client.expose(self.connection)
            self.client.dispatch(self.connection, 'connect')

    def packet_received(self, data, protocol=None, *args, **kwargs):
        if protocol is None:
            raise ArgumentError(
                'packet_received() missing 1 required argument: \'protocol\'',
                method='packet_received()'
            )
        self.client.dispatch(self.connection, 'data_receive', data, protocol)
        is_plus = self.client.is_plus or data[0] == (2**6 - 1)
        name, packet = self.parse_data(data, protocol, is_plus=is_plus)
        if packet is not None:
            if name.endswith('_PLUS'):
                name = name[:-5]
            event, *args = self.connection.handle(name, packet)
            if event is not None:
                self.client.dispatch(self.connection, event, *args)

    def data_received(self, data: bytes):
        alen = eof = len(data)
        defic = self._defic

        if defic == 0:
            bof = 1
            if data[0] == 0:
                bof += 2
                elen = Int16ul.parse(data[1:3]) + 3
            else:
                elen = data[0]
            diff = elen - alen
            if diff < 0:
                eof += diff
                diff = 0
            defic = diff
        elif defic > 0:
            bof = 0
            if alen < defic:
                defic -= alen
            else:
                eof -= defic
                defic = 0
        else:
            raise ArgumentError(f'{defic=} is less than 0', method='data_received()')

        self._buf.extend(data[bof:eof])
        self._defic = defic

        if defic == 0:
            self.packet_received(bytes(self._buf), TCP)
            self._buf = bytearray()
            if eof < alen:
                tail = data[eof:]
                self.data_received(tail)

    def datagram_received(self, datagram: bytes, addr: typing.Tuple[str, int]):
        local = self.connection.serializer.compute_checksum(datagram[2:])
        foreign = datagram[:2]
        if local == foreign:
            self.packet_received(datagram[2:], UDP)

from __future__ import annotations

# TODO:
#    protocol packet patterns should be protocol fields

import collections
import enum
import functools
import random
import sys
import typing
from abc import ABCMeta

if typing.TYPE_CHECKING:
    import asyncio
    from jj2.game.client import GameClient

from construct import (
    Byte,
    Bytes,
    Const,
    Container,
    Default,
    Enum,
    EnumIntegerString,
    evaluate,
    GreedyBytes,
    GreedyRange,
    If,
    Int,
    Int8sb as SignedByte,
    Int16ul,
    Int32ul,
    NullTerminated,
    OneOf,
    Optional,
    PaddedString,
    Padding,
    PascalString,
    Pass,
    PrefixedArray,
    Short,
    Struct,
    StringEncoded,
    Subconstruct,
    Switch,
    this,
)

from jj2 import Serializer, Protocol
from jj2.exceptions import ArgumentError

TCP_EVENTS = [
    'LATENCY_UPDATE',
    'PLAYER_DETAILS',
    'PLAYER_DETAILS_PLUS',
    'JOIN_REQUEST',
    'DOWNLOAD_REQUEST',
    'UPDATE_REQUEST',
    'UPDATE_READY',
    'SPECTATE_REQUEST',
    'PLUS_REQUEST',
    'CLIENT_DISCONNECT',
    'SERVER_DETAILS',
    'CLIENT_JOIN',
    'CLIENT_JOIN_PLUS',
    'PLAYERS_UPDATE',
    'PLAYERS_UPDATE_PLUS',
    'GAME_INITIATION',
    'DOWNLOADING_FILE',
    'DOWNLOADING_FILE_NEXT',
    'LEVEL_CYCLE',
    'END_OF_LEVEL',
    'EVENTS_UPDATE',
    'SERVER_STOPPED',
    'CONSOLE_MESSAGE',
    'PLUS_INFO',
    'CHAT_MESSAGE',
    'CHAT_MESSAGE_PLUS',
    'SPECTATORS_UPDATE',
    'SCRIPT_LIST'
]

UDP_EVENTS = [
    'GAMEPLAY',
    'PING',
    'QUERY',
    'PASSWORD',
    'KEEP_ALIVE',
    'GAME_STATE',
    'PONG',
    'QUERY_REPLY',
    'PASSWORD_REPLY',
]

__all__ = [
    'Character',
    'GameModeNonPlus',
    'Team',
    'DISCONNECT_MESSAGES',
    'VERSION_STRINGS',
    'CustomGameMode',
    'PLAYER_ARRAY',
    'PLAYER_ARRAY_PLUS',
    'SafeGreedyString',
    'VERSION_STRING',
    *TCP_EVENTS,
    *UDP_EVENTS
]

CLIENT_TO_SERVER = 'CLIENT_TO_SERVER'
SERVER_TO_CLIENT = 'SERVER_TO_CLIENT'
DEFAULT = 'DEFAULT'
PLUS_ONLY = 'PLUS_ONLY'
TCP = 'TCP'
UDP = 'UDP'


class SafeStringEncoded(StringEncoded, metaclass=ABCMeta):
    def _emitfulltype(self, _ksy, _bitwise):
        return dict(size_eos=True, type="str", encoding=self.encoding)

    def _decode(self, obj, context, path):
        return obj.decode(self.encoding, errors='ignore')


class Bool(Subconstruct):
    def __init__(self, subcon, negate=False):
        super().__init__(subcon)
        self.negate = bool(negate)

    def _parse(self, stream, context, path):
        negate = evaluate(self.negate, context)
        obj = bool(self.subcon._parsereport(stream, context, path))
        if negate:
            return not obj
        return obj

    def _build(self, obj, stream, context, path):
        negate = evaluate(self.negate, context)
        obj = self.subcon._build((not bool(obj)) if negate else bool(obj), stream, context, path)
        return obj


class IntEnumCases(dict):
    def get(self, key, default=None):
        if isinstance(key, EnumIntegerString):
            key = int(key)
        return super().get(key, default)


class PlusVersion(enum.Enum):
    # TODO: fill
    pre0 = 0x20, 0x00, 0x00, 0x00, 0x00, 0x00  # 2008-04-05
    pre1 = 0x20, 0x00, 0x00, 0x00, 0x00, 0x00  # 2008-04-09
    pre2 = 0x20, 0x00, 0x00, 0x00, 0x00, 0x00  # 2008-06-22
    v0_0 = 0x20, 0x00, 0x00, 0x00, 0x00, 0x00  # 2008-07-03
    v0_1 = 0x20, 0x00, 0x00, 0x00, 0x00, 0x00  # 2008-08-28
    v0_2 = 0x20, 0x00, 0x00, 0x00, 0x00, 0x00  # 2008-11-27
    v1_0 = 0x20, 0x00, 0x00, 0x00, 0x00, 0x00  # 2009-04-09
    v1_1 = 0x20, 0x00, 0x00, 0x00, 0x00, 0x00  # 2009-04-10
    v1_2 = 0x20, 0x00, 0x00, 0x00, 0x00, 0x00  # 2009-07-28
    v2_0 = 0x20, 0x00, 0x00, 0x00, 0x00, 0x00  # 2010-04-08
    v3_0 = 0x20, 0x01, 0x00, 0x00, 0x03, 0x00  # 2010-06-30  (not sure)
    v4_0 = 0x20, 0x00, 0x00, 0x00, 0x00, 0x00  # 2013-01-28
    v4_1 = 0x20, 0x01, 0x01, 0x00, 0x04, 0x00  # 2013-02-05  rerelease from 2013-02-04
    v4_2 = 0x20, 0x00, 0x00, 0x00, 0x00, 0x00  # 2013-09-19
    # v4_3 = OOPS DATA MISSING OOPS OOPS OOPS  # 2013-10-30
    v5_0 = 0x20, 0x00, 0x00, 0x00, 0x00, 0x00  # 2015-04-06
    v5_1 = 0x20, 0x00, 0x00, 0x00, 0x00, 0x00  # 2015-08-24
    v5_2 = 0x20, 0x00, 0x00, 0x00, 0x00, 0x00  # 2016-04-20
    v5_3 = 0x20, 0x00, 0x00, 0x00, 0x00, 0x00  # 2016-07-09
    v5_4 = 0x20, 0x00, 0x00, 0x00, 0x00, 0x00  # 2016-12-06
    v5_5 = 0x20, 0x00, 0x00, 0x00, 0x00, 0x00  # 2017-06-01
    v5_6 = 0x20, 0x00, 0x00, 0x00, 0x00, 0x00  # 2018-02-28
    v5_7 = 0x20, 0x03, 0x00, 0x00, 0x06, 0x00  # 2020-08-16
    v5_8 = 0x20, 0x00, 0x00, 0x00, 0x00, 0x00  # 2021-04-22
    v5_9 = 0x20, 0x03, 0x09, 0x00, 0x05, 0x00  # 2021-05-11

    latest = v5_9


class Character(enum.IntEnum):
    JAZZ = 0
    SPAZ = 1
    BIRD = 2
    LORI = FROG = 3


class GameModeNonPlus(enum.IntEnum):
    SINGLEPLAYER = 0
    COOP = 1
    BATTLE = 2
    RACE = 3
    TREASURE_HUNT = 4
    CTF = 5


class CustomGameMode(enum.IntEnum):
    OFF = 0
    ROAST_TAG = 1
    LRS = 2
    XLRS = 3
    PESTILENCE = 4
    TEAM_BATTLE = 11
    JAILBREAK = 12
    DEATH_CTF = 13
    FLAG_RUN = 14
    TLRS = 15
    DOMINATION = 16


class GameMode(enum.IntEnum):
    BATTLE = 1
    TREASURE = 2
    CTF = CAPTURE = 3
    RACE = 4
    COOP = 5
    ROAST_TAG = 6
    LRS = 7
    XLRS = 8
    PESTILENCE = 9
    TEAM_BATTLE = 10
    JAILBREAK = 11
    DEATH_CTF = 12
    FLAG_RUN = 13
    TLRS = 14
    DOMINATION = 15
    HEAD_HUNTERS = 16

    @property
    def pretty(self):
        return {
            self.BATTLE: 'Battle',
            self.TREASURE: 'Treasure',
            self.CTF: 'Capture The Flag',
            self.RACE: 'Race',
            self.COOP: 'Cooperative',
            self.ROAST_TAG: 'Roast Tag',
            self.LRS: 'LRS',
            self.XLRS: 'XLRS',
            self.PESTILENCE: 'Pestilence',
            self.TEAM_BATTLE: 'Team Battle',
            self.JAILBREAK: 'Jailbreak',
            self.DEATH_CTF: 'Death Capture The Flag',
            self.FLAG_RUN: 'Flagrun',
            self.TLRS: 'TLRS',
            self.DOMINATION: 'Domination',
            self.HEAD_HUNTERS: 'Head Hunters',
        }.get(self, 'Unknown')


class Team(enum.IntEnum):
    BLUE = 0
    RED = 1


DISCONNECT_MESSAGES = {
    'Unknown error': 1,
    'Version different': 2,
    'Server is full': 3,
    'Error during handshaking': 4,
    'Feature not supported in shareware': 5,
    'Error downloading level': 6,
    'Connection lost': 7,
    'Winsock error': 8,
    'Connection timed out': 9,
    'Server stopped': 10,
    'Kicked off': 11,
    'Banned': 12,
    'Denied': 13,  # Starting from 13 are JJ2+
    'Version of JJ2+ is different': 14,
    'Server kicked you for idling': 15,
    'No downloads allowed': 16,
    'Unauthorized file request': 17,
    'No splitscreeners allowed': 18,
    'Kicked for spamming': 19
}

VERSION_STRINGS = {
    '1.24': '24  ',
    '1.23': '21  ',
}

SafeGreedyString = functools.partial(SafeStringEncoded, GreedyBytes)

ENCODING = 'CP1250'
GreedyString = SafeGreedyString(ENCODING)
CString = SafeStringEncoded(NullTerminated(GreedyBytes), ENCODING)
CString._emitfulltype = lambda: dict(type='strz', encoding=ENCODING)


PLAYER_ARRAY = Struct(
    player_id=Byte,
    team_and_char=Byte,
    fur_color=Byte[4],
    rabbit_name=CString
)

PLAYER_ARRAY_PLUS = Struct(
    player_id=Byte,
    team=Byte,
    char=Byte,
    fur_color=Byte[4],
    sprite_mode=Default(Byte, 17),
    sprite_mode_param=Default(Byte, this.player_id),
    light_type=Default(Byte, 10),
    light_size=Default(Byte, 13),
    antigrav_and_nofire=Default(Byte, 0),
    unused=Default(Byte, 0),
    rabbit_name=CString
)

VERSION_STRING = Enum(PaddedString(4, 'ascii'), **VERSION_STRINGS)

#                                          TCP PACKETS                                            #

# Client -> Server
# -------------------------------------------------------------------------------------------------
PLAYER_DETAILS = Struct(
    packet_id=Const(b'\x0E'),
    players=PrefixedArray(Byte, PLAYER_ARRAY)
)

PLAYER_DETAILS_PLUS = Struct(
    packet_id=Const(b'\x0E'),
    players=PrefixedArray(Byte, PLAYER_ARRAY_PLUS)
)

JOIN_REQUEST = Struct(
    packet_id=Const(b'\x0F'),
    udp_bind=Short,
    client_version=VERSION_STRING,
    number_of_players_from_client=Byte
)

DOWNLOAD_REQUEST = Struct(
    packet_id=Const(b'\x15'),
    file_name=PascalString(Byte, ENCODING)
)

UPDATE_REQUEST = Struct(
    packet_id=Const(b'\x1A')
)

UPDATE_REQUEST_PLUS = Struct(
    packet_id=Const(b'\x1A'),
    level_challenge=Byte[4]
)

SPECTATE_REQUEST = Struct(
    packet_id=Const(b'\x42'),
    spectating=OneOf(Byte, {20, 20 + 1})
)

PLUS_REQUEST = Struct(
    packet_id=Const(b'\x3F'),
    plus_timestamp=Byte[6]
)

# Server -> Client
# -------------------------------------------------------------------------------------------------
SPECTATORS_UPDATE_BITWISE = Struct(
    unknown_data=Byte,
    states=Byte[4]
)


class SpectateTarget(enum.IntEnum):
    BOTTOM_FEEDER = -6
    RING_HOLDER = -5
    BLUE_FLAG = -4
    RED_FLAG = -3
    STOP_SPECTATING_1 = -2
    START_SPECTATING_1 = -1
    START_SPECTATING_2 = 0
    STOP_SPECTATING_2 = 33
    START_SPECTATING_3 = 34


SPECTATORS_UPDATE_BYTEWISE = Struct(
    spectators=GreedyRange(
        Struct(
            is_out=Bool(Byte),
            client_id=Byte,
            spectate_target=SignedByte
        )
    )
)

SPECTATORS_UPDATE = Struct(
    packet_id=Const(b'\x41'),
    packet_design=Byte,
    data=Switch(this.packet_design, {
        0: SPECTATORS_UPDATE_BITWISE,
        1: SPECTATORS_UPDATE_BYTEWISE
    }),
)

SCRIPT_LIST = Struct(
    packet_id=Const(b'\x5A'),
    level_challenge=Byte[4],
    script_data=Byte[5],
    scripts=GreedyRange(
        Struct(
            script_data=Byte[5],
            name=PascalString(Byte, ENCODING),
        )
    )

)

LATENCY_UPDATE = Struct(
    packet_id=Const(b'\x49'),
    latencies=GreedyRange(
        Struct(
            player_id=Byte,
            latency=Short
        )
    )
)

CLIENT_DISCONNECT = Struct(
    packet_id=Const(b'\x0D'),
    disconnect_message=Enum(Byte, **DISCONNECT_MESSAGES),
    client_id=SignedByte,
    client_version=VERSION_STRING,
    include_reason=Optional(Byte),
    reason=If(
        this.include_reason,
        PascalString(Byte, ENCODING)
    )
)

PLUS_SERVER_DETAILS = Struct(
    level_challenge=Byte[4],
    keep_alive_data=Byte[4],
    plus_version=Short[2],
    music_crc=Switch(Byte, {0: Pass, 1: PrefixedArray(Byte, Byte)}),
    scripts=Switch(
        Byte, {
            0: Pass,
            1: Struct(
                script_crc=Byte[4],
            ),
            2: Struct(
                number_of_script_files=Byte,
                number_of_required_files=Byte,
                number_of_optional_files=Byte,
                padding=Padding(1)
            )
        }
    )
)

SERVER_DETAILS = Struct(
    packet_id=Const(b'\x10'),
    client_id=Byte,
    player_id=Byte,
    level_file_name=PascalString(Byte, ENCODING),
    level_crc=Int,
    tileset_crc=Int,
    game_mode=Enum(Byte, GameModeNonPlus),
    max_score=Byte,
    plus_info=Optional(PLUS_SERVER_DETAILS)
)

UPDATE_READY = Struct(
    packet_id=Const(b'\x51')
)

CLIENT_JOIN = Struct(
    packet_id=Const(b'\x11'),
    client_id=Byte,
    players=PrefixedArray(Byte, PLAYER_ARRAY)
)

CLIENT_JOIN_PLUS = Struct(
    packet_id=Const(b'\x11'),
    client_id=Byte,
    players=PrefixedArray(Byte, PLAYER_ARRAY_PLUS)
)

PLAYERS_UPDATE = Struct(
    packet_id=Const(b'\x12'),
    players=PrefixedArray(
        Byte,
        Struct(
            'client_id' / Byte,  # type: ignore
            *PLAYER_ARRAY.subcons
        )
    )
)

PLAYERS_UPDATE_PLUS = Struct(
    packet_id=Const(b'\x12'),
    unused=Byte,
    players=GreedyRange(
        Struct(
            'client_id' / Byte,  # type: ignore
            *PLAYER_ARRAY_PLUS.subcons
        )
    )
)

GAME_INITIATION = Struct(
    packet_id=Const(b'\x13'),
)

DOWNLOADING_FILE = Struct(
    packet_id=Const(b'\x14'),
    packet_count=Int32ul,
    data=GreedyBytes
)

DOWNLOADING_FILE_OPEN = Struct(
    packet_id=Const(b'\x14'),
    packet_count=Int32ul,
    unknown_data=Byte[4],
    file_name=PascalString(Byte, ENCODING)
)

DOWNLOADING_FILE_NEXT = Struct(
    packet_id=Const(b'\x14'),
    packet_count=Int32ul,
    file_content=GreedyBytes
)

LEVEL_CYCLE = Struct(
    packet_id=Const(b'\x16'),
    level_crc=Int,
    tileset_crc=Int,
    level_file_name=PascalString(Byte, ENCODING),
    level_challenge=Byte[4],
    different=Bool(Byte),
    music=Byte,
    music_crc=Byte[4],
    script_data=Optional(Byte[5])
)

END_OF_LEVEL = Struct(
    packet_id=Const(b'\x17'),
    unknown_data=GreedyBytes
)

EVENTS_UPDATE = Struct(
    packet_id=Const(b'\x18'),
    checksum=Optional(Short),
    counter=Optional(Short),
    unknown_data=Optional(GreedyString)
)

SERVER_STOPPED = Struct(
    packet_id=Const(b'\x19')
)

PLUS_INFO = Struct(
    packet_id=Const(b'\x3F'),
    custom_gamemode=Enum(Byte, CustomGameMode),
    health_info=Byte,
    plus_data=Byte
)

CONSOLE_MESSAGE = Struct(
    packet_id=Const(b'\x40'),
    message_type=Byte,
    text=GreedyString
)

# Client -> Server
# Server -> Client
# -------------------------------------------------------------------------------------------------
CHAT_MESSAGE = Struct(
    packet_id=Const(b'\x1B'),
    from_client_id_and_team=Byte,
    padding=Padding(1, b' '),
    text=GreedyString,
)

CHAT_MESSAGE_PLUS = Struct(
    packet_id=Const(b'\x1B'),
    client_id=Byte,
    team_chat=Byte,
    text=GreedyString,
)

#                                          UDP PACKETS                                            #

# Client -> Server
# -------------------------------------------------------------------------------------------------
PING = Struct(
    packet_id=Const(b'\x03'),
    number_in_list=Byte,
    unknown_data=Byte[4],
    client_version=VERSION_STRING
)

QUERY = Struct(
    packet_id=Const(b'\x05'),
    number_in_list=Byte
)

PASSWORD = Struct(
    packet_id=Const(b'\x0A'),
    password=<PASSWORD>(Byte, ENCODING)
)

GAME_STATE = Struct(
    packet_id=Const(b'\x45'),
    game_state=Byte,
    time_left=Int  # in milliseconds
)

# Server -> Client
# -------------------------------------------------------------------------------------------------
PONG = Struct(
    packet_id=Const(b'\x04'),
    number_in_list_from_ping=Byte,
    unknown_data=Byte[4],
    game_mode_etc=Byte
)

QUERY_REPLY = Struct(
    packet_id=Const(b'\x06'),
    number_in_list=Byte,
    timer_sync=Byte,
    laps_on_timer_sync=Byte,
    unknown_data_1=Byte[2],
    client_version=VERSION_STRING,
    player_count=Byte,
    unknown_data_2=Byte,
    game_mode=Enum(Byte, GameModeNonPlus),
    player_limit=Byte,
    server_name=PascalString(Byte, ENCODING),
    unknown_data_3=Byte
)

PASSWORD_REPLY = Struct(
    packet_id=Const(b'\x0B'),
    password_ok=Byte
)

# Client -> Server
# Server -> Client
# -------------------------------------------------------------------------------------------------
MOVEMENT = Struct(
    direction=Byte,
    action=Byte,
    x_position=Byte[2],
    y_position=Byte[2],
    anim=Byte,
    shield_and_frame=Byte,
    velocities=Struct(x=Byte, y=Byte)
)

PLAYER_STATUS = Struct(
    packet_id=Const(b'\x01'),
    packet_count=Byte,
    player_mask=Enum(Byte, **{'1': 1, '3': 2, '7': 3, '15': 4}),
    timer_sync=Byte,
    movement_data=GreedyRange(MOVEMENT)
)

KEEP_ALIVE = Struct(
    packet_id=Const(b'\x09'),
    udp_count=Byte,
    keep_alive_data=GreedyBytes,
)


class GameplayEvent(enum.IntEnum):
    PLAYER_GOT_ROASTED = 10
    BULLET_SHOT = 16


BULLET_SHOT = Struct(
    sub_id=Int,
    count=Byte,
    unknown_data_1=Bytes(3),
    x_quadrant=Byte,
    unknown_byte=Byte,
    y_quadrant=Byte,
    unknown_byte_2=Byte,
    unknown_data_2=Bytes(5),
    direction=Byte,
    gun=Byte,
    ammo_left=Byte
)

PLAYER_GOT_ROASTED = Struct(
    sub_id=Int,
    victim_player_id=Byte,
    victim_roasts=Byte,
    unknown_data_1=Byte[3],
    killer_player_id=Byte,
    killer_roasts=Byte,
    unknown_data_2=Byte[3],
)

DESTROY_OBJECT = Struct(
    object_hash=Byte[2]
)

GAMEPLAY = Struct(
    packet_id=Const(b'\x07'),
    udp_count=Byte,
    event_id=Enum(Byte, GameplayEvent),
    event_data=Switch(
        this.event_id,
        IntEnumCases({
            v.value: getattr(sys.modules[__name__], v.name)
            for k, v in GameplayEvent._value2member_map_.items()
        }),
        default=GreedyBytes
    )
)


class GameSerializer(Serializer):
    def __init__(self, version_info, timestamp=PlusVersion.latest):
        self.version = version_info.version
        self.is_plus = version_info.plus_version is not None
        self.timestamp = timestamp

    @staticmethod
    def compute_checksum(buffer: bytes) -> bytes:
        c = bytes((79, 79)) + buffer
        L = R = 1
        for n in range(2, len(c)):
            L += c[n]
            R += L
        return Byte.build(L % 251) + Byte.build(R % 251)

    def build_data(self, protocol, construct, build_from_object, length_16_bit=False) -> bytes:
        packet = construct.build(build_from_object)
        prefix = bytes(0)
        assert protocol in (TCP, UDP), f'protocol must be {TCP} or {UDP}'
        if protocol == TCP:
            if length_16_bit:
                prefix = bytes(1) + Int16ul.build(len(packet))
            else:
                prefix = Byte.build(len(packet) + 1)
        if protocol == UDP:
            prefix = self.compute_checksum(packet)
        data = prefix + packet
        return data

    def build_join_request(self, connection, construct=JOIN_REQUEST) -> bytes:
        build_from_object = {
            'udp_bind': connection.udp_port,
            'client_version': self.version,
            'number_of_players_from_client': len(connection.client.rabbits)
        }
        return self.build_data(TCP, construct, build_from_object)

    def build_download_request(self, file_name, construct=DOWNLOAD_REQUEST) -> bytes:
        build_from_object = {'file_name': file_name}
        return self.build_data(TCP, construct, build_from_object)

    def build_player_details(self, connection, construct=PLAYER_DETAILS) -> bytes:
        if self.is_plus and construct == PLAYER_DETAILS:
            construct = PLAYER_DETAILS_PLUS
        build_from_object = {
            'number_of_players': len(connection.client.rabbits),
            'players': [
                {
                    'player_id': ordinal,
                    'team': player.team.value,
                    'char': player.character.value,
                    'fur_color': player.fur,
                    'rabbit_name': player.raw_nickname,
                } for ordinal, player in enumerate(connection.client.rabbits)
            ]
        }
        return self.build_data(TCP, construct, build_from_object)

    def build_chat_message(
            self, message, is_plus=False, construct=CHAT_MESSAGE
    ) -> bytes:
        if is_plus and construct == CHAT_MESSAGE:
            construct = CHAT_MESSAGE_PLUS
        build_from_object = {
            'client_id': message.author.client_id,
            'text': message.raw_text,
            'team_chat': getattr(message, 'team', None) is not None,

        }
        return self.build_data(TCP, construct, build_from_object, length_16_bit=is_plus)

    def build_plus_request(self, construct=PLUS_REQUEST) -> bytes:
        build_from_object = {'plus_timestamp': self.timestamp.value}
        return self.build_data(TCP, construct, build_from_object)

    def build_ping(self, unknown_data=bytes(4), construct=PING) -> bytes:
        build_from_object = {
            'number_in_list': random.choice(range(1, 7)),
            'unknown_data': unknown_data,
            'client_version': self.version
        }
        return self.build_data(UDP, construct, build_from_object)

    def build_keep_alive(self, data, udp_count, construct=KEEP_ALIVE) -> bytes:
        build_from_object = {
            'udp_count': udp_count if -1 < udp_count < 0xff else 0,
            'keep_alive_data': data
        }
        return self.build_data(UDP, construct, build_from_object)

    def build_update_request(self, level_challenge, construct=UPDATE_REQUEST) -> bytes:
        if self.is_plus and construct == UPDATE_REQUEST:
            construct = UPDATE_REQUEST_PLUS
        build_from_object = {'level_challenge': level_challenge}
        return self.build_data(TCP, construct, build_from_object)

    def build_spectate_request(self, spectating=True, construct=SPECTATE_REQUEST) -> bytes:
        build_from_object = {'spectating': 20 + spectating}
        return self.build_data(TCP, construct, build_from_object)

    def build_query(self, construct=QUERY) -> bytes:
        build_from_object = {'number_in_list': random.randint(1, 7)}
        return self.build_data(TCP, construct, build_from_object)


def _load_constructs(protocol) -> typing.Dict[Struct, str]:
    return {
        getattr(sys.modules[__name__], name): name
        for name in {UDP: UDP_EVENTS, TCP: TCP_EVENTS}[protocol]
    }


class GameProtocol(Protocol):
    map = {
        TCP: {
            CLIENT_TO_SERVER: {
                DEFAULT: {
                    0x0E: PLAYER_DETAILS,
                    0x0F: JOIN_REQUEST
                },
                PLUS_ONLY: {
                    0x0E: PLAYER_DETAILS_PLUS,
                    0x3F: PLUS_REQUEST,
                    0x42: SPECTATE_REQUEST
                }
            },
            SERVER_TO_CLIENT: {
                DEFAULT: {
                    0x0D: CLIENT_DISCONNECT,
                    0x10: SERVER_DETAILS,
                    0x11: CLIENT_JOIN,
                    0x12: PLAYERS_UPDATE,
                    0x13: GAME_INITIATION,
                    0x14: DOWNLOADING_FILE,
                    0x15: DOWNLOAD_REQUEST,
                    0x16: LEVEL_CYCLE,
                    0x17: END_OF_LEVEL,
                    0x18: EVENTS_UPDATE,
                    0x19: SERVER_STOPPED,
                    0x1A: UPDATE_REQUEST,
                    0x1B: CHAT_MESSAGE
                },
                PLUS_ONLY: {
                    0x11: CLIENT_JOIN_PLUS,
                    0x12: PLAYERS_UPDATE_PLUS,
                    0x1B: CHAT_MESSAGE_PLUS,
                    0x3F: PLUS_INFO,
                    0x40: CONSOLE_MESSAGE,
                    0x41: SPECTATORS_UPDATE,
                    0x49: LATENCY_UPDATE,
                    0x51: UPDATE_READY,
                    0x5A: SCRIPT_LIST
                }
            },
        },
        UDP: {
            CLIENT_TO_SERVER: {
                DEFAULT: {
                    0x03: PING,
                    0x05: QUERY,
                    0x08: GAMEPLAY,
                    0x0A: PASSWORD
                },
                PLUS_ONLY: {
                    0x09: KEEP_ALIVE
                },
            },
            SERVER_TO_CLIENT: {
                DEFAULT: {
                    0x04: PONG,
                    0x06: QUERY_REPLY,
                    0x07: GAMEPLAY,
                    0x0B: PASSWORD_REPLY
                },
                PLUS_ONLY: {
                    0x09: KEEP_ALIVE,
                    0x45: GAME_STATE
                }
            },
        }
    }
    namespace = {
        TCP: _load_constructs(TCP),
        UDP: _load_constructs(UDP)
    }

    client: GameClient

    def __init__(self, connection, waiter, eof_closure=False, ps=(TCP, UDP)):
        super().__init__(connection, waiter, eof_closure)
        self._ct: asyncio.Transport | None = None
        self._dt: asyncio.DatagramTransport | None = None
        self._ts = collections.deque()
        self._ps = tuple(ps)
        self._buf = bytearray()
        self._defic = 0

    def parse_data(
            self,
            data,
            protocol,
            turn=SERVER_TO_CLIENT,
            is_plus=False,
            default=(None, None),
            warn=False
    ) -> Container:
        packet = Struct(id=Byte, data=GreedyBytes).parse(data)
        turn = self.map[protocol][turn]
        parsers = {**turn[DEFAULT], **(turn.get(PLUS_ONLY, {}) if is_plus else {})}
        if packet.id not in parsers:
            if warn:
                print(
                    f'Received a {protocol} packet of unknown ID {packet.id}\n'
                    f'(0x{Byte.build(packet.id).hex().upper()}):\n'
                    f'{data.hex(" ").upper()}',
                    file=sys.stderr
                )
            return default
        parser = parsers[packet.id]
        name = self.namespace[protocol][parser]
        try:
            parsed = parser.parse(data)
        except Exception as exc:
            if warn:
                print(
                    f'ERROR during parsing the {protocol} {str(name).casefold()!r} packet.\n'
                    f' Given value:\n {data.hex(" ").upper()} \n {data}\n' +
                    type(exc).__name__ + ':', ' '.join(map(str, exc.args)),
                    file=sys.stderr
                )
            parsed = None
        if parsed:
            return name, parsed
        return default

    def connection_made(self, transport):
        super().connection_made(transport)
        self.connection.reset_waiter()
        self._ts.append(transport)
        protocol = self._ps[len(self._ts) - 1]
        if protocol not in self._ps:
            raise ArgumentError(
                f'{protocol!r} protocol isn\'t supported.',
                method='connection_made()'
            )
        if protocol == TCP:
            self.connection._ct = self._ct = transport
        if protocol == UDP:
            self.connection._dt = self._dt = transport
        self.client.dispatch(self.connection, f'{protocol.casefold()}_connect')
        if len(self._ts) == len(self._ps):
            self.client.expose(self.connection)
            self.client.dispatch(self.connection, 'connect')

    def packet_received(self, data, protocol=None, *args, **kwargs):
        if protocol is None:
            raise ArgumentError(
                'packet_received() missing 1 required argument: \'protocol\'',
                method='packet_received()'
            )
        self.client.dispatch(self.connection, 'data_receive', data, protocol)
        is_plus = self.client.is_plus or data[0] == (2**6 - 1)
        name, packet = self.parse_data(data, protocol, is_plus=is_plus)
        if packet is not None:
            if name.endswith('_PLUS'):
                name = name[:-5]
            event, *args = self.connection.handle(name, packet)
            if event is not None:
                self.client.dispatch(self.connection, event, *args)

    def data_received(self, data: bytes):
        alen = eof = len(data)
        defic = self._defic

        if defic == 0:
            bof = 1
            if data[0] == 0:
                bof += 2
                elen = Int16ul.parse(data[1:3]) + 3
            else:
                elen = data[0]
            diff = elen - alen
            if diff < 0:
                eof += diff
                diff = 0
            defic = diff
        elif defic > 0:
            bof = 0
            if alen < defic:
                defic -= alen
            else:
                eof -= defic
                defic = 0
        else:
            raise ArgumentError(f'{defic=} is less than 0', method='data_received()')

        self._buf.extend(data[bof:eof])
        self._defic = defic

        if defic == 0:
            self.packet_received(bytes(self._buf), TCP)
            self._buf = bytearray()
            if eof < alen:
                tail = data[eof:]
                self.data_received(tail)

    def datagram_received(self, datagram: bytes, addr: typing.Tuple[str, int]):
        local = self.connection.serializer.compute_checksum(datagram[2:])
        foreign = datagram[:2]
        if local == foreign:
            self.packet_received(datagram[2:], UDP)

# TODO: #    protocol packet patterns should be protocol fields # TODO: fill # 2008-04-05 # 2008-04-09 # 2008-06-22 # 2008-07-03 # 2008-08-28 # 2008-11-27 # 2009-04-09 # 2009-04-10 # 2009-07-28 # 2010-04-08 # 2010-06-30  (not sure) # 2013-01-28 # 2013-02-05  rerelease from 2013-02-04 # 2013-09-19 # v4_3 = OOPS DATA MISSING OOPS OOPS OOPS  # 2013-10-30 # 2015-04-06 # 2015-08-24 # 2016-04-20 # 2016-07-09 # 2016-12-06 # 2017-06-01 # 2018-02-28 # 2020-08-16 # 2021-04-22 # 2021-05-11 # Starting from 13 are JJ2+ #                                          TCP PACKETS                                            # # Client -> Server # ------------------------------------------------------------------------------------------------- # Server -> Client # ------------------------------------------------------------------------------------------------- # type: ignore # type: ignore # Client -> Server # Server -> Client # ------------------------------------------------------------------------------------------------- #                                          UDP PACKETS                                            # # Client -> Server # ------------------------------------------------------------------------------------------------- # in milliseconds # Server -> Client # ------------------------------------------------------------------------------------------------- # Client -> Server # Server -> Client # -------------------------------------------------------------------------------------------------