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

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


class JubeatProp(
    JubeatDemodataGetHitchartHandler,
    JubeatDemodataGetNewsHandler,
    JubeatGamendRegisterHandler,
    JubeatGametopGetMeetingHandler,
    JubeatLobbyCheckHandler,
    JubeatLoggerReportHandler,
    JubeatCourse,
    JubeatBase,
):

    name: str = 'Jubeat Prop'
    version: int = VersionConstants.JUBEAT_PROP

    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

    JBOX_EMBLEM_NORMAL: Final[int] = 1
    JBOX_EMBLEM_PREMIUM: Final[int] = 2

    EVENTS: Dict[int, Dict[str, bool]] = {
        5: {
            'enabled': False,
        },
        6: {
            'enabled': False,
        },
        9: {
            'enabled': False,
        },
        14: {
            'enabled': False,
        },
        15: {
            'enabled': False,
        },
        16: {
            'enabled': False,
        },
        17: {
            'enabled': False,
        },
        18: {
            'enabled': False,
        },
        19: {
            'enabled': False,
        },
    }

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

    @classmethod
    def __class_to_rank(cls, cur_class: int, cur_subclass: int) -> int:
        """
        Given a class and subclass, return an integer rank for that class.

        Class mapping is as follows:
            1 - Amateur
            2 - Regular
            3 - Master
            4 - Legend

        Subclass ranges from 1 to 5, except on Legend where it is 1 only.
        """
        if cur_subclass > 5:
            cur_subclass = 5
        if cur_subclass < 1:
            cur_subclass = 1
        if cur_class > 4:
            cur_class = 4
        if cur_class < 1:
            cur_class = 1

        lut = {
            1: {
                5: 0,
                4: 1,
                3: 2,
                2: 3,
                1: 4,
            },
            2: {
                5: 5,
                4: 6,
                3: 7,
                2: 8,
                1: 9,
            },
            3: {
                5: 10,
                4: 11,
                3: 12,
                2: 13,
                1: 14,
            },
            # Legend only has one sub-class value (1), so to make range checks
            # easier, just map all 5 possible values to the same integer.
            4: {
                5: 15,
                4: 15,
                3: 15,
                2: 15,
                1: 15,
            },
        }
        return lut[cur_class][cur_subclass]

    @classmethod
    def __rank_to_class(cls, rank: int) -> Tuple[int, int]:
        """
        Given a rank, return a tuple representing class, subclass. This function
        is the inverse of __class_to_rank.
        """
        if rank < 0:
            rank = 0
        if rank > 15:
            rank = 15

        lut = {
            0: (1, 5),
            1: (1, 4),
            2: (1, 3),
            3: (1, 2),
            4: (1, 1),
            5: (2, 5),
            6: (2, 4),
            7: (2, 3),
            8: (2, 2),
            9: (2, 1),
            10: (3, 5),
            11: (3, 4),
            12: (3, 3),
            13: (3, 2),
            14: (3, 1),
            15: (4, 1),
        }
        return lut[rank]

    @classmethod
    def _increment_class(cls, cur_class: int, cur_subclass: int) -> Tuple[int, int]:
        """
        Given a class and subclass, return a tuple representing the next
        class/subclass if we were to be promoted.
        """
        return cls.__rank_to_class(cls.__class_to_rank(cur_class, cur_subclass) + 1)

    @classmethod
    def _decrement_class(cls, cur_class: int, cur_subclass: int) -> Tuple[int, int]:
        """
        Given a class and subclass, return a tuple representing the previous
        class/subclass if we were to be demoted.
        """
        return cls.__rank_to_class(cls.__class_to_rank(cur_class, cur_subclass) - 1)

    @classmethod
    def _get_league_buckets(cls, scores: List[Tuple[UserID, int]]) -> Tuple[List[UserID], List[UserID], List[UserID]]:
        """
        Given a list of userid, score tuples, return a tuple containing three lists.
        The first list is the top 30% scorer IDs, the next list is the middle 40%
        scorer IDs, and the final list is the bottom 30% scorer IDs.
        """
        sorted_scores = sorted(scores, key=lambda x: x[1], reverse=True)

        # Top 30% get promoted
        promoted_amount = math.ceil(len(sorted_scores) * 0.3)
        promotions = [x[0] for x in sorted_scores[:promoted_amount]]
        rest = sorted_scores[promoted_amount:]

        # Bottom 30% get demoted (this is bottom 3/7 of the rest)
        demoted_amount = math.ceil(len(rest) * 0.42)
        demotions = [x[0] for x in rest[-demoted_amount:]]
        neutrals = [x[0] for x in rest[:-demoted_amount]]

        return (promotions, neutrals, demotions)

    @classmethod
    def _get_league_scores(cls, data: Data, current_id: int, profiles: List[Tuple[UserID, Profile]]) -> Tuple[List[Tuple[UserID, int]], List[UserID]]:
        """
        Given the current League ID (calculated based on the date range) and a list of
        all user profiles for this game/version, return a uple containing two lists.
        The first list should contain tuples where the first integer is a user ID and
        the second integer is the user's total score for last week's course. The second
        list is a list of user IDs that did not participate last week but have played
        this game at some point.
        """
        last_id = current_id - 1

        scores = []
        absentees = []
        for [userid, _player] in profiles:
            # Look up scores for last week if they played
            league_score = data.local.user.get_achievement(
                cls.game,
                cls.version,
                userid,
                last_id,
                'league',
            )

            # If they played, grab their total score so we can figure out if we should
            # promote, demote or leave alone
            if league_score is not None:
                scores.append((
                    userid,
                    league_score['score'][0] +
                    league_score['score'][1] +
                    league_score['score'][2],
                ))
            else:
                absentees.append(userid)

        return scores, absentees

    @classmethod
    def _get_league_absentees(cls, data: Data, current_id: int, absentees: List[UserID]) -> List[UserID]:
        """
        Given a list of user IDs that didn't play for some number of weeks, return
        a subset of those IDs that have been absent enough weeks to get a demotion.
        Demotions happen for every two weeks without play.
        """
        delinquents = []
        for userid in absentees:
            # Figure out the last time they played, if its an even boundary
            # and at least 2 weeks back, demote them (one demotion for every
            # two weeks not played).
            last_league_id = 0
            for achievement in data.local.user.get_achievements(
                cls.game,
                cls.version,
                userid,
            ):
                if achievement.type == 'league':
                    last_league_id = max(achievement.id, last_league_id)

            if last_league_id != 0:
                # If they played mid-week two IDs ago, that's not quite
                # two weeks back, so adjust by one.
                weeks_different = (current_id - last_league_id) - 1

                if weeks_different >= 2 and weeks_different % 2 == 0:
                    # It's been at least two weeks (or four, or six), which means
                    # there have been two weeks since the last time we did this,
                    # demote this person.
                    delinquents.append(userid)

        return delinquents

    @classmethod
    def _modify_profile(cls, data: Data, userid: UserID, direction: str) -> None:
        """
        Given a user ID and a direction (promote or demote), load the user's profile,
        make the necessary promotion/demotion, and set the profile to notify the user
        on next play that they have lost/gained rank. If the user still hasn't checked
        their rank since last time we changed it, make sure they know about multiple
        promotions/demotions.
        """
        profile = data.local.user.get_profile(cls.game, cls.version, userid)
        cur_class = profile.get_int('league_class', 1)
        cur_subclass = profile.get_int('league_subclass', 5)

        if direction == 'promote':
            new_class, new_subclass = cls._increment_class(cur_class, cur_subclass)
        elif direction == 'demote':
            new_class, new_subclass = cls._decrement_class(cur_class, cur_subclass)
        else:
            raise Exception(f'Logic error, unknown direction {direction}!')

        if new_class != cur_class or new_subclass != cur_subclass:
            # If they've checked last time, set up the new old class.
            if profile.get_bool('league_is_checked'):
                last = profile.get_dict('last')
                last.replace_int('league_class', cur_class)
                last.replace_int('league_subclass', cur_subclass)
                profile.replace_dict('last', last)
            # We actually changed a level, let the user know!
            profile.replace_int('league_class', new_class)
            profile.replace_int('league_subclass', new_subclass)
            profile.replace_bool('league_is_checked', False)
            data.local.user.put_profile(cls.game, cls.version, userid, profile)

    @classmethod
    def run_scheduled_work(cls, data: Data, config: Dict[str, Any]) -> List[Tuple[str, Dict[str, Any]]]:
        """
        Once a week, insert a new league course. Every day, insert new FC challenge courses.
        """
        events = []
        if data.local.network.should_schedule(cls.game, cls.version, 'league_course', 'weekly'):
            # Generate a new league course list, save it to the DB.
            start_time, end_time = data.local.network.get_schedule_duration('weekly')
            all_songs = set(song.id for song in data.local.music.get_all_songs(cls.game, cls.version))
            if len(all_songs) >= 3:
                league_songs = random.sample(all_songs, 3)
                data.local.game.put_time_sensitive_settings(
                    cls.game,
                    cls.version,
                    'league',
                    {
                        'start_time': start_time,
                        'end_time': end_time,
                        'music': league_songs,
                    },
                )
                events.append((
                    'jubeat_league_course',
                    {
                        'version': cls.version,
                        'songs': league_songs,
                    },
                ))

                # League ID for the current league we just added.
                leagueid = int(start_time / 604800)

                # Evaluate player scores on previous courses and find players
                # that didn't play last week.
                all_profiles = data.local.user.get_all_profiles(cls.game, cls.version)
                scores, absentees = cls._get_league_scores(data, leagueid, all_profiles)

                # Get user IDs to promote, demote and ignore based on scores.
                promote, ignore, demote = cls._get_league_buckets(scores)
                demote.extend(cls._get_league_absentees(data, leagueid, absentees))

                # Actually modify the profiles so the game knows to tell the user.
                for userid in promote:
                    cls._modify_profile(data, userid, 'promote')
                for userid in demote:
                    cls._modify_profile(data, userid, 'demote')

                # Mark that we did some actual work here.
                data.local.network.mark_scheduled(cls.game, cls.version, 'league_course', 'weekly')

        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

    def __get_global_info(self) -> Node:
        info = Node.void('info')

        # Event info. Valid event IDs are 5, 6, 9, 14, 15, 16, 17, 18, 19
        event_info = Node.void('event_info')
        info.add_child(event_info)
        for event in self.EVENTS:
            evt = Node.void('event')
            event_info.add_child(evt)
            evt.set_attribute('type', str(event))
            evt.add_child(Node.u8('state', 0x1 if self.EVENTS[event]['enabled'] else 0x0))

        # Each of the following three sections should have zero or more child nodes (no
        # particular name) which look like the following:
        #     <node>
        #         <id __type="s32">songid</id>
        #         <stime __type="str">start time?</stime>
        #         <etime __type="str">end time?</etime>
        #     </node>
        # Share music?
        share_music = Node.void('share_music')
        info.add_child(share_music)

        # Bonus music?
        bonus_music = Node.void('bonus_music')
        info.add_child(bonus_music)

        # Only now music?
        only_now_music = Node.void('only_now_music')
        info.add_child(only_now_music)

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

        fc_challenge = Node.void('fc_challenge')
        info.add_child(fc_challenge)
        today = Node.void('today')
        fc_challenge.add_child(today)
        today.add_child(Node.s32('music_id', entry.get_int('today', -1)))

        # Some sort of music DB whitelist
        info.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,
            ],
        ))

        info.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,
            ],
        ))

        cabinet_survey = Node.void('cabinet_survey')
        info.add_child(cabinet_survey)
        cabinet_survey.add_child(Node.s32('id', -1))
        cabinet_survey.add_child(Node.s32('status', 0))

        kaitou_bisco = Node.void('kaitou_bisco')
        info.add_child(kaitou_bisco)
        kaitou_bisco.add_child(Node.s32('remaining_days', 0))

        league = Node.void('league')
        info.add_child(league)
        league.add_child(Node.u8('status', 1))

        bistro = Node.void('bistro')
        info.add_child(bistro)
        bistro.add_child(Node.u16('bistro_id', 0))

        jbox = Node.void('jbox')
        info.add_child(jbox)
        jbox.add_child(Node.s32('point', 0))
        emblem = Node.void('emblem')
        jbox.add_child(emblem)
        normal = Node.void('normal')
        emblem.add_child(normal)
        premium = Node.void('premium')
        emblem.add_child(premium)
        normal.add_child(Node.s16('index', 2))
        premium.add_child(Node.s16('index', 1))

        return info

    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('tax_phase', 1))

        facility = Node.void('facility')
        data.add_child(facility)
        facility.add_child(Node.u32('exist', 1))

        data.add_child(self.__get_global_info())

        return shopinfo

    def handle_gametop_regist_request(self, request: Node) -> Node:
        data = request.child('data')
        player = data.child('player')
        refid = player.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')
        refid = player.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('mdata_list')
            player.add_child(playdata)
            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_info_request(self, request: Node) -> Node:
        root = Node.void('gametop')
        data = Node.void('data')
        root.add_child(data)
        data.add_child(self.__get_global_info())

        return root

    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] = []
        courses = self.get_all_courses()
        courses.extend([
            {
                'id': 31,
                'name': 'Enjoy! The 5th KAC ~ tracks of prop ~',
                'level': 5,
                'music': [
                    (60000065, 1),
                    (60000008, 1),
                    (60000001, 1),
                    (60001009, 1),
                    (60000010, 1),
                ],
                'requirements': {
                    self.COURSE_REQUIREMENT_SCORE: [900000, 950000, 980000],
                    self.COURSE_REQUIREMENT_FULL_COMBO: [1, 2, 4],
                },
            },
            {
                'id': 32,
                'name': 'Challenge! The 5th KAC ~ tracks of prop ~',
                'level': 7,
                'music': [
                    (60000065, 2),
                    (60000008, 2),
                    (60000001, 2),
                    (60001009, 2),
                    (60000010, 2),
                ],
                'requirements': {
                    self.COURSE_REQUIREMENT_SCORE: [900000, 950000, 980000],
                },
            },
            {
                'id': 33,
                'name': 'The 5th KAC ~ tracks of prop ~',
                'level': 10,
                'music': [
                    (60000065, 2),
                    (60000008, 2),
                    (60000001, 2),
                    (60001009, 2),
                    (60000010, 2),
                ],
                'requirements': {
                    self.COURSE_REQUIREMENT_SCORE: [920000, 950000, 980000],
                },
            },
        ])

        for course in 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
        data.add_child(Node.s32('last_course_id', profile.get_dict('last').get_int('last_course_id', -1)))

        return gametop

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

        # 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)

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

        league_list = Node.void('league_list')
        data.add_child(league_list)

        # Look up the current league charts in the DB
        entry = self.data.local.game.get_time_sensitive_settings(self.game, self.version, 'league')
        if entry is not None:
            # Just get the week number, use that as the ID
            leagueid = int(entry['start_time'] / 604800)

            league = Node.void('league')
            league_list.add_child(league)
            league.set_attribute('index', '0')

            league.add_child(Node.s32('id', leagueid))
            league.add_child(Node.u64('stime', entry['start_time'] * 1000))
            league.add_child(Node.u64('etime', entry['end_time'] * 1000))

            music_list = Node.void('music_list')
            league.add_child(music_list)

            # We need to know the player class so we can determine what chart to present.
            current_class = profile.get_int('league_class', 1)

            song_index = 0
            for song in entry['music']:
                music = Node.void('music')
                music_list.add_child(music)
                music.set_attribute('index', str(song_index))
                song_index = song_index + 1

                music.add_child(Node.s32('music_id', song))
                music.add_child(Node.u8('seq', 1 if current_class == 1 else 2))

            player_list = Node.void('player_list')
            league.add_child(player_list)

            player = Node.void('player')
            player_list.add_child(player)
            player.add_child(Node.s32('jid', extid))
            result = Node.void('result')
            player.add_child(result)

            league_score = self.data.local.user.get_achievement(self.game, self.version, userid, leagueid, 'league')
            if league_score is None:
                league_score = ValidatedDict()

            result.add_child(Node.s32_array('score', league_score.get_int_array('score', 3, [0] * 3)))
            result.add_child(Node.s8_array('clear', league_score.get_int_array('clear', 3, [0] * 3)))

        data.add_child(Node.s32('last_class', profile.get_dict('last').get_int('league_class', 1)))
        data.add_child(Node.s32('last_subclass', profile.get_dict('last').get_int('league_subclass', 5)))
        data.add_child(Node.bool('is_checked', profile.get_bool('league_is_checked')))

        return gametop

    def format_profile(self, userid: UserID, profile: Profile) -> Node:
        root = Node.void('gametop')
        data = Node.void('data')
        root.add_child(data)
        # Jubeat Prop appears to allow full event overrides per-player
        data.add_child(self.__get_global_info())

        player = Node.void('player')
        data.add_child(player)

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

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

        # 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('clear_cnt', profile.get_int('clear_cnt')))
        info.add_child(Node.s32('pf_cnt', profile.get_int('pf_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('bonus_tune_points', profile.get_int('bonus_tune_points')))
        info.add_child(Node.bool('is_bonus_tune_played', profile.get_bool('is_bonus_tune_played')))

        # 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')))

        # 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))

        # 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.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('category', lastdict.get_int('category')))
        last.add_child(Node.s8('sort', lastdict.get_int('sort')))
        last.add_child(Node.s32('music_id', lastdict.get_int('music_id')))
        last.add_child(Node.s8('seq_id', lastdict.get_int('seq_id')))

        settings = Node.void('settings')
        last.add_child(settings)
        settings.add_child(Node.s8('marker', lastdict.get_int('marker')))
        settings.add_child(Node.s8('theme', lastdict.get_int('theme')))
        settings.add_child(Node.s16('title', lastdict.get_int('title')))
        settings.add_child(Node.s16('parts', lastdict.get_int('parts')))
        settings.add_child(Node.s8('rank_sort', lastdict.get_int('rank_sort')))
        settings.add_child(Node.s8('combo_disp', lastdict.get_int('combo_disp')))
        settings.add_child(Node.s16_array('emblem', lastdict.get_int_array('emblem', 5)))
        settings.add_child(Node.s8('matching', lastdict.get_int('matching')))
        settings.add_child(Node.s8('hazard', lastdict.get_int('hazard')))
        settings.add_child(Node.s8('hard', lastdict.get_int('hard')))

        # Secret unlocks
        item = Node.void('item')
        player.add_child(item)
        item.add_child(Node.s32_array('music_list', profile.get_int_array('music_list', 32, [-1] * 32)))
        item.add_child(Node.s32_array('secret_list', profile.get_int_array('secret_list', 32, [-1] * 32)))
        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('title_list', profile.get_int_array('title_list', 160, [-1] * 160)))
        item.add_child(Node.s32_array('parts_list', profile.get_int_array('parts_list', 160, [-1] * 160)))
        item.add_child(Node.s32_array('emblem_list', profile.get_int_array('emblem_list', 96, [-1] * 96)))

        new = Node.void('new')
        item.add_child(new)
        new.add_child(Node.s32_array('secret_list', profile.get_int_array('secret_list_new', 32, [-1] * 32)))
        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)))

        # Sane defaults for unknown/who cares nodes
        history = Node.void('history')
        player.add_child(history)
        history.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')
        cabinet_survey = Node.void('cabinet_survey')
        player.add_child(cabinet_survey)
        cabinet_survey.add_child(Node.u32('read_flag', 0))
        kaitou_bisco = Node.void('kaitou_bisco')
        player.add_child(kaitou_bisco)
        kaitou_bisco.add_child(Node.u32('read_flag', profile.get_int('kaitou_bisco_read_flag')))
        navi = Node.void('navi')
        player.add_child(navi)
        navi.add_child(Node.u32('flag', profile.get_int('navi_flag')))

        # Player status for events
        event_info = Node.void('event_info')
        player.add_child(event_info)
        achievements = self.data.local.user.get_achievements(self.game, self.version, userid)
        for achievement in achievements:
            if achievement.type == 'event':
                # There are two significant bits here, 0x1 and 0x2, I think the first
                # one is whether the event is started, second is if its finished?
                event = Node.void('event')
                event_info.add_child(event)
                event.set_attribute('type', str(achievement.id))

                state = 0x0
                state = state + 0x2 if achievement.data.get_bool('is_completed') else 0x0
                event.add_child(Node.u8('state', state))

        # Full combo 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.version, userid, entry.get_int('today', -1), timelimit=start_time)
        whim_attempts = self.data.local.music.get_all_attempts(self.game, self.version, userid, entry.get_int('whim', -1), timelimit=start_time)

        fc_challenge = Node.void('fc_challenge')
        player.add_child(fc_challenge)
        today = Node.void('today')
        fc_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')
        fc_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))

        # No news, ever.
        news = Node.void('news')
        player.add_child(news)
        news.add_child(Node.s16('checked', 0))
        news.add_child(Node.u32('checked_flag', 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')))

            rcareerdict = rprofile.get_dict('career')
            career = Node.void('career')
            rival.add_child(career)
            career.add_child(Node.s16('level', rcareerdict.get_int('level', 1)))

            league = Node.void('league')
            rival.add_child(league)
            league.add_child(Node.bool('is_first_play', rprofile.get_bool('league_is_first_play', True)))
            league.add_child(Node.s32('class', rprofile.get_int('league_class', 1)))
            league.add_child(Node.s32('subclass', rprofile.get_int('league_subclass', 5)))

            # Lazy way of keeping track of rivals, since we can only have 3
            # or the game with throw up.
            rivalcount += 1
            if rivalcount >= 3:
                break

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

        # Nothing in life is free, WTF?
        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))
        come_come_jbox = Node.void('come_come_jbox')
        free_first_play.add_child(come_come_jbox)
        come_come_jbox.add_child(Node.bool('is_valid', False))
        come_come_jbox.add_child(Node.s64('end_time_if_paired', 0))

        # JBox stuff
        jbox = Node.void('jbox')
        jboxdict = profile.get_dict('jbox')
        player.add_child(jbox)
        jbox.add_child(Node.s32('point', jboxdict.get_int('point')))
        emblem = Node.void('emblem')
        jbox.add_child(emblem)
        normal = Node.void('normal')
        emblem.add_child(normal)
        premium = Node.void('premium')
        emblem.add_child(premium)

        # Calculate a random index for normal and premium to give to player
        # as a gatcha.
        gameitems = self.data.local.game.get_items(self.game, self.version)
        normalemblems: Set[int] = set()
        premiumemblems: Set[int] = set()
        for gameitem in gameitems:
            if gameitem.type == 'emblem':
                if gameitem.data.get_int('rarity') in {1, 2, 3}:
                    normalemblems.add(gameitem.id)
                if gameitem.data.get_int('rarity') in {3, 4, 5}:
                    premiumemblems.add(gameitem.id)

        # Default to some emblems in case the catalog is not available.
        normalindex = 2
        premiumindex = 1
        if normalemblems:
            normalindex = random.sample(normalemblems, 1)[0]
        if premiumemblems:
            premiumindex = random.sample(premiumemblems, 1)[0]

        normal.add_child(Node.s16('index', normalindex))
        premium.add_child(Node.s16('index', premiumindex))

        # Career stuff
        career = Node.void('career')
        careerdict = profile.get_dict('career')
        player.add_child(career)
        career.add_child(Node.s16('level', careerdict.get_int('level', 1)))
        career.add_child(Node.s32('point', careerdict.get_int('point')))
        career.add_child(Node.s32_array('param', careerdict.get_int_array('param', 10, [-1] * 10)))
        career.add_child(Node.bool('is_unlocked', careerdict.get_bool('is_unlocked')))

        # League stuff
        league = Node.void('league')
        player.add_child(league)
        league.add_child(Node.bool('is_first_play', profile.get_bool('league_is_first_play', True)))
        league.add_child(Node.s32('class', profile.get_int('league_class', 1)))
        league.add_child(Node.s32('subclass', profile.get_int('league_subclass', 5)))

        # New Music stuff
        new_music = Node.void('new_music')
        player.add_child(new_music)

        # Emblem list stuff?
        eapass_privilege = Node.void('eapass_privilege')
        player.add_child(eapass_privilege)
        emblem_list = Node.void('emblem_list')
        eapass_privilege.add_child(emblem_list)

        # Bonus music stuff?
        bonus_music = Node.void('bonus_music')
        player.add_child(bonus_music)
        bonus_music.add_child(Node.void('music'))
        bonus_music.add_child(Node.s32('event_id', -1))
        bonus_music.add_child(Node.string('till_time', ''))

        # Bistro stuff is back?
        bistro = Node.void('bistro')
        player.add_child(bistro)
        chef = Node.void('chef')
        bistro.add_child(chef)
        chef.add_child(Node.s32('id', 1))
        bistro.add_child(Node.s32('carry_over', 0))
        route_list = Node.void('route_list')
        bistro.add_child(route_list)
        route_list.add_child(Node.u8('route_count', 0))
        # If we have routes, they look like this:
        #     <route>
        #         <no __type="u8">#</no>
        #         <content kind="?">
        #             <value __type="s32">??</value>
        #         </content>
        #         <gourmates>
        #             <id __type="s32">??</id>
        #         </gourmates>
        bistro.add_child(Node.bool('extension', False))

        # Gift list, maybe from other players?
        gift_list = Node.void('gift_list')
        player.add_child(gift_list)
        # If we had gifts, they look like this:
        #     <gift reason="??" kind="??">
        #         <id __type="s32">??</id>
        #     </gift>

        return root

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

        # Grab system information
        sysinfo = data.child('info')

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

        # Grab result information
        result = data.child('result')

        # Grab last information. Lots of this will be filled in while grabbing scores
        last = newprofile.get_dict('last')
        if sysinfo is not None:
            last.replace_int('play_time', sysinfo.child_value('time_gameend'))
            last.replace_str('shopname', sysinfo.child_value('shopname'))
            last.replace_str('areaname', sysinfo.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('ex_cnt'))
            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('bonus_tune_points', info.child_value('bonus_tune_points'))
            newprofile.replace_bool('is_bonus_tune_played', info.child_value('is_bonus_tune_played'))

        # Grab last settings (finally mostly in its own node!)
        lastnode = player.child('last')
        if lastnode is not None:
            last.replace_int('expert_option', lastnode.child_value('expert_option'))
            last.replace_int('sort', lastnode.child_value('sort'))
            last.replace_int('category', lastnode.child_value('category'))

            settings = lastnode.child('settings')
            if settings is not None:
                last.replace_int('matching', settings.child_value('matching'))
                last.replace_int('hazard', settings.child_value('hazard'))
                last.replace_int('hard', settings.child_value('hard'))
                last.replace_int('marker', settings.child_value('marker'))
                last.replace_int('theme', settings.child_value('theme'))
                last.replace_int('title', settings.child_value('title'))
                last.replace_int('parts', settings.child_value('parts'))
                last.replace_int('rank_sort', settings.child_value('rank_sort'))
                last.replace_int('combo_disp', settings.child_value('combo_disp'))
                last.replace_int_array('emblem', 5, settings.child_value('emblem'))

        # Grab unlock progress
        item = player.child('item')
        if item is not None:
            newprofile.replace_int_array('secret_list', 32, item.child_value('secret_list'))
            newprofile.replace_int_array('title_list', 160, 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', 160, item.child_value('parts_list'))
            newprofile.replace_int_array('music_list', 32, item.child_value('music_list'))
            newprofile.replace_int_array('emblem_list', 96, item.child_value('emblem_list'))

            newitem = item.child('new')
            if newitem is not None:
                newprofile.replace_int_array('secret_list_new', 32, newitem.child_value('secret_list'))
                newprofile.replace_int('theme_list_new', newitem.child_value('theme_list'))
                newprofile.replace_int_array('marker_list_new', 2, newitem.child_value('marker_list'))

        # Career progression
        career = player.child('career')
        careerdict = newprofile.get_dict('career')
        if career is not None:
            careerdict.replace_int('level', career.child_value('level'))
            careerdict.replace_int('point', career.child_value('point'))
            careerdict.replace_int_array('param', 10, career.child_value('param'))
            careerdict.replace_bool('is_unlocked', career.child_value('is_unlocked'))
        newprofile.replace_dict('career', careerdict)

        # jbox stuff
        jbox = player.child('jbox')
        jboxdict = newprofile.get_dict('jbox')
        if jbox is not None:
            jboxdict.replace_int('point', jbox.child_value('point'))
            emblemtype = jbox.child_value('emblem/type')
            index = jbox.child_value('emblem/index')
            if emblemtype == self.JBOX_EMBLEM_NORMAL:
                jboxdict.replace_int('normal_index', index)
            elif emblemtype == self.JBOX_EMBLEM_PREMIUM:
                jboxdict.replace_int('premium_index', index)
        newprofile.replace_dict('jbox', jboxdict)

        # event stuff
        event_info = player.child('event_info')
        if event_info is not None:
            for child in event_info.children:
                try:
                    eventid = int(child.attribute('type'))
                except TypeError:
                    # Event is empty
                    continue
                is_completed = child.child_value('is_completed')

                # Figure out if we should update the rating/scores or not
                oldevent = self.data.local.user.get_achievement(
                    self.game,
                    self.version,
                    userid,
                    eventid,
                    'event',
                )

                if oldevent is None:
                    # Create a new event structure for this
                    oldevent = ValidatedDict()

                oldevent.replace_bool('is_completed', is_completed)

                # Save it as an achievement
                self.data.local.user.put_achievement(
                    self.game,
                    self.version,
                    userid,
                    eventid,
                    'event',
                    oldevent,
                )

        # A whole bunch of miscelaneous shit
        newprofile.replace_int('navi_flag', player.child_value('navi/flag'))
        newprofile.replace_int('kaitou_bisco_read_flag', player.child_value('kaitou_bisco/read_flag'))

        # 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
        if result is not None:
            for tune in result.children:
                if tune.name != 'tune':
                    continue
                result = tune.child('player')

                entry = int(tune.attribute('id'))
                songid = tune.child_value('music')
                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)

        # If this was a course save, grab and save that info too
        course = player.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)

        # If this was a league save, grab and save that info too
        league = player.child('league')
        if league is not None:
            leagueid = league.child_value('league_id')
            newprofile.replace_bool('league_is_checked', league.child_value('is_checked'))
            newprofile.replace_bool('league_is_first_play', league.child_value('is_first_play'))

            # Extract scores
            score = [0] * 3
            clear = [0] * 3
            for music in league.children:
                if music.name != 'music':
                    continue
                index = int(music.attribute('index'))
                scorenode = music.child('score')
                clear[index] = int(scorenode.attribute('clear'))
                score[index] = scorenode.value

            # Update score if it is higher
            oldleague = self.data.local.user.get_achievement(
                self.game,
                self.version,
                userid,
                leagueid,
                'league',
            )
            if oldleague is None:
                oldleague = ValidatedDict()
            oldscore = oldleague.get_int_array('score', 3)
            if sum(oldscore) < sum(score):
                self.data.local.user.put_achievement(
                    self.game,
                    self.version,
                    userid,
                    leagueid,
                    'league',
                    {'score': score, 'clear': clear},
                )

        # 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('mdata_list')
        player.add_child(playdata)

        music = ValidatedDict()
        for score in scores:
            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
