# vim: set fileencoding=utf-8
import random
from typing import Any, Dict, List, Optional, Set, Tuple
from typing_extensions import Final

from bemani.backend.base import Status
from bemani.backend.jubeat.base import JubeatBase
from bemani.backend.jubeat.common import (
    JubeatDemodataGetHitchartHandler,
    JubeatDemodataGetNewsHandler,
    JubeatGamendRegisterHandler,
    JubeatGametopGetMeetingHandler,
    JubeatLobbyCheckHandler,
    JubeatLoggerReportHandler,
)
from bemani.backend.jubeat.course import JubeatCourse
from bemani.backend.jubeat.saucer import JubeatSaucer
from bemani.common import Profile, ValidatedDict, VersionConstants, Time
from bemani.data import Data, Score, UserID
from bemani.protocol import Node


class JubeatSaucerFulfill(
    JubeatDemodataGetHitchartHandler,
    JubeatDemodataGetNewsHandler,
    JubeatGamendRegisterHandler,
    JubeatGametopGetMeetingHandler,
    JubeatLobbyCheckHandler,
    JubeatLoggerReportHandler,
    JubeatCourse,
    JubeatBase,
):
    name: str = "Jubeat Saucer Fulfill"
    version: int = VersionConstants.JUBEAT_SAUCER_FULFILL

    GAME_COURSE_REQUIREMENT_SCORE: Final[int] = 1
    GAME_COURSE_REQUIREMENT_FULL_COMBO: Final[int] = 2
    GAME_COURSE_REQUIREMENT_PERFECT_PERCENT: Final[int] = 3

    GAME_COURSE_RATING_FAILED: Final[int] = 1
    GAME_COURSE_RATING_BRONZE: Final[int] = 2
    GAME_COURSE_RATING_SILVER: Final[int] = 3
    GAME_COURSE_RATING_GOLD: Final[int] = 4

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

    @classmethod
    def run_scheduled_work(
        cls, data: Data, config: Dict[str, Any]
    ) -> List[Tuple[str, Dict[str, Any]]]:
        """
        Insert daily FC challenges into the DB.
        """
        events = []
        if data.local.network.should_schedule(
            cls.game, cls.version, "fc_challenge", "daily"
        ):
            # Generate a new list of two FC challenge songs.
            start_time, end_time = data.local.network.get_schedule_duration("daily")
            all_songs = set(
                song.id
                for song in data.local.music.get_all_songs(cls.game, cls.version)
            )
            if len(all_songs) >= 2:
                daily_songs = random.sample(all_songs, 2)
                data.local.game.put_time_sensitive_settings(
                    cls.game,
                    cls.version,
                    "fc_challenge",
                    {
                        "start_time": start_time,
                        "end_time": end_time,
                        "today": daily_songs[0],
                        "whim": daily_songs[1],
                    },
                )
                events.append(
                    (
                        "jubeat_fc_challenge_charts",
                        {
                            "version": cls.version,
                            "today": daily_songs[0],
                            "whim": daily_songs[1],
                        },
                    )
                )

                # Mark that we did some actual work here.
                data.local.network.mark_scheduled(
                    cls.game, cls.version, "fc_challenge", "daily"
                )
        return events

    @classmethod
    def get_settings(cls) -> Dict[str, Any]:
        """
        Return all of our front-end modifiably settings.
        """
        return {
            "bools": [
                {
                    "name": "Force Unlock All Songs",
                    "tip": "Forces all songs to be available by default",
                    "category": "game_config",
                    "setting": "force_song_unlock",
                },
            ],
        }

    def handle_shopinfo_regist_request(self, request: Node) -> Node:
        # Update the name of this cab for admin purposes
        self.update_machine_name(request.child_value("shop/name"))

        shopinfo = Node.void("shopinfo")

        data = Node.void("data")
        shopinfo.add_child(data)
        data.add_child(Node.u32("cabid", 1))
        data.add_child(Node.string("locationid", "nowhere"))
        data.add_child(Node.u8("is_send", 1))
        data.add_child(
            Node.s32_array(
                "white_music_list",
                [
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                ],
            )
        )
        data.add_child(Node.u8("tax_phase", 1))

        lab = Node.void("lab")
        data.add_child(lab)
        lab.add_child(Node.bool("is_open", False))

        vocaloid_event = Node.void("vocaloid_event")
        data.add_child(vocaloid_event)
        vocaloid_event.add_child(Node.u8("state", 0))
        vocaloid_event.add_child(Node.s32("music_id", 0))

        vocaloid_event2 = Node.void("vocaloid_event2")
        data.add_child(vocaloid_event2)
        vocaloid_event2.add_child(Node.u8("state", 0))
        vocaloid_event2.add_child(Node.s32("music_id", 0))

        # No obnoxious 30 second wait to play.
        matching_off = Node.void("matching_off")
        data.add_child(matching_off)
        matching_off.add_child(Node.bool("is_open", True))

        tenka = Node.void("tenka")
        data.add_child(tenka)
        tenka.add_child(Node.bool("is_participant", False))

        return shopinfo

    def handle_gametop_get_course_request(self, request: Node) -> Node:
        data = request.child("data")
        player = data.child("player")
        extid = player.child_value("jid")

        gametop = Node.void("gametop")
        data = Node.void("data")
        gametop.add_child(data)

        # Course list available
        course_list = Node.void("course_list")
        data.add_child(course_list)

        validcourses: List[int] = []
        for course in self.get_all_courses():
            coursenode = Node.void("course")
            course_list.add_child(coursenode)

            # Basic course info
            if course["id"] in validcourses:
                raise Exception("Cannot have same course ID specified twice!")
            validcourses.append(course["id"])
            coursenode.add_child(Node.s32("id", course["id"]))
            coursenode.add_child(Node.string("name", course["name"]))
            coursenode.add_child(Node.u8("level", course["level"]))

            # Translate internal to game
            def translate_req(internal_req: int) -> int:
                return {
                    self.COURSE_REQUIREMENT_SCORE: self.GAME_COURSE_REQUIREMENT_SCORE,
                    self.COURSE_REQUIREMENT_FULL_COMBO: self.GAME_COURSE_REQUIREMENT_FULL_COMBO,
                    self.COURSE_REQUIREMENT_PERFECT_PERCENT: self.GAME_COURSE_REQUIREMENT_PERFECT_PERCENT,
                }.get(internal_req, 0)

            # Course bronze/silver/gold rules
            ids = [0] * 3
            bronze_values = [0] * 3
            silver_values = [0] * 3
            gold_values = [0] * 3
            slot = 0
            for req in course["requirements"]:
                req_values = course["requirements"][req]

                ids[slot] = translate_req(req)
                bronze_values[slot] = req_values[0]
                silver_values[slot] = req_values[1]
                gold_values[slot] = req_values[2]
                slot = slot + 1

            norma = Node.void("norma")
            coursenode.add_child(norma)
            norma.add_child(Node.s32_array("norma_id", ids))
            norma.add_child(Node.s32_array("bronze_value", bronze_values))
            norma.add_child(Node.s32_array("silver_value", silver_values))
            norma.add_child(Node.s32_array("gold_value", gold_values))

            # Music list for course
            music_index = 0
            music_list = Node.void("music_list")
            coursenode.add_child(music_list)

            for entry in course["music"]:
                music = Node.void("music")
                music.set_attribute("index", str(music_index))
                music_list.add_child(music)
                music.add_child(Node.s32("music_id", entry[0]))
                music.add_child(Node.u8("seq", entry[1]))
                music_index = music_index + 1

        # Look up profile so we can load the last course played
        userid = self.data.remote.user.from_extid(self.game, self.version, extid)
        profile = self.get_profile(userid)
        if profile is None:
            profile = Profile(self.game, self.version, "", extid)

        # Player scores for courses
        player_list = Node.void("player_list")
        data.add_child(player_list)
        player = Node.void("player")
        player_list.add_child(player)
        player.add_child(Node.s32("jid", extid))

        result_list = Node.void("result_list")
        player.add_child(result_list)
        playercourses = self.get_courses(userid)
        for courseid in playercourses:
            if courseid not in validcourses:
                continue

            rating = {
                self.COURSE_RATING_FAILED: self.GAME_COURSE_RATING_FAILED,
                self.COURSE_RATING_BRONZE: self.GAME_COURSE_RATING_BRONZE,
                self.COURSE_RATING_SILVER: self.GAME_COURSE_RATING_SILVER,
                self.COURSE_RATING_GOLD: self.GAME_COURSE_RATING_GOLD,
            }[playercourses[courseid]["rating"]]
            scores = playercourses[courseid]["scores"]

            result = Node.void("result")
            result_list.add_child(result)
            result.add_child(Node.s32("id", courseid))
            result.add_child(Node.u8("rating", rating))
            result.add_child(Node.s32_array("score", scores))

        # Last course ID
        last_course_id = Node.s32(
            "last_course_id", profile.get_dict("last").get_int("last_course_id", -1)
        )
        data.add_child(last_course_id)

        return gametop

    def handle_gametop_regist_request(self, request: Node) -> Node:
        data = request.child("data")
        player = data.child("player")
        passnode = player.child("pass")
        refid = passnode.child_value("refid")
        name = player.child_value("name")
        root = self.new_profile_by_refid(refid, name)
        return root

    def handle_gametop_get_pdata_request(self, request: Node) -> Node:
        data = request.child("data")
        player = data.child("player")
        passnode = player.child("pass")
        refid = passnode.child_value("refid")
        root = self.get_profile_by_refid(refid)
        if root is None:
            root = Node.void("gametop")
            root.set_attribute("status", str(Status.NO_PROFILE))
        return root

    def handle_gametop_get_mdata_request(self, request: Node) -> Node:
        data = request.child("data")
        player = data.child("player")
        extid = player.child_value("jid")
        mdata_ver = player.child_value(
            "mdata_ver"
        )  # Game requests mdata 3 times per profile for some reason
        if mdata_ver != 1:
            root = Node.void("gametop")
            datanode = Node.void("data")
            root.add_child(datanode)
            player = Node.void("player")
            datanode.add_child(player)
            player.add_child(Node.s32("jid", extid))
            playdata = Node.void("playdata")
            player.add_child(playdata)
            playdata.set_attribute("count", "0")
            return root
        root = self.get_scores_by_extid(extid)
        if root is None:
            root = Node.void("gametop")
            root.set_attribute("status", str(Status.NO_PROFILE))
        return root

    def handle_gametop_get_rival_mdata_request(self, request: Node) -> Node:
        data = request.child("data")
        player = data.child("player")
        extid = player.child_value("rival")
        root = self.get_scores_by_extid(extid)
        if root is None:
            root = Node.void("gametop")
            root.set_attribute("status", str(Status.NO_PROFILE))
        return root

    def format_profile(self, userid: UserID, profile: Profile) -> Node:
        root = Node.void("gametop")
        data = Node.void("data")
        root.add_child(data)
        player = Node.void("player")
        data.add_child(player)

        # Figure out if we're force-unlocking songs.
        game_config = self.get_game_config()
        force_unlock = game_config.get_bool("force_song_unlock")

        # Allow figuring out owned songs.
        achievements = self.data.local.user.get_achievements(
            self.game, self.version, userid
        )
        owned_songs: Set[int] = set()
        owned_secrets: Set[int] = set()
        for achievement in achievements:
            if achievement.type == "song":
                owned_songs.add(achievement.id)
            elif achievement.type == "secret":
                owned_secrets.add(achievement.id)

        # Player info and statistics
        info = Node.void("info")
        player.add_child(info)
        info.add_child(Node.s16("jubility", profile.get_int("jubility")))
        info.add_child(Node.s16("jubility_yday", profile.get_int("jubility_yday")))
        info.add_child(Node.s32("tune_cnt", profile.get_int("tune_cnt")))
        info.add_child(Node.s32("save_cnt", profile.get_int("save_cnt")))
        info.add_child(Node.s32("saved_cnt", profile.get_int("saved_cnt")))
        info.add_child(Node.s32("fc_cnt", profile.get_int("fc_cnt")))
        info.add_child(Node.s32("ex_cnt", profile.get_int("ex_cnt")))
        info.add_child(Node.s32("pf_cnt", profile.get_int("pf_cnt")))
        info.add_child(Node.s32("clear_cnt", profile.get_int("clear_cnt")))
        info.add_child(Node.s32("match_cnt", profile.get_int("match_cnt")))
        info.add_child(Node.s32("beat_cnt", profile.get_int("beat_cnt")))
        info.add_child(Node.s32("mynews_cnt", profile.get_int("mynews_cnt")))
        info.add_child(Node.s32("extra_point", profile.get_int("extra_point")))
        info.add_child(
            Node.bool("is_extra_played", profile.get_bool("is_extra_played"))
        )
        if "total_best_score" in profile:
            info.add_child(
                Node.s32("total_best_score", profile.get_int("total_best_score"))
            )

        # Looks to be set to true when there's an old profile, stops tutorial from
        # happening on first load.
        info.add_child(
            Node.bool(
                "inherit",
                profile.get_bool("has_old_version") and not profile.get_bool("saved"),
            )
        )

        # Not saved, but loaded
        info.add_child(Node.s32("mtg_entry_cnt", 123))
        info.add_child(Node.s32("mtg_hold_cnt", 456))
        info.add_child(Node.u8("mtg_result", 10))

        # First play stuff we don't support
        free_first_play = Node.void("free_first_play")
        player.add_child(free_first_play)
        free_first_play.add_child(Node.bool("is_available", False))
        free_first_play.add_child(Node.s32("point", 0))
        free_first_play.add_child(Node.s32("point_used", 0))

        # Secret unlocks
        item = Node.void("item")
        player.add_child(item)
        item.add_child(
            Node.s32_array(
                "secret_list",
                ([-1] * 32)
                if force_unlock
                else self.create_owned_items(owned_songs, 32),
            )
        )
        item.add_child(
            Node.s32_array(
                "title_list",
                profile.get_int_array(
                    "title_list",
                    96,
                    [-1] * 96,
                ),
            )
        )
        item.add_child(Node.s16("theme_list", profile.get_int("theme_list", -1)))
        item.add_child(
            Node.s32_array(
                "marker_list", profile.get_int_array("marker_list", 2, [-1] * 2)
            )
        )
        item.add_child(
            Node.s32_array(
                "parts_list", profile.get_int_array("parts_list", 96, [-1] * 96)
            )
        )

        new = Node.void("new")
        item.add_child(new)
        new.add_child(
            Node.s32_array(
                "secret_list",
                ([-1] * 32)
                if force_unlock
                else self.create_owned_items(owned_secrets, 32),
            )
        )
        new.add_child(
            Node.s32_array(
                "title_list",
                profile.get_int_array(
                    "title_list_new",
                    96,
                    [-1] * 96,
                ),
            )
        )
        new.add_child(Node.s16("theme_list", profile.get_int("theme_list_new", -1)))
        new.add_child(
            Node.s32_array(
                "marker_list", profile.get_int_array("marker_list_new", 2, [-1] * 2)
            )
        )

        # Last played data, for showing cursor and such
        lastdict = profile.get_dict("last")
        last = Node.void("last")
        player.add_child(last)
        last.add_child(Node.s32("music_id", lastdict.get_int("music_id")))
        last.add_child(Node.s8("marker", lastdict.get_int("marker")))
        last.add_child(Node.s16("title", lastdict.get_int("title")))
        last.add_child(Node.s8("theme", lastdict.get_int("theme")))
        last.add_child(Node.s8("sort", lastdict.get_int("sort")))
        last.add_child(Node.s8("rank_sort", lastdict.get_int("rank_sort")))
        last.add_child(Node.s8("combo_disp", lastdict.get_int("combo_disp")))
        last.add_child(Node.s8("seq_id", lastdict.get_int("seq_id")))
        last.add_child(Node.s16("parts", lastdict.get_int("parts")))
        last.add_child(Node.s8("category", lastdict.get_int("category")))
        last.add_child(Node.s64("play_time", lastdict.get_int("play_time")))
        last.add_child(Node.string("shopname", lastdict.get_str("shopname")))
        last.add_child(Node.string("areaname", lastdict.get_str("areaname")))
        last.add_child(Node.s8("expert_option", lastdict.get_int("expert_option")))
        last.add_child(Node.s8("matching", lastdict.get_int("matching")))
        last.add_child(Node.s8("hazard", lastdict.get_int("hazard")))
        last.add_child(Node.s8("hard", lastdict.get_int("hard")))

        # Miscelaneous crap
        player.add_child(Node.s32("session_id", 1))
        player.add_child(Node.u64("event_flag", 0))

        # Macchiato event
        macchiatodict = profile.get_dict("macchiato")
        macchiato = Node.void("macchiato")
        player.add_child(macchiato)
        macchiato.add_child(Node.s32("pack_id", macchiatodict.get_int("pack_id")))
        macchiato.add_child(Node.u16("bean_num", macchiatodict.get_int("bean_num")))
        macchiato.add_child(
            Node.s32("daily_milk_num", macchiatodict.get_int("daily_milk_num"))
        )
        macchiato.add_child(
            Node.bool(
                "is_received_daily_milk",
                macchiatodict.get_bool("is_received_daily_milk"),
            )
        )
        macchiato.add_child(
            Node.s32("today_tune_cnt", macchiatodict.get_int("today_tune_cnt"))
        )
        macchiato.add_child(
            Node.s32_array(
                "daily_milk_bonus",
                macchiatodict.get_int_array(
                    "daily_milk_bonus", 9, [-1, -1, -1, -1, -1, -1, -1, -1, -1]
                ),
            )
        )
        macchiato.add_child(
            Node.s32("daily_play_burst", macchiatodict.get_int("daily_play_burst"))
        )
        macchiato.add_child(
            Node.bool(
                "sub_menu_is_completed", macchiatodict.get_bool("sub_menu_is_completed")
            )
        )
        macchiato.add_child(
            Node.s32("compensation_milk", macchiatodict.get_int("compensation_milk"))
        )
        macchiato.add_child(Node.s32("match_cnt", macchiatodict.get_int("match_cnt")))

        # Probably never will support this
        macchiato_music_list = Node.void("macchiato_music_list")
        macchiato.add_child(macchiato_music_list)
        macchiato_music_list.set_attribute("count", "0")

        # Same with this comment
        macchiato.add_child(Node.s32("sub_pack_id", 0))
        sub_macchiato_music_list = Node.void("sub_macchiato_music_list")
        macchiato.add_child(sub_macchiato_music_list)
        sub_macchiato_music_list.set_attribute("count", "0")

        # And this
        season_music_list = Node.void("season_music_list")
        macchiato.add_child(season_music_list)
        season_music_list.set_attribute("count", "0")

        # Weird, this is sent as a void with a bunch of subnodes, but returned as an int array.
        achievement_list = Node.void("achievement_list")
        macchiato.add_child(achievement_list)
        achievement_list.set_attribute("count", "0")

        # Also probably never supporting this either
        cow_list = Node.void("cow_list")
        macchiato.add_child(cow_list)
        cow_list.set_attribute("count", "0")

        # No news, ever.
        news = Node.void("news")
        player.add_child(news)
        news.add_child(Node.s16("checked", 0))

        # Add rivals to profile.
        rivallist = Node.void("rivallist")
        player.add_child(rivallist)

        links = self.data.local.user.get_links(self.game, self.version, userid)
        rivalcount = 0
        for link in links:
            if link.type != "rival":
                continue

            rprofile = self.get_profile(link.other_userid)
            if rprofile is None:
                continue

            rival = Node.void("rival")
            rivallist.add_child(rival)
            rival.add_child(Node.s32("jid", rprofile.extid))
            rival.add_child(Node.string("name", rprofile.get_str("name")))

            # Lazy way of keeping track of rivals, since we can only have 4
            # or the game with throw up. At least, I think Fulfill can have
            # 4 instead of the 3 found in newer versions, given the size of
            # the array that it loads the values in. However, to keep things
            # simple, I only supported three here.
            rivalcount += 1
            if rivalcount >= 3:
                break

        rivallist.set_attribute("count", str(rivalcount))

        # Full combo daily challenge.
        entry = self.data.local.game.get_time_sensitive_settings(
            self.game, self.version, "fc_challenge"
        )
        if entry is None:
            entry = ValidatedDict()

        # Figure out if we've played these songs
        start_time, end_time = self.data.local.network.get_schedule_duration("daily")
        today_attempts = self.data.local.music.get_all_attempts(
            self.game,
            self.music_version,
            userid,
            entry.get_int("today", -1),
            timelimit=start_time,
        )
        whim_attempts = self.data.local.music.get_all_attempts(
            self.game,
            self.music_version,
            userid,
            entry.get_int("whim", -1),
            timelimit=start_time,
        )

        challenge = Node.void("challenge")
        player.add_child(challenge)
        today = Node.void("today")
        challenge.add_child(today)
        today.add_child(Node.s32("music_id", entry.get_int("today", -1)))
        today.add_child(Node.u8("state", 0x40 if len(today_attempts) > 0 else 0x0))
        whim = Node.void("whim")
        challenge.add_child(whim)
        whim.add_child(Node.s32("music_id", entry.get_int("whim", -1)))
        whim.add_child(Node.u8("state", 0x40 if len(whim_attempts) > 0 else 0x0))

        # Sane defaults for unknown nodes
        only_now_music = Node.void("only_now_music")
        player.add_child(only_now_music)
        only_now_music.set_attribute("count", "0")
        lab_edit_seq = Node.void("lab_edit_seq")
        player.add_child(lab_edit_seq)
        lab_edit_seq.set_attribute("count", "0")
        kac_music = Node.void("kac_music")
        player.add_child(kac_music)
        kac_music.set_attribute("count", "0")
        history = Node.void("history")
        player.add_child(history)
        history.set_attribute("count", "0")
        share_music = Node.void("share_music")
        player.add_child(share_music)
        share_music.set_attribute("count", "0")
        bonus_music = Node.void("bonus_music")
        player.add_child(bonus_music)
        bonus_music.set_attribute("count", "0")

        bingo = Node.void("bingo")
        player.add_child(bingo)
        reward = Node.void("reward")
        bingo.add_child(reward)
        reward.add_child(Node.s32("total", 0))
        reward.add_child(Node.s32("point", 0))
        group = Node.void("group")
        player.add_child(group)
        group.add_child(Node.s32("group_id", 0))

        # Basic profile info
        player.add_child(Node.string("name", profile.get_str("name", "なし")))
        player.add_child(Node.s32("jid", profile.extid))
        player.add_child(Node.string("refid", profile.refid))

        # Miscelaneous history stuff
        data.add_child(Node.u8("termver", 16))
        data.add_child(Node.u32("season_etime", 0))
        data.add_child(
            Node.s32_array(
                "white_music_list",
                [
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                ],
            )
        )
        data.add_child(
            Node.s32_array(
                "open_music_list",
                [
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                    -1,
                ],
            )
        )

        # Unsupported collaboration events with other games
        collabo_info = Node.void("collabo_info")
        data.add_child(collabo_info)

        # Unsupported policy break stuff
        policy_break = Node.void("policy_break")
        collabo_info.add_child(policy_break)
        policy_break.set_attribute("type", "1")
        policy_break.add_child(Node.u8("state", 1))
        policy_break.add_child(Node.bool("is_report_end", False))

        # Unsupported vocaloid stuff
        vocaloid_event = Node.void("vocaloid_event")
        collabo_info.add_child(vocaloid_event)
        vocaloid_event.set_attribute("type", "1")
        vocaloid_event.add_child(Node.u8("state", 0))
        vocaloid_event.add_child(Node.s32("music_id", 0))

        # Unsupported vocaloid stuff
        vocaloid_event2 = Node.void("vocaloid_event2")
        collabo_info.add_child(vocaloid_event2)
        vocaloid_event2.set_attribute("type", "1")
        vocaloid_event2.add_child(Node.u8("state", 0))
        vocaloid_event2.add_child(Node.s32("music_id", 0))

        # Maybe it is possible to turn off internet matching here?
        lab = Node.void("lab")
        data.add_child(lab)
        lab.add_child(Node.bool("is_open", False))
        matching_off = Node.void("matching_off")
        data.add_child(matching_off)
        matching_off.add_child(Node.bool("is_open", True))

        return root

    def unformat_profile(
        self, userid: UserID, request: Node, oldprofile: Profile
    ) -> Profile:
        newprofile = oldprofile.clone()
        newprofile.replace_bool("saved", True)
        data = request.child("data")

        # Figure out if we're force-unlocking songs. If we are, we don't want to persist
        # secret stuff otherwise the game will accidentally unlock everything in the profile.
        game_config = self.get_game_config()
        force_unlock = game_config.get_bool("force_song_unlock")

        # Grab player information
        player = data.child("player")

        # Grab last information. Lots of this will be filled in while grabbing scores
        last = newprofile.get_dict("last")
        last.replace_int("play_time", player.child_value("time_gameend"))
        last.replace_str("shopname", player.child_value("shopname"))
        last.replace_str("areaname", player.child_value("areaname"))

        # Grab player info for echoing back
        info = player.child("info")
        if info is not None:
            newprofile.replace_int("jubility", info.child_value("jubility"))
            newprofile.replace_int("jubility_yday", info.child_value("jubility_yday"))
            newprofile.replace_int("tune_cnt", info.child_value("tune_cnt"))
            newprofile.replace_int("save_cnt", info.child_value("save_cnt"))
            newprofile.replace_int("saved_cnt", info.child_value("saved_cnt"))
            newprofile.replace_int("fc_cnt", info.child_value("fc_cnt"))
            newprofile.replace_int(
                "ex_cnt", info.child_value("exc_cnt")
            )  # Not a mistake, Jubeat is weird
            newprofile.replace_int("pf_cnt", info.child_value("pf_cnt"))
            newprofile.replace_int("clear_cnt", info.child_value("clear_cnt"))
            newprofile.replace_int("match_cnt", info.child_value("match_cnt"))
            newprofile.replace_int("beat_cnt", info.child_value("beat_cnt"))
            newprofile.replace_int(
                "total_best_score", info.child_value("total_best_score")
            )
            newprofile.replace_int("mynews_cnt", info.child_value("mynews_cnt"))
            newprofile.replace_int("extra_point", info.child_value("extra_point"))
            newprofile.replace_bool(
                "is_extra_played", info.child_value("is_extra_played")
            )

            last.replace_int("expert_option", info.child_value("expert_option"))
            last.replace_int("matching", info.child_value("matching"))
            last.replace_int("hazard", info.child_value("hazard"))
            last.replace_int("hard", info.child_value("hard"))

        # Grab unlock progress
        item = player.child("item")
        if item is not None:
            newprofile.replace_int_array(
                "title_list", 96, item.child_value("title_list")
            )
            newprofile.replace_int("theme_list", item.child_value("theme_list"))
            newprofile.replace_int_array(
                "marker_list", 2, item.child_value("marker_list")
            )
            newprofile.replace_int_array(
                "parts_list", 96, item.child_value("parts_list")
            )
            newprofile.replace_int_array(
                "title_list_new", 96, item.child_value("title_new")
            )
            newprofile.replace_int("theme_list_new", item.child_value("theme_new"))
            newprofile.replace_int_array(
                "marker_list_new", 2, item.child_value("marker_new")
            )

            if not force_unlock:
                # Don't persist if we're force-unlocked, this data will be bogus.
                owned_songs = self.calculate_owned_items(
                    item.child_value("secret_list")
                )
                for index in owned_songs:
                    self.data.local.user.put_achievement(
                        self.game,
                        self.version,
                        userid,
                        index,
                        "song",
                        {},
                    )

                owned_secrets = self.calculate_owned_items(
                    item.child_value("secret_new")
                )
                for index in owned_secrets:
                    self.data.local.user.put_achievement(
                        self.game,
                        self.version,
                        userid,
                        index,
                        "secret",
                        {},
                    )

        # Grab macchiato event
        macchiatodict = newprofile.get_dict("macchiato")
        macchiato = player.child("macchiato")
        if macchiato is not None:
            macchiatodict.replace_int("pack_id", macchiato.child_value("pack_id"))
            macchiatodict.replace_int("bean_num", macchiato.child_value("bean_num"))
            macchiatodict.replace_int(
                "daily_milk_num", macchiato.child_value("daily_milk_num")
            )
            macchiatodict.replace_bool(
                "is_received_daily_milk",
                macchiato.child_value("is_received_daily_milk"),
            )
            macchiatodict.replace_bool(
                "sub_menu_is_completed", macchiato.child_value("sub_menu_is_completed")
            )
            macchiatodict.replace_int(
                "today_tune_cnt", macchiato.child_value("today_tune_cnt")
            )
            macchiatodict.replace_int_array(
                "daily_milk_bonus", 9, macchiato.child_value("daily_milk_bonus")
            )
            macchiatodict.replace_int(
                "compensation_milk", macchiato.child_value("compensation_milk")
            )
            macchiatodict.replace_int("match_cnt", macchiato.child_value("match_cnt"))
            macchiatodict.replace_int("used_bean", macchiato.child_value("used_bean"))
            macchiatodict.replace_int("used_milk", macchiato.child_value("used_milk"))
            macchiatodict.replace_int(
                "daily_play_burst", macchiato.child_value("daily_play_burst")
            )
        newprofile.replace_dict("macchiato", macchiatodict)

        # Get timestamps for played songs
        timestamps: Dict[int, int] = {}
        history = player.child("history")
        if history is not None:
            for tune in history.children:
                if tune.name != "tune":
                    continue
                entry = int(tune.attribute("log_id"))
                ts = int(tune.child_value("timestamp") / 1000)
                timestamps[entry] = ts

        # Grab scores and save those
        result = data.child("result")
        if result is not None:
            for tune in result.children:
                if tune.name != "tune":
                    continue
                result = tune.child("player")

                last.replace_int("marker", tune.child_value("marker"))
                last.replace_int("title", tune.child_value("title"))
                last.replace_int("parts", tune.child_value("parts"))
                last.replace_int("theme", tune.child_value("theme"))
                last.replace_int("sort", tune.child_value("sort"))
                last.replace_int("category", tune.child_value("category"))
                last.replace_int("rank_sort", tune.child_value("rank_sort"))
                last.replace_int("combo_disp", tune.child_value("combo_disp"))

                songid = tune.child_value("music")
                entry = int(tune.attribute("id"))
                timestamp = timestamps.get(entry, Time.now())
                chart = int(result.child("score").attribute("seq"))
                points = result.child_value("score")
                flags = int(result.child("score").attribute("clear"))
                combo = int(result.child("score").attribute("combo"))
                ghost = result.child_value("mbar")

                # Miscelaneous last data for echoing to profile get
                last.replace_int("music_id", songid)
                last.replace_int("seq_id", chart)

                mapping = {
                    self.GAME_FLAG_BIT_CLEARED: self.PLAY_MEDAL_CLEARED,
                    self.GAME_FLAG_BIT_FULL_COMBO: self.PLAY_MEDAL_FULL_COMBO,
                    self.GAME_FLAG_BIT_EXCELLENT: self.PLAY_MEDAL_EXCELLENT,
                    self.GAME_FLAG_BIT_NEARLY_FULL_COMBO: self.PLAY_MEDAL_NEARLY_FULL_COMBO,
                    self.GAME_FLAG_BIT_NEARLY_EXCELLENT: self.PLAY_MEDAL_NEARLY_EXCELLENT,
                }

                # Figure out the highest medal based on bits passed in
                medal = self.PLAY_MEDAL_FAILED
                for bit in mapping:
                    if flags & bit > 0:
                        medal = max(medal, mapping[bit])

                self.update_score(
                    userid, timestamp, songid, chart, points, medal, combo, ghost
                )

        # Grab the course results as well
        course = data.child("course")
        if course is not None:
            courseid = course.child_value("course_id")
            rating = {
                self.GAME_COURSE_RATING_FAILED: self.COURSE_RATING_FAILED,
                self.GAME_COURSE_RATING_BRONZE: self.COURSE_RATING_BRONZE,
                self.GAME_COURSE_RATING_SILVER: self.COURSE_RATING_SILVER,
                self.GAME_COURSE_RATING_GOLD: self.COURSE_RATING_GOLD,
            }[course.child_value("rating")]
            scores = [0] * 5
            for music in course.children:
                if music.name != "music":
                    continue
                index = int(music.attribute("index"))
                scores[index] = music.child_value("score")

            # Save course itself
            self.save_course(userid, courseid, rating, scores)

            # Save the last course ID
            last.replace_int("last_course_id", courseid)

        # Save back last information gleaned from results
        newprofile.replace_dict("last", last)

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

        return newprofile

    def format_scores(
        self, userid: UserID, profile: Profile, scores: List[Score]
    ) -> Node:
        root = Node.void("gametop")
        datanode = Node.void("data")
        root.add_child(datanode)
        player = Node.void("player")
        datanode.add_child(player)
        player.add_child(Node.s32("jid", profile.extid))
        playdata = Node.void("playdata")
        player.add_child(playdata)
        playdata.set_attribute("count", str(len(scores)))

        music = ValidatedDict()
        for score in scores:
            # Ignore festo-and-above chart types.
            if score.chart not in {
                self.CHART_TYPE_BASIC,
                self.CHART_TYPE_ADVANCED,
                self.CHART_TYPE_EXTREME,
            }:
                continue

            data = music.get_dict(str(score.id))
            play_cnt = data.get_int_array("play_cnt", 3)
            clear_cnt = data.get_int_array("clear_cnt", 3)
            clear_flags = data.get_int_array("clear_flags", 3)
            fc_cnt = data.get_int_array("fc_cnt", 3)
            ex_cnt = data.get_int_array("ex_cnt", 3)
            points = data.get_int_array("points", 3)

            # Replace data for this chart type
            play_cnt[score.chart] = score.plays
            clear_cnt[score.chart] = score.data.get_int("clear_count")
            fc_cnt[score.chart] = score.data.get_int("full_combo_count")
            ex_cnt[score.chart] = score.data.get_int("excellent_count")
            points[score.chart] = score.points

            # Format the clear flags
            clear_flags[score.chart] = self.GAME_FLAG_BIT_PLAYED
            if score.data.get_int("clear_count") > 0:
                clear_flags[score.chart] |= self.GAME_FLAG_BIT_CLEARED
            if score.data.get_int("full_combo_count") > 0:
                clear_flags[score.chart] |= self.GAME_FLAG_BIT_FULL_COMBO
            if score.data.get_int("excellent_count") > 0:
                clear_flags[score.chart] |= self.GAME_FLAG_BIT_EXCELLENT

            # Save chart data back
            data.replace_int_array("play_cnt", 3, play_cnt)
            data.replace_int_array("clear_cnt", 3, clear_cnt)
            data.replace_int_array("clear_flags", 3, clear_flags)
            data.replace_int_array("fc_cnt", 3, fc_cnt)
            data.replace_int_array("ex_cnt", 3, ex_cnt)
            data.replace_int_array("points", 3, points)

            # Update the ghost (untyped)
            ghost = data.get("ghost", [None, None, None])
            ghost[score.chart] = score.data.get("ghost")
            data["ghost"] = ghost

            # Save it back
            music.replace_dict(str(score.id), data)

        for scoreid in music:
            scoredata = music[scoreid]
            musicdata = Node.void("musicdata")
            playdata.add_child(musicdata)

            musicdata.set_attribute("music_id", scoreid)
            musicdata.add_child(
                Node.s32_array("play_cnt", scoredata.get_int_array("play_cnt", 3))
            )
            musicdata.add_child(
                Node.s32_array("clear_cnt", scoredata.get_int_array("clear_cnt", 3))
            )
            musicdata.add_child(
                Node.s32_array("fc_cnt", scoredata.get_int_array("fc_cnt", 3))
            )
            musicdata.add_child(
                Node.s32_array("ex_cnt", scoredata.get_int_array("ex_cnt", 3))
            )
            musicdata.add_child(
                Node.s32_array("score", scoredata.get_int_array("points", 3))
            )
            musicdata.add_child(
                Node.s8_array("clear", scoredata.get_int_array("clear_flags", 3))
            )

            ghosts = scoredata.get("ghost", [None, None, None])
            for i in range(len(ghosts)):
                ghost = ghosts[i]
                if ghost is None:
                    continue

                bar = Node.u8_array("bar", ghost)
                musicdata.add_child(bar)
                bar.set_attribute("seq", str(i))

        return root