from typing import Optional, Dict, Any, List, Tuple
from typing_extensions import Final

from bemani.backend.reflec.base import ReflecBeatBase
from bemani.backend.reflec.colette import ReflecBeatColette

from bemani.common import Profile, ValidatedDict, VersionConstants, ID, Time
from bemani.data import Achievement, Attempt, Score, UserID
from bemani.protocol import Node


class ReflecBeatGroovin(ReflecBeatBase):

    name: str = "REFLEC BEAT groovin'!!"
    version: int = VersionConstants.REFLEC_BEAT_GROOVIN

    # Clear types according to the game
    GAME_CLEAR_TYPE_NO_PLAY: Final[int] = 0
    GAME_CLEAR_TYPE_EARLY_FAILED: Final[int] = 1
    GAME_CLEAR_TYPE_FAILED: Final[int] = 2
    GAME_CLEAR_TYPE_CLEARED: Final[int] = 9
    GAME_CLEAR_TYPE_HARD_CLEARED: Final[int] = 10
    GAME_CLEAR_TYPE_S_HARD_CLEARED: Final[int] = 11

    # Combo types according to the game (actually a bitmask, where bit 0 is
    # full combo status, and bit 2 is just reflec status). But we don't support
    # saving just reflec without full combo, so we downgrade it.
    GAME_COMBO_TYPE_NONE: Final[int] = 0
    GAME_COMBO_TYPE_ALL_JUST: Final[int] = 2
    GAME_COMBO_TYPE_FULL_COMBO: Final[int] = 1
    GAME_COMBO_TYPE_FULL_COMBO_ALL_JUST: Final[int] = 3

    def previous_version(self) -> Optional[ReflecBeatBase]:
        return ReflecBeatColette(self.data, self.config, self.model)

    @classmethod
    def get_settings(cls) -> Dict[str, Any]:
        """
        Return all of our front-end modifiably settings.
        """
        return {
            'bools': [
                {
                    'name': 'Force Song Unlock',
                    'tip': 'Force unlock all songs.',
                    'category': 'game_config',
                    'setting': 'force_unlock_songs',
                },
            ],
            'ints': [],
        }

    def __db_to_game_clear_type(self, db_status: int) -> int:
        return {
            self.CLEAR_TYPE_NO_PLAY: self.GAME_CLEAR_TYPE_NO_PLAY,
            self.CLEAR_TYPE_FAILED: self.GAME_CLEAR_TYPE_FAILED,
            self.CLEAR_TYPE_CLEARED: self.GAME_CLEAR_TYPE_CLEARED,
            self.CLEAR_TYPE_HARD_CLEARED: self.GAME_CLEAR_TYPE_HARD_CLEARED,
            self.CLEAR_TYPE_S_HARD_CLEARED: self.GAME_CLEAR_TYPE_S_HARD_CLEARED,
        }[db_status]

    def __game_to_db_clear_type(self, status: int) -> int:
        return {
            self.GAME_CLEAR_TYPE_NO_PLAY: self.CLEAR_TYPE_NO_PLAY,
            self.GAME_CLEAR_TYPE_EARLY_FAILED: self.CLEAR_TYPE_FAILED,
            self.GAME_CLEAR_TYPE_FAILED: self.CLEAR_TYPE_FAILED,
            self.GAME_CLEAR_TYPE_CLEARED: self.CLEAR_TYPE_CLEARED,
            self.GAME_CLEAR_TYPE_HARD_CLEARED: self.CLEAR_TYPE_HARD_CLEARED,
            self.GAME_CLEAR_TYPE_S_HARD_CLEARED: self.CLEAR_TYPE_S_HARD_CLEARED,
        }[status]

    def __db_to_game_combo_type(self, db_combo: int) -> int:
        return {
            self.COMBO_TYPE_NONE: self.GAME_COMBO_TYPE_NONE,
            self.COMBO_TYPE_ALMOST_COMBO: self.GAME_COMBO_TYPE_NONE,
            self.COMBO_TYPE_FULL_COMBO: self.GAME_COMBO_TYPE_FULL_COMBO,
            self.COMBO_TYPE_FULL_COMBO_ALL_JUST: self.GAME_COMBO_TYPE_FULL_COMBO_ALL_JUST,
        }[db_combo]

    def __game_to_db_combo_type(self, game_combo: int, miss_count: int) -> int:
        if game_combo in [
            self.GAME_COMBO_TYPE_NONE,
            self.GAME_COMBO_TYPE_ALL_JUST,
        ]:
            if miss_count >= 0 and miss_count <= 2:
                return self.COMBO_TYPE_ALMOST_COMBO
            else:
                return self.COMBO_TYPE_NONE
        if game_combo == self.GAME_COMBO_TYPE_FULL_COMBO:
            return self.COMBO_TYPE_FULL_COMBO
        if game_combo == self.GAME_COMBO_TYPE_FULL_COMBO_ALL_JUST:
            return self.COMBO_TYPE_FULL_COMBO_ALL_JUST
        raise Exception(f'Invalid game_combo value {game_combo}')

    def handle_pcb_rb4error_request(self, request: Node) -> Node:
        return Node.void('pcb')

    def handle_pcb_rb4uptime_update_request(self, request: Node) -> Node:
        return Node.void('pcb')

    def handle_pcb_rb4boot_request(self, request: Node) -> Node:
        shop_id = ID.parse_machine_id(request.child_value('lid'))
        machine = self.get_machine_by_id(shop_id)
        if machine is not None:
            machine_name = machine.name
            close = machine.data.get_bool('close')
            hour = machine.data.get_int('hour')
            minute = machine.data.get_int('minute')
        else:
            machine_name = ''
            close = False
            hour = 0
            minute = 0

        root = Node.void('pcb')
        sinfo = Node.void('sinfo')
        root.add_child(sinfo)
        sinfo.add_child(Node.string('nm', machine_name))
        sinfo.add_child(Node.bool('cl_enbl', close))
        sinfo.add_child(Node.u8('cl_h', hour))
        sinfo.add_child(Node.u8('cl_m', minute))
        sinfo.add_child(Node.bool('shop_flag', True))
        return root

    def handle_lobby_rb4entry_request(self, request: Node) -> Node:
        root = Node.void('lobby')
        root.add_child(Node.s32('interval', 120))
        root.add_child(Node.s32('interval_p', 120))

        # Create a lobby entry for this user
        extid = request.child_value('e/uid')
        userid = self.data.remote.user.from_extid(self.game, self.version, extid)
        if userid is not None:
            profile = self.get_profile(userid)
            info = self.data.local.lobby.get_play_session_info(self.game, self.version, userid)
            if profile is None or info is None:
                return root

            self.data.local.lobby.put_lobby(
                self.game,
                self.version,
                userid,
                {
                    'mid': request.child_value('e/mid'),
                    'ng': request.child_value('e/ng'),
                    'mopt': request.child_value('e/mopt'),
                    'lid': request.child_value('e/lid'),
                    'sn': request.child_value('e/sn'),
                    'pref': request.child_value('e/pref'),
                    'stg': request.child_value('e/stg'),
                    'pside': request.child_value('e/pside'),
                    'eatime': request.child_value('e/eatime'),
                    'ga': request.child_value('e/ga'),
                    'gp': request.child_value('e/gp'),
                    'la': request.child_value('e/la'),
                    'ver': request.child_value('e/ver'),
                    'tension': request.child_value('e/tension'),
                }
            )
            lobby = self.data.local.lobby.get_lobby(
                self.game,
                self.version,
                userid,
            )
            root.add_child(Node.s32('eid', lobby.get_int('id')))
            e = Node.void('e')
            root.add_child(e)
            e.add_child(Node.s32('eid', lobby.get_int('id')))
            e.add_child(Node.u16('mid', lobby.get_int('mid')))
            e.add_child(Node.u8('ng', lobby.get_int('ng')))
            e.add_child(Node.s32('uid', profile.extid))
            e.add_child(Node.s32('uattr', profile.get_int('uattr')))
            e.add_child(Node.string('pn', profile.get_str('name')))
            e.add_child(Node.s32('plyid', info.get_int('id')))
            e.add_child(Node.s16('mg', profile.get_int('mg')))
            e.add_child(Node.s32('mopt', lobby.get_int('mopt')))
            e.add_child(Node.string('lid', lobby.get_str('lid')))
            e.add_child(Node.string('sn', lobby.get_str('sn')))
            e.add_child(Node.u8('pref', lobby.get_int('pref')))
            e.add_child(Node.s8('stg', lobby.get_int('stg')))
            e.add_child(Node.s8('pside', lobby.get_int('pside')))
            e.add_child(Node.s16('eatime', lobby.get_int('eatime')))
            e.add_child(Node.u8_array('ga', lobby.get_int_array('ga', 4)))
            e.add_child(Node.u16('gp', lobby.get_int('gp')))
            e.add_child(Node.u8_array('la', lobby.get_int_array('la', 4)))
            e.add_child(Node.u8('ver', lobby.get_int('ver')))
            e.add_child(Node.s8('tension', lobby.get_int('tension')))

        return root

    def handle_lobby_rb4read_request(self, request: Node) -> Node:
        root = Node.void('lobby')
        root.add_child(Node.s32('interval', 120))
        root.add_child(Node.s32('interval_p', 120))

        # Look up all lobbies matching the criteria specified
        ver = request.child_value('var')
        mg = request.child_value('m_grade')  # noqa: F841
        extid = request.child_value('uid')
        limit = request.child_value('max')
        userid = self.data.remote.user.from_extid(self.game, self.version, extid)
        if userid is not None:
            lobbies = self.data.local.lobby.get_all_lobbies(self.game, self.version)
            for (user, lobby) in lobbies:
                if limit <= 0:
                    break

                if user == userid:
                    # If we have our own lobby, don't return it
                    continue
                if ver != lobby.get_int('ver'):
                    # Don't return lobby data for different versions
                    continue

                profile = self.get_profile(user)
                info = self.data.local.lobby.get_play_session_info(self.game, self.version, userid)
                if profile is None or info is None:
                    # No profile info, don't return this lobby
                    return root

                e = Node.void('e')
                root.add_child(e)
                e.add_child(Node.s32('eid', lobby.get_int('id')))
                e.add_child(Node.u16('mid', lobby.get_int('mid')))
                e.add_child(Node.u8('ng', lobby.get_int('ng')))
                e.add_child(Node.s32('uid', profile.extid))
                e.add_child(Node.s32('uattr', profile.get_int('uattr')))
                e.add_child(Node.string('pn', profile.get_str('name')))
                e.add_child(Node.s32('plyid', info.get_int('id')))
                e.add_child(Node.s16('mg', profile.get_int('mg')))
                e.add_child(Node.s32('mopt', lobby.get_int('mopt')))
                e.add_child(Node.string('lid', lobby.get_str('lid')))
                e.add_child(Node.string('sn', lobby.get_str('sn')))
                e.add_child(Node.u8('pref', lobby.get_int('pref')))
                e.add_child(Node.s8('stg', lobby.get_int('stg')))
                e.add_child(Node.s8('pside', lobby.get_int('pside')))
                e.add_child(Node.s16('eatime', lobby.get_int('eatime')))
                e.add_child(Node.u8_array('ga', lobby.get_int_array('ga', 4)))
                e.add_child(Node.u16('gp', lobby.get_int('gp')))
                e.add_child(Node.u8_array('la', lobby.get_int_array('la', 4)))
                e.add_child(Node.u8('ver', lobby.get_int('ver')))
                e.add_child(Node.s8('tension', lobby.get_int('tension')))

                limit = limit - 1

        return root

    def handle_lobby_rb4delete_request(self, request: Node) -> Node:
        eid = request.child_value('eid')
        self.data.local.lobby.destroy_lobby(eid)
        return Node.void('lobby')

    def handle_shop_rb4setting_write_request(self, request: Node) -> Node:
        return Node.void('shop')

    def handle_shop_rb4info_write_request(self, request: Node) -> Node:
        self.update_machine_name(request.child_value('sinfo/nm'))
        self.update_machine_data({
            'close': request.child_value('sinfo/cl_enbl'),
            'hour': request.child_value('sinfo/cl_h'),
            'minute': request.child_value('sinfo/cl_m'),
            'pref': request.child_value('sinfo/prf'),
        })
        return Node.void('shop')

    def __add_event_info(self, root: Node) -> None:
        event_ctrl = Node.void('event_ctrl')
        root.add_child(event_ctrl)
        # Contains zero or more nodes like:
        # <data>
        #     <type __type="s32">any</type>
        #     <index __type="s32">any</phase>
        #     <value __type="s32">any</phase>
        #     <value2 __type="s32">any</phase>
        #     <start_time __type="s32">any</phase>
        #     <end_time __type="s32">any</phase>
        # </data>

        item_lock_ctrl = Node.void('item_lock_ctrl')
        root.add_child(item_lock_ctrl)
        # Contains zero or more nodes like:
        # <item>
        #     <type __type="u8">any</type>
        #     <id __type="u16">any</id>
        #     <param __type="u16">0-3</param>
        # </item>

    def __add_shop_score(self, root: Node) -> None:
        shop_score = Node.void('shop_score')
        root.add_child(shop_score)
        today = Node.void('today')
        shop_score.add_child(today)
        yesterday = Node.void('yesterday')
        shop_score.add_child(yesterday)

        all_profiles = self.data.local.user.get_all_profiles(self.game, self.version)
        all_attempts = self.data.local.music.get_all_attempts(self.game, self.version, timelimit=(Time.beginning_of_today() - Time.SECONDS_IN_DAY))
        machine = self.data.local.machine.get_machine(self.config.machine.pcbid)
        if machine.arcade is not None:
            lids = [
                machine.id for machine in self.data.local.machine.get_all_machines(machine.arcade)
            ]
        else:
            lids = [machine.id]

        relevant_profiles = [
            profile for profile in all_profiles
            if profile[1].get_int('lid', -1) in lids
        ]

        for (rootnode, timeoffset) in [
            (today, 0),
            (yesterday, Time.SECONDS_IN_DAY),
        ]:
            # Grab all attempts made in the relevant day
            relevant_attempts = [
                attempt for attempt in all_attempts
                if (
                    attempt[1].timestamp >= (Time.beginning_of_today() - timeoffset) and
                    attempt[1].timestamp <= (Time.end_of_today() - timeoffset)
                )
            ]

            # Calculate scores based on attempt
            scores_by_user: Dict[UserID, Dict[int, Dict[int, Attempt]]] = {}
            for (userid, attempt) in relevant_attempts:
                if userid not in scores_by_user:
                    scores_by_user[userid] = {}
                if attempt.id not in scores_by_user[userid]:
                    scores_by_user[userid][attempt.id] = {}
                if attempt.chart not in scores_by_user[userid][attempt.id]:
                    # No high score for this yet, just use this attempt
                    scores_by_user[userid][attempt.id][attempt.chart] = attempt
                else:
                    # If this attempt is better than the stored one, replace it
                    if scores_by_user[userid][attempt.id][attempt.chart].points < attempt.points:
                        scores_by_user[userid][attempt.id][attempt.chart] = attempt

            # Calculate points earned by user in the day
            points_by_user: Dict[UserID, int] = {}
            for userid in scores_by_user:
                points_by_user[userid] = 0
                for mid in scores_by_user[userid]:
                    for chart in scores_by_user[userid][mid]:
                        points_by_user[userid] = points_by_user[userid] + scores_by_user[userid][mid][chart].points

            # Output that day's earned points
            for (userid, profile) in relevant_profiles:
                data = Node.void('data')
                rootnode.add_child(data)
                data.add_child(Node.s16('day_id', int((Time.now() - timeoffset) / Time.SECONDS_IN_DAY)))
                data.add_child(Node.s32('user_id', profile.extid))
                data.add_child(Node.s16('icon_id', profile.get_dict('config').get_int('icon_id')))
                data.add_child(Node.s16('point', min(points_by_user.get(userid, 0), 32767)))
                data.add_child(Node.s32('update_time', Time.now()))
                data.add_child(Node.string('name', profile.get_str('name')))

            rootnode.add_child(Node.s32('timestamp', Time.beginning_of_today() - timeoffset))

    def handle_info_rb4common_request(self, request: Node) -> Node:
        root = Node.void('info')
        self.__add_event_info(root)
        self.__add_shop_score(root)

        return root

    def handle_info_rb4ranking_request(self, request: Node) -> Node:
        version = request.child_value('ver')

        root = Node.void('info')
        root.add_child(Node.s32('ver', version))
        ranking = Node.void('ranking')
        root.add_child(ranking)

        def add_hitchart(name: str, start: int, end: int, hitchart: List[Tuple[int, int]]) -> None:
            base = Node.void(name)
            ranking.add_child(base)
            base.add_child(Node.s32('bt', start))
            base.add_child(Node.s32('et', end))
            new = Node.void('new')
            base.add_child(new)

            for (mid, plays) in hitchart:
                d = Node.void('d')
                new.add_child(d)
                d.add_child(Node.s16('mid', mid))
                d.add_child(Node.s32('cnt', plays))

        # Weekly hit chart
        add_hitchart(
            'weekly',
            Time.now() - Time.SECONDS_IN_WEEK,
            Time.now(),
            self.data.local.music.get_hit_chart(self.game, self.version, 1024, 7),
        )

        # Monthly hit chart
        add_hitchart(
            'monthly',
            Time.now() - Time.SECONDS_IN_DAY * 30,
            Time.now(),
            self.data.local.music.get_hit_chart(self.game, self.version, 1024, 30),
        )

        # All time hit chart
        add_hitchart(
            'total',
            Time.now() - Time.SECONDS_IN_DAY * 365,
            Time.now(),
            self.data.local.music.get_hit_chart(self.game, self.version, 1024, 365),
        )

        return root

    def handle_info_rb4shop_score_ranking_request(self, request: Node) -> Node:
        start_music_id = request.child_value('min')
        end_music_id = request.child_value('max')

        root = Node.void('info')
        shop_score = Node.void('shop_score')
        root.add_child(shop_score)
        shop_score.add_child(Node.s32('time', Time.now()))

        profiles: Dict[UserID, Profile] = {}
        for songid in range(start_music_id, end_music_id + 1):
            allscores = self.data.local.music.get_all_scores(
                self.game,
                self.version,
                songid=songid,
            )

            for ng in [
                self.CHART_TYPE_BASIC,
                self.CHART_TYPE_MEDIUM,
                self.CHART_TYPE_HARD,
                self.CHART_TYPE_SPECIAL,
            ]:
                scores = sorted(
                    [score for score in allscores if score[1].chart == ng],
                    key=lambda score: score[1].points,
                    reverse=True,
                )

                for i in range(len(scores)):
                    userid, score = scores[i]
                    if userid not in profiles:
                        profiles[userid] = self.get_any_profile(userid)
                    profile = profiles[userid]

                    data = Node.void('data')
                    shop_score.add_child(data)
                    data.add_child(Node.s32('rank', i + 1))
                    data.add_child(Node.s16('music_id', songid))
                    data.add_child(Node.s8('note_grade', score.chart))
                    data.add_child(Node.s8('clear_type', self.__db_to_game_clear_type(score.data.get_int('clear_type'))))
                    data.add_child(Node.s32('user_id', profile.extid))
                    data.add_child(Node.s16('icon_id', profile.get_dict('config').get_int('icon_id')))
                    data.add_child(Node.s32('score', score.points))
                    data.add_child(Node.s32('time', score.timestamp))
                    data.add_child(Node.string('name', profile.get_str('name')))

        return root

    def handle_info_rb4pzlcmt_read_request(self, request: Node) -> Node:
        extid = request.child_value('uid')
        locid = ID.parse_machine_id(request.child_value('lid'))
        limit = request.child_value('limit')
        userid = self.data.remote.user.from_extid(self.game, self.version, extid)

        comments = [
            achievement for achievement in
            self.data.local.user.get_all_time_based_achievements(self.game, self.version)
            if achievement[1].type == 'puzzle_comment'
        ]
        comments.sort(key=lambda x: x[1].timestamp, reverse=True)
        favorites = [
            comment for comment in comments
            if comment[0] == userid
        ]
        locationcomments = [
            comment for comment in comments
            if comment[1].data.get_int('locid') == locid
        ]

        # Cap all comment blocks to the limit
        if limit >= 0:
            comments = comments[:limit]
            favorites = favorites[:limit]
            locationcomments = locationcomments[:limit]

        root = Node.void('info')
        comment = Node.void('comment')
        root.add_child(comment)
        comment.add_child(Node.s32('time', Time.now()))

        # Mapping of profiles to userIDs
        uid_mapping = {
            uid: prof for (uid, prof) in self.get_any_profiles([c[0] for c in comments])
        }

        # Handle anonymous comments by returning a default profile
        uid_mapping[UserID(0)] = Profile(
            self.game,
            self.version,
            "",
            0,
            {'name': 'ＰＬＡＹＥＲ'},
        )

        def add_comments(name: str, selected: List[Tuple[UserID, Achievement]]) -> None:
            for (uid, ach) in selected:
                cmnt = Node.void(name)
                root.add_child(cmnt)
                cmnt.add_child(Node.s32('uid', uid_mapping[uid].extid))
                cmnt.add_child(Node.string('name', uid_mapping[uid].get_str('name')))
                cmnt.add_child(Node.s16('icon', ach.data.get_int('icon')))
                cmnt.add_child(Node.s8('bln', ach.data.get_int('bln')))
                cmnt.add_child(Node.string('lid', ID.format_machine_id(ach.data.get_int('locid'))))
                cmnt.add_child(Node.s8('pref', ach.data.get_int('prefecture')))
                cmnt.add_child(Node.s32('time', ach.timestamp))
                cmnt.add_child(Node.string('comment', ach.data.get_str('comment')))
                cmnt.add_child(Node.bool('is_tweet', ach.data.get_bool('tweet')))

        # Add all comments
        add_comments('c', comments)

        # Add personal comments (favorites)
        add_comments('cf', favorites)

        # Add location comments
        add_comments('cs', locationcomments)

        return root

    def handle_info_rb4pzlcmt_write_request(self, request: Node) -> Node:
        extid = request.child_value('uid')
        userid = self.data.remote.user.from_extid(self.game, self.version, extid)
        if userid is None:
            # Anonymous comment
            userid = UserID(0)

        icon = request.child_value('icon')
        bln = request.child_value('bln')
        locid = ID.parse_machine_id(request.child_value('lid'))
        prefecture = request.child_value('pref')
        comment = request.child_value('comment')
        is_tweet = request.child_value('is_tweet')

        # Link comment to user's profile
        self.data.local.user.put_time_based_achievement(
            self.game,
            self.version,
            userid,
            0,  # We never have an ID for this, since comments are add-only
            'puzzle_comment',
            {
                'icon': icon,
                'bln': bln,
                'locid': locid,
                'prefecture': prefecture,
                'comment': comment,
                'tweet': is_tweet,
            },
        )

        return Node.void('info')

    def handle_player_rb4start_request(self, request: Node) -> Node:
        root = Node.void('player')

        # Create a new play session based on info from the request
        refid = request.child_value('rid')
        userid = self.data.remote.user.from_refid(self.game, self.version, refid)
        if userid is not None:
            self.data.local.lobby.put_play_session_info(
                self.game,
                self.version,
                userid,
                {
                    'ga': request.child_value('ga'),
                    'gp': request.child_value('gp'),
                    'la': request.child_value('la'),
                    'pnid': request.child_value('pnid'),
                },
            )
            info = self.data.local.lobby.get_play_session_info(
                self.game,
                self.version,
                userid,
            )
            if info is not None:
                play_id = info.get_int('id')
            else:
                play_id = 0
        else:
            play_id = 0

        # Session stuff, and resend global defaults
        root.add_child(Node.s32('plyid', play_id))
        root.add_child(Node.u64('start_time', Time.now() * 1000))
        self.__add_event_info(root)

        return root

    def handle_player_rb4end_request(self, request: Node) -> Node:
        # Destroy play session based on info from the request
        refid = request.child_value('rid')
        userid = self.data.remote.user.from_refid(self.game, self.version, refid)
        if userid is not None:
            # Kill any lingering lobbies by this user
            lobby = self.data.local.lobby.get_lobby(
                self.game,
                self.version,
                userid,
            )
            if lobby is not None:
                self.data.local.lobby.destroy_lobby(lobby.get_int('id'))
            self.data.local.lobby.destroy_play_session_info(self.game, self.version, userid)

        return Node.void('player')

    def handle_player_rb4readepisode_request(self, request: Node) -> Node:
        extid = request.child_value('user_id')
        userid = self.data.remote.user.from_extid(self.game, self.version, extid)
        if userid is not None:
            achievements = self.data.local.user.get_achievements(self.game, self.version, userid)
        else:
            achievements = []

        root = Node.void('player')
        pdata = Node.void('pdata')
        root.add_child(pdata)
        episode = Node.void('episode')
        pdata.add_child(episode)

        for achievement in achievements:
            if achievement.type != 'episode':
                continue

            info = Node.void('info')
            episode.add_child(info)
            info.add_child(Node.s32('user_id', extid))
            info.add_child(Node.u8('type', achievement.id))
            info.add_child(Node.u16('value0', achievement.data.get_int('value0')))
            info.add_child(Node.u16('value1', achievement.data.get_int('value1')))
            info.add_child(Node.string('text', achievement.data.get_str('text')))
            info.add_child(Node.s32('time', achievement.data.get_int('time')))

        return root

    def handle_player_rb4readscore_request(self, request: Node) -> Node:
        refid = request.child_value('rid')
        userid = self.data.remote.user.from_refid(self.game, self.version, refid)
        if userid is None:
            scores: List[Score] = []
        else:
            scores = self.data.remote.music.get_scores(self.game, self.version, userid)

        root = Node.void('player')
        pdata = Node.void('pdata')
        root.add_child(pdata)

        record = Node.void('record')
        pdata.add_child(record)
        record_old = Node.void('record_old')
        pdata.add_child(record_old)

        for score in scores:
            rec = Node.void('rec')
            record.add_child(rec)
            rec.add_child(Node.s16('mid', score.id))
            rec.add_child(Node.s8('ntgrd', score.chart))
            rec.add_child(Node.s32('pc', score.plays))
            rec.add_child(Node.s8('ct', self.__db_to_game_clear_type(score.data.get_int('clear_type'))))
            rec.add_child(Node.s16('ar', score.data.get_int('achievement_rate')))
            rec.add_child(Node.s16('scr', score.points))
            rec.add_child(Node.s16('ms', score.data.get_int('miss_count')))
            rec.add_child(Node.s16(
                'param',
                self.__db_to_game_combo_type(score.data.get_int('combo_type')) + score.data.get_int('param'),
            ))
            rec.add_child(Node.s32('bscrt', score.timestamp))
            rec.add_child(Node.s32('bart', score.data.get_int('best_achievement_rate_time')))
            rec.add_child(Node.s32('bctt', score.data.get_int('best_clear_type_time')))
            rec.add_child(Node.s32('bmst', score.data.get_int('best_miss_count_time')))
            rec.add_child(Node.s32('time', score.data.get_int('last_played_time')))

        return root

    def handle_player_rb4selectscore_request(self, request: Node) -> Node:
        extid = request.child_value('uid')
        songid = request.child_value('music_id')
        chart = request.child_value('note_grade')
        userid = self.data.remote.user.from_extid(self.game, self.version, extid)
        if userid is None:
            score = None
            profile = None
        else:
            score = self.data.remote.music.get_score(self.game, self.version, userid, songid, chart)
            profile = self.get_any_profile(userid)

        root = Node.void('player')
        if score is not None and profile is not None:
            player_select_score = Node.void('player_select_score')
            root.add_child(player_select_score)

            player_select_score.add_child(Node.s32('user_id', extid))
            player_select_score.add_child(Node.string('name', profile.get_str('name')))
            player_select_score.add_child(Node.s32('m_score', score.points))
            player_select_score.add_child(Node.s32('m_scoreTime', score.timestamp))
            player_select_score.add_child(Node.s16('m_iconID', profile.get_dict('config').get_int('icon_id')))
        return root

    def handle_player_rbsvLinkageSave_request(self, request: Node) -> Node:
        # I think this is ReflecBeat/SoundVoltex linkage save, and I
        # am somewhat convinced that PK/BN is for packets/blocks, but
        # whatever.
        root = Node.void('player')
        root.add_child(Node.s32('before_pk_value', -1))
        root.add_child(Node.s32('after_pk_value', -1))
        root.add_child(Node.s32('before_bn_value', -1))
        root.add_child(Node.s32('after_bn_value', -1))
        return root

    def handle_player_rb4total_bestallrank_read_request(self, request: Node) -> Node:
        # This gives us a 6-integer array mapping to user scores for the following:
        # [total score, basic chart score, medium chart score, hard chart score,
        # special chart score, new songs score].
        # It appears to return several 6-array values similar to the following:
        # <score>
        #     <rank __type="s32" __count="6">1 2 3 4 5 6</rank>
        #     <score __type="s32" __count="6">101 102 103 104 105 106</score>
        #     <allrank __type="s32" __count="6">7 8 9 10 11 12</allrank>
        # </score>
        # The first 'rank' is the displayed value for the six categories. The
        # second and third values appear unused in-game. I think this is supposed
        # to give a player the idea of what ranking they are on the server for
        # various scores.
        current_scores = request.child_value('score')

        # First, grab all scores on the network for this version, and all songs
        # available so we know which songs are new to this version of the game.
        all_scores = self.data.remote.music.get_all_scores(self.game, self.version)
        all_songs = self.data.local.music.get_all_songs(self.game, self.version)

        # Figure out what song IDs are new
        new_songs = {song.id for song in all_songs if song.data.get_int('folder', 0) == self.version}

        # Now grab all participating users that had scores
        all_users = {userid for (userid, score) in all_scores}

        # Now, group the scores by user, so we can add up the totals, only including
        # scores where the user at least cleared the song.
        scores_by_user = {
            userid: [
                score for (uid, score) in all_scores
                if uid == userid and score.data.get_int('clear_type') >= self.CLEAR_TYPE_CLEARED]
            for userid in all_users
        }

        # Now, sum up the scores into the six categories that the game expects.
        total_scores = sorted(
            [
                sum([score.points for score in scores])
                for userid, scores in scores_by_user.items()
            ],
            reverse=True,
        )
        basic_scores = sorted(
            [
                sum([score.points for score in scores if score.chart == self.CHART_TYPE_BASIC])
                for userid, scores in scores_by_user.items()
            ],
            reverse=True,
        )
        medium_scores = sorted(
            [
                sum([score.points for score in scores if score.chart == self.CHART_TYPE_MEDIUM])
                for userid, scores in scores_by_user.items()
            ],
            reverse=True,
        )
        hard_scores = sorted(
            [
                sum([score.points for score in scores if score.chart == self.CHART_TYPE_HARD])
                for userid, scores in scores_by_user.items()
            ],
            reverse=True,
        )
        special_scores = sorted(
            [
                sum([score.points for score in scores if score.chart == self.CHART_TYPE_SPECIAL])
                for userid, scores in scores_by_user.items()
            ],
            reverse=True,
        )
        new_scores = sorted(
            [
                sum([score.points for score in scores if score.id in new_songs])
                for userid, scores in scores_by_user.items()
            ],
            reverse=True,
        )

        # Guarantee that a zero score is at the end of every list, so that it makes
        # the algorithm for figuring out place have no edge case.
        total_scores.append(0)
        basic_scores.append(0)
        medium_scores.append(0)
        hard_scores.append(0)
        special_scores.append(0)
        new_scores.append(0)

        # Now, figure out where we fit based on the scores sent from the game.
        user_place = [1, 1, 1, 1, 1, 1]
        which_score = [
            total_scores,
            basic_scores,
            medium_scores,
            hard_scores,
            special_scores,
            new_scores,
        ]
        for i in range(len(user_place)):
            current_score = current_scores[i]
            scores = which_score[i]
            for score in scores:
                if current_score >= score:
                    break
                user_place[i] = user_place[i] + 1

        root = Node.void('player')
        scorenode = Node.void('score')
        root.add_child(scorenode)
        scorenode.add_child(Node.s32_array('rank', user_place))
        scorenode.add_child(Node.s32_array('score', [0] * 6))
        scorenode.add_child(Node.s32_array('allrank', [len(total_scores)] * 6))
        return root

    def handle_player_rb4delete_request(self, request: Node) -> Node:
        return Node.void('player')

    def handle_player_rb4read_request(self, request: Node) -> Node:
        refid = request.child_value('rid')
        profile = self.get_profile_by_refid(refid)
        if profile:
            return profile
        return Node.void('player')

    def handle_player_rb4succeed_request(self, request: Node) -> Node:
        refid = request.child_value('rid')
        userid = self.data.remote.user.from_refid(self.game, self.version, refid)
        if userid is not None:
            previous_version = self.previous_version()
            profile = previous_version.get_profile(userid)
            achievements = self.data.local.user.get_achievements(previous_version.game, previous_version.version, userid)
            scores = self.data.remote.music.get_scores(previous_version.game, previous_version.version, userid)
        else:
            profile = None

        root = Node.void('player')

        if profile is None:
            # Return empty succeed to say this is new
            root.add_child(Node.string('name', ''))
            root.add_child(Node.s16('lv', -1))
            root.add_child(Node.s32('exp', -1))
            root.add_child(Node.s32('grd', -1))
            root.add_child(Node.s32('ap', -1))
            root.add_child(Node.s32('money', -1))
            root.add_child(Node.void('released'))
            root.add_child(Node.void('mrecord'))
        else:
            # Return previous profile formatted to say this is data succession
            root.add_child(Node.string('name', profile.get_str('name')))
            root.add_child(Node.s16('lv', profile.get_int('lvl')))
            root.add_child(Node.s32('exp', profile.get_int('exp')))
            root.add_child(Node.s32('grd', profile.get_int('mg')))  # This is a guess
            root.add_child(Node.s32('ap', profile.get_int('ap')))
            root.add_child(Node.s32('money', 0))

            released = Node.void('released')
            root.add_child(released)
            for item in achievements:
                if item.type != 'item_0':
                    continue

                released.add_child(Node.s16('i', item.id))

            mrecord = Node.void('mrecord')
            root.add_child(mrecord)
            for score in scores:
                mrec = Node.void('mrec')
                mrecord.add_child(mrec)
                mrec.add_child(Node.s16('mid', score.id))
                mrec.add_child(Node.s8('ntgrd', score.chart))
                mrec.add_child(Node.s32('pc', score.plays))
                mrec.add_child(Node.s8('ct', self.__db_to_game_clear_type(score.data.get_int('clear_type'))))
                mrec.add_child(Node.s16('ar', score.data.get_int('achievement_rate')))
                mrec.add_child(Node.s16('scr', score.points))
                mrec.add_child(Node.s16('ms', score.data.get_int('miss_count')))
                mrec.add_child(Node.u16('ver', 0))
                mrec.add_child(Node.s32('bst', score.timestamp))
                mrec.add_child(Node.s32('bat', score.data.get_int('best_achievement_rate_time')))
                mrec.add_child(Node.s32('bct', score.data.get_int('best_clear_type_time')))
                mrec.add_child(Node.s32('bmt', score.data.get_int('best_miss_count_time')))

        return root

    def handle_player_rb4write_request(self, request: Node) -> Node:
        refid = request.child_value('pdata/account/rid')
        profile = self.put_profile_by_refid(refid, request)
        root = Node.void('player')

        if profile is None:
            root.add_child(Node.s32('uid', 0))
        else:
            root.add_child(Node.s32('uid', profile.extid))
        return root

    def format_profile(self, userid: UserID, profile: Profile) -> Node:
        statistics = self.get_play_statistics(userid)
        game_config = self.get_game_config()
        achievements = self.data.local.user.get_achievements(self.game, self.version, userid)
        links = self.data.local.user.get_links(self.game, self.version, userid)
        root = Node.void('player')
        pdata = Node.void('pdata')
        root.add_child(pdata)

        # Account info
        account = Node.void('account')
        pdata.add_child(account)
        account.add_child(Node.s32('usrid', profile.extid))
        account.add_child(Node.s32('tpc', statistics.total_plays))
        account.add_child(Node.s32('dpc', statistics.today_plays))
        account.add_child(Node.s32('crd', 1))
        account.add_child(Node.s32('brd', 1))
        account.add_child(Node.s32('tdc', statistics.total_days))
        account.add_child(Node.s32('intrvld', 0))
        account.add_child(Node.s16('ver', 1))
        account.add_child(Node.u64('pst', 0))
        account.add_child(Node.u64('st', Time.now() * 1000))
        account.add_child(Node.u8('debutVer', 2))

        # Base profile info
        base = Node.void('base')
        pdata.add_child(base)
        base.add_child(Node.string('name', profile.get_str('name')))
        base.add_child(Node.s32('exp', profile.get_int('exp')))
        base.add_child(Node.s32('lv', profile.get_int('lvl')))
        base.add_child(Node.s32('mg', profile.get_int('mg')))
        base.add_child(Node.s32('ap', profile.get_int('ap')))
        base.add_child(Node.string('cmnt', ''))
        base.add_child(Node.s32('uattr', profile.get_int('uattr')))
        base.add_child(Node.s32('money', profile.get_int('money')))
        base.add_child(Node.s32('tbs', -1))
        base.add_child(Node.s32('tbs_r', -1))
        base.add_child(Node.s32('tbgs', -1))
        base.add_child(Node.s32('tbgs_r', -1))
        base.add_child(Node.s32('tbms', -1))
        base.add_child(Node.s32('tbms_r', -1))
        base.add_child(Node.s32('qe_win', -1))
        base.add_child(Node.s32('qe_legend', -1))
        base.add_child(Node.s32('qe2_win', -1))
        base.add_child(Node.s32('qe2_legend', -1))
        base.add_child(Node.s32('qe3_win', -1))
        base.add_child(Node.s32('qe3_legend', -1))
        base.add_child(Node.s16_array('mlog', [-1, -1, -1, -1, -1, -1, -1, -1, -1, -1]))
        base.add_child(Node.s32('class', profile.get_int('class')))
        base.add_child(Node.s32('class_ar', profile.get_int('class_ar')))
        base.add_child(Node.s32('getrfl', -1))
        base.add_child(Node.s32('upper_pt', profile.get_int('upper_pt')))

        # Rivals
        rival = Node.void('rival')
        pdata.add_child(rival)
        slotid = 0
        for link in links:
            if link.type != 'rival':
                continue

            rprofile = self.get_profile(link.other_userid)
            if rprofile is None:
                continue
            lobbyinfo = self.data.local.lobby.get_play_session_info(self.game, self.version, link.other_userid)
            if lobbyinfo is None:
                lobbyinfo = ValidatedDict()

            r = Node.void('r')
            rival.add_child(r)
            r.add_child(Node.s32('slot_id', slotid))
            r.add_child(Node.s32('id', rprofile.extid))
            r.add_child(Node.string('name', rprofile.get_str('name')))
            r.add_child(Node.s32('icon', profile.get_dict('config').get_int('icon_id')))
            r.add_child(Node.s32('m_level', profile.get_int('mg')))
            r.add_child(Node.s32('class', profile.get_int('class')))
            r.add_child(Node.s32('class_ar', profile.get_int('class_ar')))
            r.add_child(Node.bool('friend', True))
            r.add_child(Node.bool('target', False))
            r.add_child(Node.u32('time', lobbyinfo.get_int('time')))
            r.add_child(Node.u8_array('ga', lobbyinfo.get_int_array('ga', 4)))
            r.add_child(Node.u16('gp', lobbyinfo.get_int('gp')))
            r.add_child(Node.u8_array('ipn', lobbyinfo.get_int_array('la', 4)))
            r.add_child(Node.u8_array('pnid', lobbyinfo.get_int_array('pnid', 16)))
            slotid = slotid + 1

        # Stamps
        stamp = Node.void('stamp')
        stampdict = profile.get_dict('stamp')
        pdata.add_child(stamp)
        stamp.add_child(Node.s32_array('stmpcnt', stampdict.get_int_array('stmpcnt', 10)))
        stamp.add_child(Node.s64('area', stampdict.get_int('area')))
        stamp.add_child(Node.s64('prfvst', stampdict.get_int('prfvst')))

        # Configuration
        configdict = profile.get_dict('config')
        config = Node.void('config')
        pdata.add_child(config)
        config.add_child(Node.u8('msel_bgm', configdict.get_int('msel_bgm')))
        config.add_child(Node.u8('narrowdown_type', configdict.get_int('narrowdown_type')))
        config.add_child(Node.s16('icon_id', configdict.get_int('icon_id')))
        config.add_child(Node.s16('byword_0', configdict.get_int('byword_0')))
        config.add_child(Node.s16('byword_1', configdict.get_int('byword_1')))
        config.add_child(Node.bool('is_auto_byword_0', configdict.get_bool('is_auto_byword_0')))
        config.add_child(Node.bool('is_auto_byword_1', configdict.get_bool('is_auto_byword_1')))
        config.add_child(Node.u8('mrec_type', configdict.get_int('mrec_type')))
        config.add_child(Node.u8('tab_sel', configdict.get_int('tab_sel')))
        config.add_child(Node.u8('card_disp', configdict.get_int('card_disp')))
        config.add_child(Node.u8('score_tab_disp', configdict.get_int('score_tab_disp')))
        config.add_child(Node.s16('last_music_id', configdict.get_int('last_music_id', -1)))
        config.add_child(Node.u8('last_note_grade', configdict.get_int('last_note_grade')))
        config.add_child(Node.u8('sort_type', configdict.get_int('sort_type')))
        config.add_child(Node.u8('rival_panel_type', configdict.get_int('rival_panel_type')))
        config.add_child(Node.u64('random_entry_work', configdict.get_int('random_entry_work')))
        config.add_child(Node.u64('custom_folder_work', configdict.get_int('custom_folder_work')))
        config.add_child(Node.u8('folder_type', configdict.get_int('folder_type')))
        config.add_child(Node.u8('folder_lamp_type', configdict.get_int('folder_lamp_type')))
        config.add_child(Node.bool('is_tweet', configdict.get_bool('is_tweet')))
        config.add_child(Node.bool('is_link_twitter', configdict.get_bool('is_link_twitter')))

        # Customizations
        customdict = profile.get_dict('custom')
        custom = Node.void('custom')
        pdata.add_child(custom)
        custom.add_child(Node.u8('st_shot', customdict.get_int('st_shot')))
        custom.add_child(Node.u8('st_frame', customdict.get_int('st_frame')))
        custom.add_child(Node.u8('st_expl', customdict.get_int('st_expl')))
        custom.add_child(Node.u8('st_bg', customdict.get_int('st_bg')))
        custom.add_child(Node.u8('st_shot_vol', customdict.get_int('st_shot_vol')))
        custom.add_child(Node.u8('st_bg_bri', customdict.get_int('st_bg_bri')))
        custom.add_child(Node.u8('st_obj_size', customdict.get_int('st_obj_size')))
        custom.add_child(Node.u8('st_jr_gauge', customdict.get_int('st_jr_gauge')))
        custom.add_child(Node.u8('st_clr_gauge', customdict.get_int('st_clr_gauge')))
        custom.add_child(Node.u8('st_jdg_disp', customdict.get_int('st_jdg_disp')))
        custom.add_child(Node.u8('st_tm_disp', customdict.get_int('st_tm_disp')))
        custom.add_child(Node.u8('st_rnd', customdict.get_int('st_rnd')))
        custom.add_child(Node.u8('st_hazard', customdict.get_int('st_hazard')))
        custom.add_child(Node.u8('st_clr_cond', customdict.get_int('st_clr_cond')))
        custom.add_child(Node.s16_array('schat_0', customdict.get_int_array('schat_0', 10)))
        custom.add_child(Node.s16_array('schat_1', customdict.get_int_array('schat_1', 10)))
        custom.add_child(Node.u8('cheer_voice', customdict.get_int('cheer_voice')))
        custom.add_child(Node.u8('same_time_note_disp', customdict.get_int('same_time_note_disp')))

        # Unlocks
        released = Node.void('released')
        pdata.add_child(released)

        for item in achievements:
            if item.type[:5] != 'item_':
                continue
            itemtype = int(item.type[5:])
            if game_config.get_bool('force_unlock_songs') and itemtype == 0:
                # Don't echo unlocks when we're force unlocking, we'll do it later
                continue

            info = Node.void('info')
            released.add_child(info)
            info.add_child(Node.u8('type', itemtype))
            info.add_child(Node.u16('id', item.id))
            info.add_child(Node.u16('param', item.data.get_int('param')))

        if game_config.get_bool('force_unlock_songs'):
            ids: Dict[int, int] = {}
            songs = self.data.local.music.get_all_songs(self.game, self.version)
            for song in songs:
                if song.id not in ids:
                    ids[song.id] = 0

                if song.data.get_int('difficulty') > 0:
                    ids[song.id] = ids[song.id] | (1 << song.chart)

            for songid in ids:
                if ids[songid] == 0:
                    continue

                info = Node.void('info')
                released.add_child(info)
                info.add_child(Node.u8('type', 0))
                info.add_child(Node.u16('id', songid))
                info.add_child(Node.u16('param', ids[songid]))

        # Announcements
        announce = Node.void('announce')
        pdata.add_child(announce)

        for announcement in achievements:
            if announcement.type[:13] != 'announcement_':
                continue
            announcementtype = int(announcement.type[13:])

            info = Node.void('info')
            announce.add_child(info)
            info.add_child(Node.u8('type', announcementtype))
            info.add_child(Node.u16('id', announcement.id))
            info.add_child(Node.u16('param', announcement.data.get_int('param')))
            info.add_child(Node.bool('bneedannounce', announcement.data.get_bool('need')))

        # Dojo ranking return
        dojo = Node.void('dojo')
        pdata.add_child(dojo)

        for entry in achievements:
            if entry.type != 'dojo':
                continue

            rec = Node.void('rec')
            dojo.add_child(rec)
            rec.add_child(Node.s32('class', entry.id))
            rec.add_child(Node.s32('clear_type', entry.data.get_int('clear_type')))
            rec.add_child(Node.s32('total_ar', entry.data.get_int('ar')))
            rec.add_child(Node.s32('total_score', entry.data.get_int('score')))
            rec.add_child(Node.s32('play_count', entry.data.get_int('plays')))
            rec.add_child(Node.s32('last_play_time', entry.data.get_int('play_timestamp')))
            rec.add_child(Node.s32('record_update_time', entry.data.get_int('record_timestamp')))
            rec.add_child(Node.s32('rank', 0))

        # Player Parameters
        player_param = Node.void('player_param')
        pdata.add_child(player_param)

        for param in achievements:
            if param.type[:13] != 'player_param_':
                continue
            itemtype = int(param.type[13:])

            itemnode = Node.void('item')
            player_param.add_child(itemnode)
            itemnode.add_child(Node.s32('type', itemtype))
            itemnode.add_child(Node.s32('bank', param.id))
            itemnode.add_child(Node.s32_array('data', param.data.get_int_array('data', 256)))

        # Shop score for players
        self.__add_shop_score(pdata)

        # Quest data
        questdict = profile.get_dict('quest')
        quest = Node.void('quest')
        pdata.add_child(quest)
        quest.add_child(Node.s16('eye_color', questdict.get_int('eye_color')))
        quest.add_child(Node.s16('body_color', questdict.get_int('body_color')))
        quest.add_child(Node.s16('item', questdict.get_int('item')))
        quest.add_child(Node.string('comment', ''))

        # Derby settings
        derby = Node.void('derby')
        pdata.add_child(derby)
        derby.add_child(Node.bool('is_open', False))

        # Codebreaking stuff
        codebreaking = Node.void('codebreaking')
        pdata.add_child(codebreaking)
        codebreaking.add_child(Node.s32('cb_id', -1))
        codebreaking.add_child(Node.s32('cb_sub_id', -1))
        codebreaking.add_child(Node.s32('music_id', -1))
        codebreaking.add_child(Node.string('question', ''))

        # Unknown IIDX link crap
        iidx_linkage = Node.void('iidx_linkage')
        pdata.add_child(iidx_linkage)
        iidx_linkage.add_child(Node.s32('linkage_id', -1))
        iidx_linkage.add_child(Node.s32('phase', -1))
        iidx_linkage.add_child(Node.s64('long_bit_0', -1))
        iidx_linkage.add_child(Node.s64('long_bit_1', -1))
        iidx_linkage.add_child(Node.s64('long_bit_2', -1))
        iidx_linkage.add_child(Node.s64('long_bit_3', -1))
        iidx_linkage.add_child(Node.s64('long_bit_4', -1))
        iidx_linkage.add_child(Node.s64('long_bit_5', -1))
        iidx_linkage.add_child(Node.s32('add_0', -1))
        iidx_linkage.add_child(Node.s32('add_1', -1))
        iidx_linkage.add_child(Node.s32('add_2', -1))
        iidx_linkage.add_child(Node.s32('add_3', -1))

        # Unknown event crap
        pue = Node.void('pue')
        pdata.add_child(pue)
        pue.add_child(Node.s32('event_id', -1))
        pue.add_child(Node.s32('point', -1))
        pue.add_child(Node.s32('value0', -1))
        pue.add_child(Node.s32('value1', -1))
        pue.add_child(Node.s32('value2', -1))
        pue.add_child(Node.s32('value3', -1))
        pue.add_child(Node.s32('value4', -1))
        pue.add_child(Node.s32('start_time', -1))
        pue.add_child(Node.s32('end_time', -1))

        return root

    def unformat_profile(self, userid: UserID, request: Node, oldprofile: Profile) -> Profile:
        game_config = self.get_game_config()
        newprofile = oldprofile.clone()

        # Save base player profile info
        newprofile.replace_int('lid', ID.parse_machine_id(request.child_value('pdata/account/lid')))
        newprofile.replace_str('name', request.child_value('pdata/base/name'))
        newprofile.replace_int('exp', request.child_value('pdata/base/exp'))
        newprofile.replace_int('lvl', request.child_value('pdata/base/lvl'))
        newprofile.replace_int('mg', request.child_value('pdata/base/mg'))
        newprofile.replace_int('ap', request.child_value('pdata/base/ap'))
        newprofile.replace_int('money', request.child_value('pdata/base/money'))
        newprofile.replace_int('class', request.child_value('pdata/base/class'))
        newprofile.replace_int('class_ar', request.child_value('pdata/base/class_ar'))
        newprofile.replace_int('upper_pt', request.child_value('pdata/base/upper_pt'))

        # Save stamps
        stampdict = newprofile.get_dict('stamp')
        stamp = request.child('pdata/stamp')
        if stamp:
            stampdict.replace_int_array('stmpcnt', 10, stamp.child_value('stmpcnt'))
            stampdict.replace_int('area', stamp.child_value('area'))
            stampdict.replace_int('prfvst', stamp.child_value('prfvst'))
        newprofile.replace_dict('stamp', stampdict)

        # Save quest stuff
        questdict = newprofile.get_dict('quest')
        quest = request.child('pdata/quest')
        if quest:
            questdict.replace_int('eye_color', quest.child_value('eye_color'))
            questdict.replace_int('body_color', quest.child_value('body_color'))
            questdict.replace_int('item', quest.child_value('item'))
        newprofile.replace_dict('quest', questdict)

        # Save player dojo
        dojo = request.child('pdata/dojo')
        if dojo:
            dojoid = dojo.child_value('class')
            clear_type = dojo.child_value('clear_type')
            ar = dojo.child_value('t_ar')
            score = dojo.child_value('t_score')

            # Figure out timestamp stuff
            data = self.data.local.user.get_achievement(
                self.game,
                self.version,
                userid,
                dojoid,
                'dojo',
            ) or ValidatedDict()

            if ar >= data.get_int('ar'):
                # We set a new achievement rate, keep the new values
                record_time = Time.now()
            else:
                # We didn't, keep the old values for achievement rate, but
                # override score and clear_type only if they were better.
                record_time = data.get_int('record_timestamp')
                ar = data.get_int('ar')
                score = max(score, data.get_int('score'))
                clear_type = max(clear_type, data.get_int('clear_type'))

            play_time = Time.now()
            plays = data.get_int('plays') + 1

            self.data.local.user.put_achievement(
                self.game,
                self.version,
                userid,
                dojoid,
                'dojo',
                {
                    'clear_type': clear_type,
                    'ar': ar,
                    'score': score,
                    'plays': plays,
                    'play_timestamp': play_time,
                    'record_timestamp': record_time,
                },
            )

        # Save player config
        configdict = newprofile.get_dict('config')
        config = request.child('pdata/config')
        if config:
            configdict.replace_int('msel_bgm', config.child_value('msel_bgm'))
            configdict.replace_int('narrowdown_type', config.child_value('narrowdown_type'))
            configdict.replace_int('icon_id', config.child_value('icon_id'))
            configdict.replace_int('byword_0', config.child_value('byword_0'))
            configdict.replace_int('byword_1', config.child_value('byword_1'))
            configdict.replace_bool('is_auto_byword_0', config.child_value('is_auto_byword_0'))
            configdict.replace_bool('is_auto_byword_1', config.child_value('is_auto_byword_1'))
            configdict.replace_int('mrec_type', config.child_value('mrec_type'))
            configdict.replace_int('tab_sel', config.child_value('tab_sel'))
            configdict.replace_int('card_disp', config.child_value('card_disp'))
            configdict.replace_int('score_tab_disp', config.child_value('score_tab_disp'))
            configdict.replace_int('last_music_id', config.child_value('last_music_id'))
            configdict.replace_int('last_note_grade', config.child_value('last_note_grade'))
            configdict.replace_int('sort_type', config.child_value('sort_type'))
            configdict.replace_int('rival_panel_type', config.child_value('rival_panel_type'))
            configdict.replace_int('random_entry_work', config.child_value('random_entry_work'))
            configdict.replace_int('custom_folder_work', config.child_value('custom_folder_work'))
            configdict.replace_int('folder_type', config.child_value('folder_type'))
            configdict.replace_int('folder_lamp_type', config.child_value('folder_lamp_type'))
            configdict.replace_bool('is_tweet', config.child_value('is_tweet'))
            configdict.replace_bool('is_link_twitter', config.child_value('is_link_twitter'))
        newprofile.replace_dict('config', configdict)

        # Save player custom settings
        customdict = newprofile.get_dict('custom')
        custom = request.child('pdata/custom')
        if custom:
            customdict.replace_int('st_shot', custom.child_value('st_shot'))
            customdict.replace_int('st_frame', custom.child_value('st_frame'))
            customdict.replace_int('st_expl', custom.child_value('st_expl'))
            customdict.replace_int('st_bg', custom.child_value('st_bg'))
            customdict.replace_int('st_shot_vol', custom.child_value('st_shot_vol'))
            customdict.replace_int('st_bg_bri', custom.child_value('st_bg_bri'))
            customdict.replace_int('st_obj_size', custom.child_value('st_obj_size'))
            customdict.replace_int('st_jr_gauge', custom.child_value('st_jr_gauge'))
            customdict.replace_int('st_clr_gauge', custom.child_value('st_clr_gauge'))
            customdict.replace_int('st_jdg_disp', custom.child_value('st_jdg_disp'))
            customdict.replace_int('st_tm_disp', custom.child_value('st_tm_disp'))
            customdict.replace_int('st_rnd', custom.child_value('st_rnd'))
            customdict.replace_int('st_hazard', custom.child_value('st_hazard'))
            customdict.replace_int('st_clr_cond', custom.child_value('st_clr_cond'))
            customdict.replace_int_array('schat_0', 10, custom.child_value('schat_0'))
            customdict.replace_int_array('schat_1', 10, custom.child_value('schat_1'))
            customdict.replace_int('cheer_voice', custom.child_value('cheer_voice'))
            customdict.replace_int('same_time_note_disp', custom.child_value('same_time_note_disp'))
        newprofile.replace_dict('custom', customdict)

        # Save player parameter info
        params = request.child('pdata/player_param')
        if params:
            for child in params.children:
                if child.name != 'item':
                    continue

                item_type = child.child_value('type')
                bank = child.child_value('bank')
                paramdata = child.child_value('data') or []
                while len(paramdata) < 256:
                    paramdata.append(0)
                self.data.local.user.put_achievement(
                    self.game,
                    self.version,
                    userid,
                    bank,
                    f'player_param_{item_type}',
                    {
                        'data': paramdata,
                    },
                )

        # Save player episode info
        episode = request.child('pdata/episode')
        if episode:
            for child in episode.children:
                if child.name != 'info':
                    continue

                # I assume this is copypasta, but I want to be sure
                extid = child.child_value('user_id')
                if extid != newprofile.extid:
                    raise Exception(f'Unexpected user ID, got {extid} expecting {newprofile.extid}')

                episode_type = child.child_value('type')
                episode_value0 = child.child_value('value0')
                episode_value1 = child.child_value('value1')
                episode_text = child.child_value('text')
                episode_time = child.child_value('time')
                self.data.local.user.put_achievement(
                    self.game,
                    self.version,
                    userid,
                    episode_type,
                    'episode',
                    {
                        'value0': episode_value0,
                        'value1': episode_value1,
                        'text': episode_text,
                        'time': episode_time,
                    },
                )

        # Save released info
        released = request.child('pdata/released')
        if released:
            for child in released.children:
                if child.name != 'info':
                    continue

                item_id = child.child_value('id')
                item_type = child.child_value('type')
                param = child.child_value('param')
                if game_config.get_bool('force_unlock_songs') and item_type == 0:
                    # Don't save unlocks when we're force unlocking
                    continue

                self.data.local.user.put_achievement(
                    self.game,
                    self.version,
                    userid,
                    item_id,
                    f'item_{item_type}',
                    {
                        'param': param,
                    },
                )

        # Save announce info
        announce = request.child('pdata/announce')
        if announce:
            for child in announce.children:
                if child.name != 'info':
                    continue

                announce_id = child.child_value('id')
                announce_type = child.child_value('type')
                param = child.child_value('param')
                need = child.child_value('bneedannounce')
                self.data.local.user.put_achievement(
                    self.game,
                    self.version,
                    userid,
                    announce_id,
                    f'announcement_{announce_type}',
                    {
                        'param': param,
                        'need': need,
                    },
                )

        # Grab any new rivals added during this play session
        rivalnode = request.child('pdata/rival')
        if rivalnode:
            for child in rivalnode.children:
                if child.name != 'r':
                    continue

                extid = child.child_value('id')
                other_userid = self.data.remote.user.from_extid(self.game, self.version, extid)
                if other_userid is None:
                    continue

                self.data.local.user.put_link(
                    self.game,
                    self.version,
                    userid,
                    'rival',
                    other_userid,
                    {},
                )

        # Grab any new records set during this play session
        songplays = request.child('pdata/stglog')
        if songplays:
            for child in songplays.children:
                if child.name != 'log':
                    continue

                songid = child.child_value('mid')
                chart = child.child_value('ng')
                clear_type = child.child_value('ct')
                if songid == 0 and chart == 0 and clear_type == -1:
                    # Dummy song save during profile create
                    continue

                points = child.child_value('sc')
                achievement_rate = child.child_value('ar')
                param = child.child_value('param')
                miss_count = child.child_value('jt_ms')

                # Param is some random bits along with the combo type
                combo_type = param & 0x3
                param = param ^ combo_type

                clear_type = self.__game_to_db_clear_type(clear_type)
                combo_type = self.__game_to_db_combo_type(combo_type, miss_count)
                self.update_score(
                    userid,
                    songid,
                    chart,
                    points,
                    achievement_rate,
                    clear_type,
                    combo_type,
                    miss_count,
                    param=param,
                )

        # Keep track of play statistics
        self.update_play_statistics(userid)

        return newprofile
