# vim: set fileencoding=utf-8
import binascii
from typing import Any, Dict, List
from typing_extensions import Final

from bemani.backend.popn.base import PopnMusicBase
from bemani.backend.popn.lapistoria import PopnMusicLapistoria

from bemani.common import Profile, VersionConstants
from bemani.data import UserID, Link
from bemani.protocol import Node


class PopnMusicEclale(PopnMusicBase):

    name: str = "Pop'n Music éclale"
    version: int = VersionConstants.POPN_MUSIC_ECLALE

    # Chart type, as returned from the game
    GAME_CHART_TYPE_EASY: Final[int] = 0
    GAME_CHART_TYPE_NORMAL: Final[int] = 1
    GAME_CHART_TYPE_HYPER: Final[int] = 2
    GAME_CHART_TYPE_EX: Final[int] = 3

    # Medal type, as returned from the game
    GAME_PLAY_MEDAL_CIRCLE_FAILED: Final[int] = 1
    GAME_PLAY_MEDAL_DIAMOND_FAILED: Final[int] = 2
    GAME_PLAY_MEDAL_STAR_FAILED: Final[int] = 3
    GAME_PLAY_MEDAL_EASY_CLEAR: Final[int] = 4
    GAME_PLAY_MEDAL_CIRCLE_CLEARED: Final[int] = 5
    GAME_PLAY_MEDAL_DIAMOND_CLEARED: Final[int] = 6
    GAME_PLAY_MEDAL_STAR_CLEARED: Final[int] = 7
    GAME_PLAY_MEDAL_CIRCLE_FULL_COMBO: Final[int] = 8
    GAME_PLAY_MEDAL_DIAMOND_FULL_COMBO: Final[int] = 9
    GAME_PLAY_MEDAL_STAR_FULL_COMBO: Final[int] = 10
    GAME_PLAY_MEDAL_PERFECT: Final[int] = 11

    # Biggest ID in the music DB
    GAME_MAX_MUSIC_ID: Final[int] = 1550

    def previous_version(self) -> PopnMusicBase:
        return PopnMusicLapistoria(self.data, self.config, self.model)

    @classmethod
    def get_settings(cls) -> Dict[str, Any]:
        """
        Return all of our front-end modifiably settings.
        """
        return {
            'ints': [
                {
                    'name': 'Music Open Phase',
                    'tip': 'Default music phase for all players.',
                    'category': 'game_config',
                    'setting': 'music_phase',
                    'values': {
                        0: 'No music unlocks',
                        1: 'Phase 1',
                        2: 'Phase 2',
                        3: 'Phase 3',
                        4: 'Phase 4',
                        5: 'Phase 5',
                        6: 'Phase 6',
                        7: 'Phase 7',
                        8: 'Phase 8',
                        9: 'Phase 9',
                        10: 'Phase 10',
                        11: 'Phase 11',
                        12: 'Phase 12',
                        13: 'Phase 13',
                        14: 'Phase 14',
                        15: 'Phase 15',
                        16: 'Phase MAX',
                    }
                },
                {
                    'name': 'Additional Music Unlock Phase',
                    'tip': 'Additional music unlock phase for all players.',
                    'category': 'game_config',
                    'setting': 'music_sub_phase',
                    'values': {
                        0: 'No additional unlocks',
                        1: 'Phase 1',
                        2: 'Phase 2',
                        3: 'Phase MAX',
                    },
                },
            ],
            'bools': [
                {
                    'name': 'Enable Starmaker Event',
                    'tip': 'Enable Starmaker event as well as song shop.',
                    'category': 'game_config',
                    'setting': 'starmaker_enable',
                },
                # We don't currently support lobbies or anything, so this is commented out until
                # somebody gets around to implementing it.
                # {
                #     'name': 'Net Taisen',
                #     'tip': 'Enable Net Taisen, including win/loss display on song select',
                #     'category': 'game_config',
                #     'setting': 'enable_net_taisen',
                # },
                {
                    'name': 'Force Song Unlock',
                    'tip': 'Force unlock all songs.',
                    'category': 'game_config',
                    'setting': 'force_unlock_songs',
                },
            ],
        }

    def __construct_common_info(self, root: Node) -> None:
        game_config = self.get_game_config()
        music_phase = game_config.get_int('music_phase')
        music_sub_phase = game_config.get_int('music_sub_phase')
        enable_net_taisen = False  # game_config.get_bool('enable_net_taisen')

        # Event phases. Eclale seems to be so basic that there is no way to disable/enable
        # the starmaker event. It is just baked into the game.
        phases = {
            # Music open phase (0-16).
            # The following songs are unlocked when the phase is at or above the number specified:
            # 1  - 1470, 1471, 1472
            # 2  - 1447, 1450, 1454, 1457
            # 3  - 1477, 1475, 1483
            # 4  - 1473
            # 5  - 1480, 1479, 1481
            # 6  - 1494, 1495
            # 7  - 1490, 1491
            # 8  - 1489
            # 9  - 1502, 1503, 1504, 1505, 1506, 1507
            # 10 - 1492
            # 11 - 1508, 1509, 1510, 1511
            # 12 - 1518
            # 13 - 1530
            # 14 - 1543
            # 15 - 1544
            # 16 - 1548
            0: music_phase,
            # Unknown event (0-3)
            1: 3,
            # Unknown event (0-1)
            2: 1,
            # Unknown event (0-2)
            3: 2,
            # Something to do with favorites folder and the favorites button on the 10key (0-1)
            4: 1,
            # Looks like something to do with stamp cards, enabled with 1 (0-2)
            5: 1,
            # Unknown event (0-1)
            6: 1,
            # Unknown event (0-4)
            7: 4,
            # Unlock a few more songs (1: 1496, 2: 1474, 3: 1531) (0-3)
            8: music_sub_phase,
            # Unknown event (0-4)
            9: 4,
            # Unknown event (0-4)
            10: 4,
            # Unknown event, maybe something to do with song categories? (0-1)
            11: 1,
            # Enable Net Taisen, including win/loss sort option on music select (0-1)
            12: 1 if enable_net_taisen else 0,
            # Enable local and server-side matching when selecting a song (0-4)
            13: 4,
        }

        for phaseid in phases:
            phase = Node.void('phase')
            root.add_child(phase)
            phase.add_child(Node.s16('event_id', phaseid))
            phase.add_child(Node.s16('phase', phases[phaseid]))

        if game_config.get_bool('starmaker_enable'):
            for areaid in range(1, 51):
                area = Node.void('area')
                root.add_child(area)
                area.add_child(Node.s16('area_id', areaid))
                area.add_child(Node.u64('end_date', 0))
                area.add_child(Node.s16('medal_id', areaid))
                area.add_child(Node.bool('is_limit', False))

        # Calculate most popular characters
        profiles = self.data.remote.user.get_all_profiles(self.game, self.version)
        charas: Dict[int, int] = {}
        for (_userid, profile) in profiles:
            chara = profile.get_int('chara', -1)
            if chara <= 0:
                continue
            if chara not in charas:
                charas[chara] = 1
            else:
                charas[chara] = charas[chara] + 1

        # Order a typle by most popular character to least popular character
        charamap = sorted(
            [(c, charas[c]) for c in charas],
            key=lambda c: c[1],
            reverse=True,
        )

        # Output the top 20 of them
        rank = 1
        for (charaid, _usecount) in charamap[:20]:
            popular = Node.void('popular')
            root.add_child(popular)
            popular.add_child(Node.s16('rank', rank))
            popular.add_child(Node.s16('chara_num', charaid))
            rank = rank + 1

        # Output the hit chart
        for (songid, _plays) in self.data.local.music.get_hit_chart(self.game, self.version, 500):
            popular_music = Node.void('popular_music')
            root.add_child(popular_music)
            popular_music.add_child(Node.s16('music_num', songid))

        # Output goods prices
        for goodsid in range(1, 421):
            if goodsid >= 1 and goodsid <= 80:
                price = 60
            elif goodsid >= 81 and goodsid <= 120:
                price = 250
            elif goodsid >= 121 and goodsid <= 142:
                price = 500
            elif goodsid >= 143 and goodsid <= 300:
                price = 100
            elif goodsid >= 301 and goodsid <= 420:
                price = 150
            else:
                raise Exception('Invalid goods ID!')
            goods = Node.void('goods')
            root.add_child(goods)
            goods.add_child(Node.s16('goods_id', goodsid))
            goods.add_child(Node.s32('price', price))
            goods.add_child(Node.s16('goods_type', 0))

    def handle_pcb23_boot_request(self, request: Node) -> Node:
        return Node.void('pcb23')

    def handle_pcb23_error_request(self, request: Node) -> Node:
        return Node.void('pcb23')

    def handle_pcb23_dlstatus_request(self, request: Node) -> Node:
        return Node.void('pcb23')

    def handle_pcb23_write_request(self, request: Node) -> Node:
        # Update the name of this cab for admin purposes
        self.update_machine_name(request.child_value('pcb_setting/name'))
        return Node.void('pcb23')

    def handle_info23_common_request(self, request: Node) -> Node:
        info = Node.void('info23')
        self.__construct_common_info(info)
        return info

    def handle_lobby22_request(self, request: Node) -> Node:
        # Stub out the entire lobby22 service (yes, its lobby22 in Pop'n 23)
        return Node.void('lobby22')

    def handle_player23_start_request(self, request: Node) -> Node:
        root = Node.void('player23')
        root.add_child(Node.s32('play_id', 0))
        self.__construct_common_info(root)
        return root

    def handle_player23_logout_request(self, request: Node) -> Node:
        return Node.void('player23')

    def handle_player23_read_request(self, request: Node) -> Node:
        refid = request.child_value('ref_id')
        root = self.get_profile_by_refid(refid, self.OLD_PROFILE_FALLTHROUGH)
        if root is None:
            root = Node.void('player23')
            root.add_child(Node.s8('result', 2))
        return root

    def handle_player23_write_request(self, request: Node) -> Node:
        refid = request.child_value('ref_id')

        if refid is not None:
            userid = self.data.remote.user.from_refid(self.game, self.version, refid)
        else:
            userid = None

        if userid is not None:
            oldprofile = self.get_profile(userid) or Profile(self.game, self.version, refid, 0)
            newprofile = self.unformat_profile(userid, request, oldprofile)

            if newprofile is not None:
                self.put_profile(userid, newprofile)

        return Node.void('player23')

    def handle_player23_new_request(self, request: Node) -> Node:
        refid = request.child_value('ref_id')
        name = request.child_value('name')
        root = self.new_profile_by_refid(refid, name)
        if root is None:
            root = Node.void('player23')
            root.add_child(Node.s8('result', 2))
        return root

    def handle_player23_conversion_request(self, request: Node) -> Node:
        refid = request.child_value('ref_id')
        name = request.child_value('name')
        chara = request.child_value('chara')
        root = self.new_profile_by_refid(refid, name, chara)
        if root is None:
            root = Node.void('player23')
            root.add_child(Node.s8('result', 2))
        return root

    def handle_player23_buy_request(self, request: Node) -> Node:
        refid = request.child_value('ref_id')

        if refid is not None:
            userid = self.data.remote.user.from_refid(self.game, self.version, refid)
        else:
            userid = None

        if userid is not None:
            itemid = request.child_value('id')
            itemtype = request.child_value('type')
            itemparam = request.child_value('param')

            price = request.child_value('price')
            lumina = request.child_value('lumina')

            if lumina >= price:
                # Update player lumina balance
                profile = self.get_profile(userid) or Profile(self.game, self.version, refid, 0)
                profile.replace_int('lumina', lumina - price)
                self.put_profile(userid, profile)

                # Grant the object
                self.data.local.user.put_achievement(
                    self.game,
                    self.version,
                    userid,
                    itemid,
                    f'item_{itemtype}',
                    {
                        'param': itemparam,
                        'is_new': True,
                    },
                )

        return Node.void('player23')

    def handle_player23_read_score_request(self, request: Node) -> Node:
        refid = request.child_value('ref_id')

        root = Node.void('player23')

        userid = self.data.remote.user.from_refid(self.game, self.version, refid)
        if userid is not None:
            scores = self.data.remote.music.get_scores(self.game, self.version, userid)
        else:
            scores = []

        for score in scores:
            # Skip any scores for chart types we don't support
            if score.chart not in [
                self.CHART_TYPE_EASY,
                self.CHART_TYPE_NORMAL,
                self.CHART_TYPE_HYPER,
                self.CHART_TYPE_EX,
            ]:
                continue
            if score.data.get_int('medal') == self.PLAY_MEDAL_NO_PLAY:
                continue

            points = score.points
            medal = score.data.get_int('medal')

            music = Node.void('music')
            root.add_child(music)
            music.add_child(Node.s16('music_num', score.id))
            music.add_child(Node.u8('sheet_num', {
                self.CHART_TYPE_EASY: self.GAME_CHART_TYPE_EASY,
                self.CHART_TYPE_NORMAL: self.GAME_CHART_TYPE_NORMAL,
                self.CHART_TYPE_HYPER: self.GAME_CHART_TYPE_HYPER,
                self.CHART_TYPE_EX: self.GAME_CHART_TYPE_EX,
            }[score.chart]))
            music.add_child(Node.s32('score', points))
            music.add_child(Node.u8('clear_type', {
                self.PLAY_MEDAL_CIRCLE_FAILED: self.GAME_PLAY_MEDAL_CIRCLE_FAILED,
                self.PLAY_MEDAL_DIAMOND_FAILED: self.GAME_PLAY_MEDAL_DIAMOND_FAILED,
                self.PLAY_MEDAL_STAR_FAILED: self.GAME_PLAY_MEDAL_STAR_FAILED,
                self.PLAY_MEDAL_EASY_CLEAR: self.GAME_PLAY_MEDAL_EASY_CLEAR,
                self.PLAY_MEDAL_CIRCLE_CLEARED: self.GAME_PLAY_MEDAL_CIRCLE_CLEARED,
                self.PLAY_MEDAL_DIAMOND_CLEARED: self.GAME_PLAY_MEDAL_DIAMOND_CLEARED,
                self.PLAY_MEDAL_STAR_CLEARED: self.GAME_PLAY_MEDAL_STAR_CLEARED,
                self.PLAY_MEDAL_CIRCLE_FULL_COMBO: self.GAME_PLAY_MEDAL_CIRCLE_FULL_COMBO,
                self.PLAY_MEDAL_DIAMOND_FULL_COMBO: self.GAME_PLAY_MEDAL_DIAMOND_FULL_COMBO,
                self.PLAY_MEDAL_STAR_FULL_COMBO: self.GAME_PLAY_MEDAL_STAR_FULL_COMBO,
                self.PLAY_MEDAL_PERFECT: self.GAME_PLAY_MEDAL_PERFECT,
            }[medal]))
            music.add_child(Node.s16('cnt', score.plays))

        return root

    def handle_player23_friend_request(self, request: Node) -> Node:
        refid = request.attribute('ref_id')
        no = int(request.attribute('no', '-1'))

        root = Node.void('player23')
        if no < 0:
            root.add_child(Node.s8('result', 2))
            return root

        # Look up our own user ID based on the RefID provided.
        userid = self.data.remote.user.from_refid(self.game, self.version, refid)
        if userid is None:
            root.add_child(Node.s8('result', 2))
            return root

        # Grab the links that we care about.
        links = self.data.local.user.get_links(self.game, self.version, userid)
        profiles: Dict[UserID, Profile] = {}
        rivals: List[Link] = []
        for link in links:
            if link.type != 'rival':
                continue

            other_profile = self.get_profile(link.other_userid)
            if other_profile is None:
                continue
            profiles[link.other_userid] = other_profile
            rivals.append(link)

        # Somehow requested an invalid profile.
        if no >= len(rivals):
            root.add_child(Node.s8('result', 2))
            return root
        rivalid = links[no].other_userid
        rivalprofile = profiles[rivalid]
        scores = self.data.remote.music.get_scores(self.game, self.version, rivalid)

        # First, output general profile info.
        friend = Node.void('friend')
        root.add_child(friend)
        friend.add_child(Node.s16('no', no))
        friend.add_child(Node.string('g_pm_id', self.format_extid(rivalprofile.extid)))  # Eclale formats on its own
        friend.add_child(Node.string('name', rivalprofile.get_str('name', 'なし')))
        friend.add_child(Node.s16('chara_num', rivalprofile.get_int('chara', -1)))
        # This might be for having non-active or non-confirmed friends, but setting to 0 makes the
        # ranking numbers disappear and the player icon show a questionmark.
        friend.add_child(Node.s8('is_open', 1))

        for score in scores:
            # Skip any scores for chart types we don't support
            if score.chart not in [
                self.CHART_TYPE_EASY,
                self.CHART_TYPE_NORMAL,
                self.CHART_TYPE_HYPER,
                self.CHART_TYPE_EX,
            ]:
                continue
            if score.data.get_int('medal') == self.PLAY_MEDAL_NO_PLAY:
                continue

            points = score.points
            medal = score.data.get_int('medal')

            music = Node.void('music')
            friend.add_child(music)
            music.set_attribute('music_num', str(score.id))
            music.set_attribute('sheet_num', str({
                self.CHART_TYPE_EASY: self.GAME_CHART_TYPE_EASY,
                self.CHART_TYPE_NORMAL: self.GAME_CHART_TYPE_NORMAL,
                self.CHART_TYPE_HYPER: self.GAME_CHART_TYPE_HYPER,
                self.CHART_TYPE_EX: self.GAME_CHART_TYPE_EX,
            }[score.chart]))
            music.set_attribute('score', str(points))
            music.set_attribute('clearmedal', str({
                self.PLAY_MEDAL_CIRCLE_FAILED: self.GAME_PLAY_MEDAL_CIRCLE_FAILED,
                self.PLAY_MEDAL_DIAMOND_FAILED: self.GAME_PLAY_MEDAL_DIAMOND_FAILED,
                self.PLAY_MEDAL_STAR_FAILED: self.GAME_PLAY_MEDAL_STAR_FAILED,
                self.PLAY_MEDAL_EASY_CLEAR: self.GAME_PLAY_MEDAL_EASY_CLEAR,
                self.PLAY_MEDAL_CIRCLE_CLEARED: self.GAME_PLAY_MEDAL_CIRCLE_CLEARED,
                self.PLAY_MEDAL_DIAMOND_CLEARED: self.GAME_PLAY_MEDAL_DIAMOND_CLEARED,
                self.PLAY_MEDAL_STAR_CLEARED: self.GAME_PLAY_MEDAL_STAR_CLEARED,
                self.PLAY_MEDAL_CIRCLE_FULL_COMBO: self.GAME_PLAY_MEDAL_CIRCLE_FULL_COMBO,
                self.PLAY_MEDAL_DIAMOND_FULL_COMBO: self.GAME_PLAY_MEDAL_DIAMOND_FULL_COMBO,
                self.PLAY_MEDAL_STAR_FULL_COMBO: self.GAME_PLAY_MEDAL_STAR_FULL_COMBO,
                self.PLAY_MEDAL_PERFECT: self.GAME_PLAY_MEDAL_PERFECT,
            }[medal]))

        return root

    def handle_player23_write_music_request(self, request: Node) -> Node:
        refid = request.child_value('ref_id')

        root = Node.void('player23')
        if refid is None:
            return root

        userid = self.data.remote.user.from_refid(self.game, self.version, refid)
        if userid is None:
            return root

        songid = request.child_value('music_num')
        chart = {
            self.GAME_CHART_TYPE_EASY: self.CHART_TYPE_EASY,
            self.GAME_CHART_TYPE_NORMAL: self.CHART_TYPE_NORMAL,
            self.GAME_CHART_TYPE_HYPER: self.CHART_TYPE_HYPER,
            self.GAME_CHART_TYPE_EX: self.CHART_TYPE_EX,
        }[request.child_value('sheet_num')]
        medal = request.child_value('clearmedal')
        points = request.child_value('score')
        combo = request.child_value('combo')
        stats = {
            'cool': request.child_value('cool'),
            'great': request.child_value('great'),
            'good': request.child_value('good'),
            'bad': request.child_value('bad')
        }
        medal = {
            self.GAME_PLAY_MEDAL_CIRCLE_FAILED: self.PLAY_MEDAL_CIRCLE_FAILED,
            self.GAME_PLAY_MEDAL_DIAMOND_FAILED: self.PLAY_MEDAL_DIAMOND_FAILED,
            self.GAME_PLAY_MEDAL_STAR_FAILED: self.PLAY_MEDAL_STAR_FAILED,
            self.GAME_PLAY_MEDAL_EASY_CLEAR: self.PLAY_MEDAL_EASY_CLEAR,
            self.GAME_PLAY_MEDAL_CIRCLE_CLEARED: self.PLAY_MEDAL_CIRCLE_CLEARED,
            self.GAME_PLAY_MEDAL_DIAMOND_CLEARED: self.PLAY_MEDAL_DIAMOND_CLEARED,
            self.GAME_PLAY_MEDAL_STAR_CLEARED: self.PLAY_MEDAL_STAR_CLEARED,
            self.GAME_PLAY_MEDAL_CIRCLE_FULL_COMBO: self.PLAY_MEDAL_CIRCLE_FULL_COMBO,
            self.GAME_PLAY_MEDAL_DIAMOND_FULL_COMBO: self.PLAY_MEDAL_DIAMOND_FULL_COMBO,
            self.GAME_PLAY_MEDAL_STAR_FULL_COMBO: self.PLAY_MEDAL_STAR_FULL_COMBO,
            self.GAME_PLAY_MEDAL_PERFECT: self.PLAY_MEDAL_PERFECT,
        }[medal]
        self.update_score(userid, songid, chart, points, medal, combo=combo, stats=stats)

        if request.child_value('is_image_store') == 1:
            self.broadcast_score(userid, songid, chart, medal, points, combo, stats)

        return root

    def format_conversion(self, userid: UserID, profile: Profile) -> Node:
        root = Node.void('player23')
        root.add_child(Node.string('name', profile.get_str('name', 'なし')))
        root.add_child(Node.s16('chara', profile.get_int('chara', -1)))
        root.add_child(Node.s8('result', 1))

        # Scores
        scores = self.data.remote.music.get_scores(self.game, self.version, userid)
        for score in scores:
            # Skip any scores for chart types we don't support
            if score.chart not in [
                self.CHART_TYPE_EASY,
                self.CHART_TYPE_NORMAL,
                self.CHART_TYPE_HYPER,
                self.CHART_TYPE_EX,
            ]:
                continue
            if score.data.get_int('medal') == self.PLAY_MEDAL_NO_PLAY:
                continue

            music = Node.void('music')
            root.add_child(music)
            music.add_child(Node.s16('music_num', score.id))
            music.add_child(Node.u8('sheet_num', {
                self.CHART_TYPE_EASY: self.GAME_CHART_TYPE_EASY,
                self.CHART_TYPE_NORMAL: self.GAME_CHART_TYPE_NORMAL,
                self.CHART_TYPE_HYPER: self.GAME_CHART_TYPE_HYPER,
                self.CHART_TYPE_EX: self.GAME_CHART_TYPE_EX,
            }[score.chart]))
            music.add_child(Node.s32('score', score.points))
            music.add_child(Node.u8('clear_type', {
                self.PLAY_MEDAL_CIRCLE_FAILED: self.GAME_PLAY_MEDAL_CIRCLE_FAILED,
                self.PLAY_MEDAL_DIAMOND_FAILED: self.GAME_PLAY_MEDAL_DIAMOND_FAILED,
                self.PLAY_MEDAL_STAR_FAILED: self.GAME_PLAY_MEDAL_STAR_FAILED,
                self.PLAY_MEDAL_EASY_CLEAR: self.GAME_PLAY_MEDAL_EASY_CLEAR,
                self.PLAY_MEDAL_CIRCLE_CLEARED: self.GAME_PLAY_MEDAL_CIRCLE_CLEARED,
                self.PLAY_MEDAL_DIAMOND_CLEARED: self.GAME_PLAY_MEDAL_DIAMOND_CLEARED,
                self.PLAY_MEDAL_STAR_CLEARED: self.GAME_PLAY_MEDAL_STAR_CLEARED,
                self.PLAY_MEDAL_CIRCLE_FULL_COMBO: self.GAME_PLAY_MEDAL_CIRCLE_FULL_COMBO,
                self.PLAY_MEDAL_DIAMOND_FULL_COMBO: self.GAME_PLAY_MEDAL_DIAMOND_FULL_COMBO,
                self.PLAY_MEDAL_STAR_FULL_COMBO: self.GAME_PLAY_MEDAL_STAR_FULL_COMBO,
                self.PLAY_MEDAL_PERFECT: self.GAME_PLAY_MEDAL_PERFECT,
            }[score.data.get_int('medal')]))
            music.add_child(Node.s16('cnt', score.plays))

        return root

    def format_extid(self, extid: int) -> str:
        data = str(extid)
        crc = abs(binascii.crc32(data.encode('ascii'))) % 10000
        return f'{data}{crc:04d}'

    def format_profile(self, userid: UserID, profile: Profile) -> Node:
        root = Node.void('player23')

        # Mark this as a current profile
        root.add_child(Node.s8('result', 0))

        # Account stuff
        account = Node.void('account')
        root.add_child(account)
        account.add_child(Node.string('g_pm_id', self.format_extid(profile.extid)))
        account.add_child(Node.string('name', profile.get_str('name', 'なし')))
        account.add_child(Node.s8('tutorial', profile.get_int('tutorial')))
        account.add_child(Node.s16('area_id', profile.get_int('area_id')))
        account.add_child(Node.s16('lumina', profile.get_int('lumina', 300)))
        account.add_child(Node.s16('read_news', profile.get_int('read_news')))
        account.add_child(Node.bool('welcom_pack', False))  # Set this to true to grant extra stage no matter what.
        account.add_child(Node.s16_array('medal_set', profile.get_int_array('medal_set', 4)))
        account.add_child(Node.s16_array('nice', profile.get_int_array('nice', 30, [-1] * 30)))
        account.add_child(Node.s16_array('favorite_chara', profile.get_int_array('favorite_chara', 20, [-1] * 20)))
        account.add_child(Node.s16_array('special_area', profile.get_int_array('special_area', 8)))
        account.add_child(Node.s16_array('chocolate_charalist', profile.get_int_array('chocolate_charalist', 5, [-1] * 5)))
        account.add_child(Node.s16_array('teacher_setting', profile.get_int_array('teacher_setting', 10)))

        # Stuff we never change
        account.add_child(Node.s8('staff', 0))
        account.add_child(Node.s16('item_type', 0))
        account.add_child(Node.s16('item_id', 0))
        account.add_child(Node.s8('is_conv', 0))
        account.add_child(Node.bool('meteor_flg', True))
        account.add_child(Node.s16_array('license_data', [-1] * 20))

        # Add statistics section
        last_played = [x[0] for x in self.data.local.music.get_last_played(self.game, self.version, userid, 5)]
        most_played = [x[0] for x in self.data.local.music.get_most_played(self.game, self.version, userid, 10)]
        while len(last_played) < 5:
            last_played.append(-1)
        while len(most_played) < 10:
            most_played.append(-1)

        account.add_child(Node.s16_array('my_best', most_played))
        account.add_child(Node.s16_array('latest_music', last_played))

        # Number of rivals that are active for this version.
        links = self.data.local.user.get_links(self.game, self.version, userid)
        rivalcount = 0
        for link in links:
            if link.type != 'rival':
                continue

            if not self.has_profile(link.other_userid):
                continue

            # This profile is valid.
            rivalcount += 1
        account.add_child(Node.u8('active_fr_num', rivalcount))

        # player statistics
        statistics = self.get_play_statistics(userid)
        account.add_child(Node.s16('total_play_cnt', statistics.total_plays))
        account.add_child(Node.s16('today_play_cnt', statistics.today_plays))
        account.add_child(Node.s16('consecutive_days', statistics.consecutive_days))
        account.add_child(Node.s16('total_days', statistics.total_days))
        account.add_child(Node.s16('interval_day', 0))

        # eAmuse account link
        eaappli = Node.void('eaappli')
        root.add_child(eaappli)
        eaappli.add_child(Node.s8('relation', 1 if self.data.triggers.has_broadcast_destination(self.game) else -1))

        # Set up info node
        info = Node.void('info')
        root.add_child(info)
        info.add_child(Node.u16('ep', profile.get_int('ep')))

        # Set up last information
        config = Node.void('config')
        root.add_child(config)
        config.add_child(Node.u8('mode', profile.get_int('mode')))
        config.add_child(Node.s16('chara', profile.get_int('chara', -1)))
        config.add_child(Node.s16('music', profile.get_int('music', -1)))
        config.add_child(Node.u8('sheet', profile.get_int('sheet')))
        config.add_child(Node.s8('category', profile.get_int('category', -1)))
        config.add_child(Node.s8('sub_category', profile.get_int('sub_category', -1)))
        config.add_child(Node.s8('chara_category', profile.get_int('chara_category', -1)))
        config.add_child(Node.s16('course_id', profile.get_int('course_id', -1)))
        config.add_child(Node.s8('course_folder', profile.get_int('course_folder', -1)))
        config.add_child(Node.s8('ms_banner_disp', profile.get_int('ms_banner_disp')))
        config.add_child(Node.s8('ms_down_info', profile.get_int('ms_down_info')))
        config.add_child(Node.s8('ms_side_info', profile.get_int('ms_side_info')))
        config.add_child(Node.s8('ms_raise_type', profile.get_int('ms_raise_type')))
        config.add_child(Node.s8('ms_rnd_type', profile.get_int('ms_rnd_type')))

        # Player options
        option = Node.void('option')
        option_dict = profile.get_dict('option')
        root.add_child(option)
        option.add_child(Node.s16('hispeed', option_dict.get_int('hispeed')))
        option.add_child(Node.u8('popkun', option_dict.get_int('popkun')))
        option.add_child(Node.bool('hidden', option_dict.get_bool('hidden')))
        option.add_child(Node.s16('hidden_rate', option_dict.get_int('hidden_rate')))
        option.add_child(Node.bool('sudden', option_dict.get_bool('sudden')))
        option.add_child(Node.s16('sudden_rate', option_dict.get_int('sudden_rate')))
        option.add_child(Node.s8('randmir', option_dict.get_int('randmir')))
        option.add_child(Node.s8('gauge_type', option_dict.get_int('gauge_type')))
        option.add_child(Node.u8('ojama_0', option_dict.get_int('ojama_0')))
        option.add_child(Node.u8('ojama_1', option_dict.get_int('ojama_1')))
        option.add_child(Node.bool('forever_0', option_dict.get_bool('forever_0')))
        option.add_child(Node.bool('forever_1', option_dict.get_bool('forever_1')))
        option.add_child(Node.bool('full_setting', option_dict.get_bool('full_setting')))
        option.add_child(Node.u8('judge', option_dict.get_int('judge')))

        # Unknown custom category stuff?
        custom_cate = Node.void('custom_cate')
        root.add_child(custom_cate)
        custom_cate.add_child(Node.s8('valid', 0))
        custom_cate.add_child(Node.s8('lv_min', -1))
        custom_cate.add_child(Node.s8('lv_max', -1))
        custom_cate.add_child(Node.s8('medal_min', -1))
        custom_cate.add_child(Node.s8('medal_max', -1))
        custom_cate.add_child(Node.s8('friend_no', -1))
        custom_cate.add_child(Node.s8('score_flg', -1))

        game_config = self.get_game_config()
        if game_config.get_bool('force_unlock_songs'):
            songs = {song.id for song in self.data.local.music.get_all_songs(self.game, self.version)}
            for song in songs:
                item = Node.void('item')
                root.add_child(item)
                item.add_child(Node.u8('type', 0))
                item.add_child(Node.u16('id', song))
                item.add_child(Node.u16('param', 15))
                item.add_child(Node.bool('is_new', False))

        # Set up achievements
        achievements = self.data.local.user.get_achievements(self.game, self.version, userid)
        for achievement in achievements:
            if achievement.type[:5] == 'item_':
                itemtype = int(achievement.type[5:])
                param = achievement.data.get_int('param')
                is_new = achievement.data.get_bool('is_new')

                # Type is the type of unlock/item. Type 0 is song unlock in Eclale.
                # In this case, the id is the song ID according to the game. Unclear
                # what the param is supposed to be, but i've seen 8 and 0. Might be
                # what chart is available?
                if game_config.get_bool('force_unlock_songs') and itemtype == 0:
                    # We already sent song unlocks in the force unlock section above.
                    continue

                item = Node.void('item')
                root.add_child(item)
                item.add_child(Node.u8('type', itemtype))
                item.add_child(Node.u16('id', achievement.id))
                item.add_child(Node.u16('param', param))
                item.add_child(Node.bool('is_new', is_new))

            elif achievement.type == 'chara':
                friendship = achievement.data.get_int('friendship')

                chara = Node.void('chara_param')
                root.add_child(chara)
                chara.add_child(Node.u16('chara_id', achievement.id))
                chara.add_child(Node.u16('friendship', friendship))

            elif achievement.type == 'medal':
                level = achievement.data.get_int('level')
                exp = achievement.data.get_int('exp')
                set_count = achievement.data.get_int('set_count')
                get_count = achievement.data.get_int('get_count')

                medal = Node.void('medal')
                root.add_child(medal)
                medal.add_child(Node.s16('medal_id', achievement.id))
                medal.add_child(Node.s16('level', level))
                medal.add_child(Node.s32('exp', exp))
                medal.add_child(Node.s32('set_count', set_count))
                medal.add_child(Node.s32('get_count', get_count))

        # Character customizations
        customize = Node.void('customize')
        root.add_child(customize)
        customize.add_child(Node.u16('effect_left', profile.get_int('effect_left')))
        customize.add_child(Node.u16('effect_center', profile.get_int('effect_center')))
        customize.add_child(Node.u16('effect_right', profile.get_int('effect_right')))
        customize.add_child(Node.u16('hukidashi', profile.get_int('hukidashi')))
        customize.add_child(Node.u16('comment_1', profile.get_int('comment_1')))
        customize.add_child(Node.u16('comment_2', profile.get_int('comment_2')))

        # NetVS section
        netvs = Node.void('netvs')
        root.add_child(netvs)
        netvs.add_child(Node.s16_array('record', [0] * 6))
        netvs.add_child(Node.string('dialog', ''))
        netvs.add_child(Node.string('dialog', ''))
        netvs.add_child(Node.string('dialog', ''))
        netvs.add_child(Node.string('dialog', ''))
        netvs.add_child(Node.string('dialog', ''))
        netvs.add_child(Node.string('dialog', ''))
        netvs.add_child(Node.s8_array('ojama_condition', [0] * 74))
        netvs.add_child(Node.s8_array('set_ojama', [0] * 3))
        netvs.add_child(Node.s8_array('set_recommend', [0] * 3))
        netvs.add_child(Node.u32('netvs_play_cnt', 0))

        # Event stuff
        event = Node.void('event')
        root.add_child(event)
        event.add_child(Node.s16('enemy_medal', profile.get_int('event_enemy_medal')))
        event.add_child(Node.s16('hp', profile.get_int('event_hp')))

        # Stamp stuff
        stamp = Node.void('stamp')
        root.add_child(stamp)
        stamp.add_child(Node.s16('stamp_id', profile.get_int('stamp_id')))
        stamp.add_child(Node.s16('cnt', profile.get_int('stamp_cnt')))

        return root

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

        account = request.child('account')
        if account is not None:
            newprofile.replace_int('tutorial', account.child_value('tutorial'))
            newprofile.replace_int('read_news', account.child_value('read_news'))
            newprofile.replace_int('area_id', account.child_value('area_id'))
            newprofile.replace_int('lumina', account.child_value('lumina'))
            newprofile.replace_int_array('medal_set', 4, account.child_value('medal_set'))
            newprofile.replace_int_array('nice', 30, account.child_value('nice'))
            newprofile.replace_int_array('favorite_chara', 20, account.child_value('favorite_chara'))
            newprofile.replace_int_array('special_area', 8, account.child_value('special_area'))
            newprofile.replace_int_array('chocolate_charalist', 5, account.child_value('chocolate_charalist'))
            newprofile.replace_int_array('teacher_setting', 10, account.child_value('teacher_setting'))

        info = request.child('info')
        if info is not None:
            newprofile.replace_int('ep', info.child_value('ep'))

        config = request.child('config')
        if config is not None:
            newprofile.replace_int('mode', config.child_value('mode'))
            newprofile.replace_int('chara', config.child_value('chara'))
            newprofile.replace_int('music', config.child_value('music'))
            newprofile.replace_int('sheet', config.child_value('sheet'))
            newprofile.replace_int('category', config.child_value('category'))
            newprofile.replace_int('sub_category', config.child_value('sub_category'))
            newprofile.replace_int('chara_category', config.child_value('chara_category'))
            newprofile.replace_int('course_id', config.child_value('course_id'))
            newprofile.replace_int('course_folder', config.child_value('course_folder'))
            newprofile.replace_int('ms_banner_disp', config.child_value('ms_banner_disp'))
            newprofile.replace_int('ms_down_info', config.child_value('ms_down_info'))
            newprofile.replace_int('ms_side_info', config.child_value('ms_side_info'))
            newprofile.replace_int('ms_raise_type', config.child_value('ms_raise_type'))
            newprofile.replace_int('ms_rnd_type', config.child_value('ms_rnd_type'))

        option_dict = newprofile.get_dict('option')
        option = request.child('option')
        if option is not None:
            option_dict.replace_int('hispeed', option.child_value('hispeed'))
            option_dict.replace_int('popkun', option.child_value('popkun'))
            option_dict.replace_bool('hidden', option.child_value('hidden'))
            option_dict.replace_int('hidden_rate', option.child_value('hidden_rate'))
            option_dict.replace_bool('sudden', option.child_value('sudden'))
            option_dict.replace_int('sudden_rate', option.child_value('sudden_rate'))
            option_dict.replace_int('randmir', option.child_value('randmir'))
            option_dict.replace_int('gauge_type', option.child_value('gauge_type'))
            option_dict.replace_int('ojama_0', option.child_value('ojama_0'))
            option_dict.replace_int('ojama_1', option.child_value('ojama_1'))
            option_dict.replace_bool('forever_0', option.child_value('forever_0'))
            option_dict.replace_bool('forever_1', option.child_value('forever_1'))
            option_dict.replace_bool('full_setting', option.child_value('full_setting'))
            option_dict.replace_int('judge', option.child_value('judge'))
        newprofile.replace_dict('option', option_dict)

        customize = request.child('customize')
        if customize is not None:
            newprofile.replace_int('effect_left', customize.child_value('effect_left'))
            newprofile.replace_int('effect_center', customize.child_value('effect_center'))
            newprofile.replace_int('effect_right', customize.child_value('effect_right'))
            newprofile.replace_int('hukidashi', customize.child_value('hukidashi'))
            newprofile.replace_int('comment_1', customize.child_value('comment_1'))
            newprofile.replace_int('comment_2', customize.child_value('comment_2'))

        event = request.child('event')
        if event is not None:
            newprofile.replace_int('event_enemy_medal', event.child_value('enemy_medal'))
            newprofile.replace_int('event_hp', event.child_value('hp'))

        stamp = request.child('stamp')
        if stamp is not None:
            newprofile.replace_int('stamp_id', stamp.child_value('stamp_id'))
            newprofile.replace_int('stamp_cnt', stamp.child_value('cnt'))

        # Extract achievements
        game_config = self.get_game_config()
        for node in request.children:
            if node.name == 'item':
                itemid = node.child_value('id')
                itemtype = node.child_value('type')
                param = node.child_value('param')
                is_new = node.child_value('is_new')

                if game_config.get_bool('force_unlock_songs') and itemtype == 0:
                    # If we enabled force song unlocks, don't save songs to the profile.
                    continue

                self.data.local.user.put_achievement(
                    self.game,
                    self.version,
                    userid,
                    itemid,
                    f'item_{itemtype}',
                    {
                        'param': param,
                        'is_new': is_new,
                    },
                )

            elif node.name == 'chara_param':
                charaid = node.child_value('chara_id')
                friendship = node.child_value('friendship')

                self.data.local.user.put_achievement(
                    self.game,
                    self.version,
                    userid,
                    charaid,
                    'chara',
                    {
                        'friendship': friendship,
                    },
                )

            elif node.name == 'medal':
                medalid = node.child_value('medal_id')
                level = node.child_value('level')
                exp = node.child_value('exp')
                set_count = node.child_value('set_count')
                get_count = node.child_value('get_count')

                self.data.local.user.put_achievement(
                    self.game,
                    self.version,
                    userid,
                    medalid,
                    'medal',
                    {
                        'level': level,
                        'exp': exp,
                        'set_count': set_count,
                        'get_count': get_count,
                    },
                )

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

        return newprofile
