# 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.music_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_requests(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.music_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.music_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.music_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.music_version, userid, 5
            )
        ]
        most_played = [
            x[0]
            for x in self.data.local.music.get_most_played(
                self.game, self.music_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.music_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