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

from bemani.backend.ess import EventLogHandler
from bemani.backend.museca.base import MusecaBase
from bemani.backend.museca.common import (
    MusecaGameFrozenHandler,
    MusecaGameHiscoreHandler,
    MusecaGameNewHandler,
    MusecaGamePlayEndHandler,
    MusecaGameSaveHandler,
    MusecaGameSaveMusicHandler,
    MusecaGameShopHandler,
)
from bemani.backend.museca.museca1 import Museca1
from bemani.common import VersionConstants, Profile, ID
from bemani.data import UserID
from bemani.protocol import Node


class Museca1Plus(
    EventLogHandler,
    MusecaGameFrozenHandler,
    MusecaGameHiscoreHandler,
    MusecaGameNewHandler,
    MusecaGamePlayEndHandler,
    MusecaGameSaveHandler,
    MusecaGameSaveMusicHandler,
    MusecaGameShopHandler,
    MusecaBase,
):

    name: str = "MÚSECA 1+1/2"
    version: int = VersionConstants.MUSECA_1_PLUS

    GAME_LIMITED_LOCKED: Final[int] = 1
    GAME_LIMITED_UNLOCKABLE: Final[int] = 2
    GAME_LIMITED_UNLOCKED: Final[int] = 3

    GAME_CATALOG_TYPE_SONG: Final[int] = 0
    GAME_CATALOG_TYPE_GRAFICA: Final[int] = 15
    GAME_CATALOG_TYPE_MISSION: Final[int] = 16

    GAME_GRADE_DEATH: Final[int] = 0
    GAME_GRADE_POOR: Final[int] = 1
    GAME_GRADE_MEDIOCRE: Final[int] = 2
    GAME_GRADE_GOOD: Final[int] = 3
    GAME_GRADE_GREAT: Final[int] = 4
    GAME_GRADE_EXCELLENT: Final[int] = 5
    GAME_GRADE_SUPERB: Final[int] = 6
    GAME_GRADE_MASTERPIECE: Final[int] = 7
    GAME_GRADE_PERFECT: Final[int] = 8

    GAME_CLEAR_TYPE_FAILED: Final[int] = 1
    GAME_CLEAR_TYPE_CLEARED: Final[int] = 2
    GAME_CLEAR_TYPE_FULL_COMBO: Final[int] = 4

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

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

    def game_to_db_clear_type(self, clear_type: int) -> int:
        return {
            self.GAME_CLEAR_TYPE_FAILED: self.CLEAR_TYPE_FAILED,
            self.GAME_CLEAR_TYPE_CLEARED: self.CLEAR_TYPE_CLEARED,
            self.GAME_CLEAR_TYPE_FULL_COMBO: self.CLEAR_TYPE_FULL_COMBO,
        }[clear_type]

    def db_to_game_clear_type(self, clear_type: int) -> int:
        return {
            self.CLEAR_TYPE_FAILED: self.GAME_CLEAR_TYPE_FAILED,
            self.CLEAR_TYPE_CLEARED: self.GAME_CLEAR_TYPE_CLEARED,
            self.CLEAR_TYPE_FULL_COMBO: self.GAME_CLEAR_TYPE_FULL_COMBO,
        }[clear_type]

    def game_to_db_grade(self, grade: int) -> int:
        return {
            self.GAME_GRADE_DEATH: self.GRADE_DEATH,
            self.GAME_GRADE_POOR: self.GRADE_POOR,
            self.GAME_GRADE_MEDIOCRE: self.GRADE_MEDIOCRE,
            self.GAME_GRADE_GOOD: self.GRADE_GOOD,
            self.GAME_GRADE_GREAT: self.GRADE_GREAT,
            self.GAME_GRADE_EXCELLENT: self.GRADE_EXCELLENT,
            self.GAME_GRADE_SUPERB: self.GRADE_SUPERB,
            self.GAME_GRADE_MASTERPIECE: self.GRADE_MASTERPIECE,
            self.GAME_GRADE_PERFECT: self.GRADE_PERFECT,
        }[grade]

    def db_to_game_grade(self, grade: int) -> int:
        return {
            self.GRADE_DEATH: self.GAME_GRADE_DEATH,
            self.GRADE_POOR: self.GAME_GRADE_POOR,
            self.GRADE_MEDIOCRE: self.GAME_GRADE_MEDIOCRE,
            self.GRADE_GOOD: self.GAME_GRADE_GOOD,
            self.GRADE_GREAT: self.GAME_GRADE_GREAT,
            self.GRADE_EXCELLENT: self.GAME_GRADE_EXCELLENT,
            self.GRADE_SUPERB: self.GAME_GRADE_SUPERB,
            self.GRADE_MASTERPIECE: self.GAME_GRADE_MASTERPIECE,
            self.GRADE_PERFECT: self.GAME_GRADE_PERFECT,
        }[grade]

    def handle_game_3_common_request(self, request: Node) -> Node:
        game = Node.void('game_3')
        limited = Node.void('music_limited')
        game.add_child(limited)

        # Song unlock config
        game_config = self.get_game_config()
        if game_config.get_bool('force_unlock_songs'):
            ids = set()
            songs = self.data.local.music.get_all_songs(self.game, self.music_version)
            for song in songs:
                if song.data.get_int('limited') in (self.GAME_LIMITED_LOCKED, self.GAME_LIMITED_UNLOCKABLE):
                    ids.add((song.id, song.chart))

            for (songid, chart) in ids:
                info = Node.void('info')
                limited.add_child(info)
                info.add_child(Node.s32('music_id', songid))
                info.add_child(Node.u8('music_type', chart))
                info.add_child(Node.u8('limited', self.GAME_LIMITED_UNLOCKED))

        # Event config
        event = Node.void('event')
        game.add_child(event)

        def enable_event(eid: int) -> None:
            evt = Node.void('info')
            event.add_child(evt)
            evt.add_child(Node.u32('event_id', eid))

        if not game_config.get_bool('disable_matching'):
            enable_event(143)  # Matching enabled

        # These events are meant specifically for Museca Plus
        museca_plus_events = [
            140,  # Agetta Moratta (vmlink_phase 3 in musicdb)
            211,  # News 1
            212,  # News 2
        ]
        event_ids = [
            1,    # Extended pedal options (no effect on Museca 1+1/2)
            56,   # Generator grafica icon <print 1 in musicdb>
            83,   # Paseli Light Start
            86,   # Generator grafica icon <print 2 in musicdb>
            98,   # Caption 2 notice (grs_grafica_caption_2.png)
            105,  # Makes the "Number of Layers" option visible in game settings
            130,  # Curator Rank
            141,  # Coconatsu & Mukipara grafica effects
            145,  # MUKIPARA UNLOCKS
            146,  # MUKIPARA UNLOCKS
            147,  # MUKIPARA UNLOCKS
            148,  # MUKIPARA UNLOCKS
            149,  # MUKIPARA UNLOCKS
            195,  # Fictional Curator (foot pedal options)
        ]

        for evtid in event_ids:
            enable_event(evtid)
        if self.omnimix:
            for evtid in museca_plus_events:
                enable_event(evtid)

        # List of known event IDs:
            # 56,   # Generator grafica icon <print 1 in musicdb>
            # 83,   # Paseli Light Start
            # 86,   # Generator grafica icon <print 2 in musicdb>
            # 98,   # Caption 2 notice (grs_grafica_caption_2.png)
            # 100,  # DJ YOSHITAKA EXHIBITION 2016
            # 103,  # HATSUNE MIKU EXHIBITION 2016 - PART 1
            # 104,  # HATSUNE MIKU EXHIBITION 2016 - PART 2
            # 105,  # Makes the "Number of Layers" option visible in game settings
            # 106,  # HATSUNE MIKU EXHIBITION 2016 - PART 3
            # 117,  # NEW GENERATION METEOR DIFFUSE FESTA 2016 / RYUSEI FESTA TRIGGER
            # 129,  # COCONATSU EXHIBITION 2016
            # 130,  # Curator Rank
            # 97,   # Agetta Moratta (vmlink_phase 1 in musicdb)
            # 114,  # Agetta Moratta (vmlink_phase 2 in musicdb)
            # 140,  # Agetta Moratta (vmlink_phase 3 in musicdb)
            # 141,  # Coconatsu & Mukipara grafica effects
            # 143,  # Matching
            # 144,  # BEMANI ARCHAEOLOGICAL EXHIBITION
            # 163,  # TUTORIAL SNOW
            # 169,  # SHIORI FUJISAKI EXHIBITION 2017 - PART 1
            # 174,  # SHIORI FUJISAKI EXHIBITION 2017 - PART 2
            # 182,  # Mute illil's voice?
            # 192,  # GREAT REPRINT FESTIVAL: MIKU + DJ YOSHITAKA
            # 194,  # Continue
            # 195,  # Fictional Curator (foot pedal options)
            # 211,  #News 1
            # 212,  #News 2
            # 213,  #News 3
            # 214,  #News 4
            # 217,  #News 5
            # 218,  #News 6
            # 219,  #News 7
            # 220,  #News 8
            # 221,  # GRAFICA PRESENTATION CAMPAIGN “THE PRIMITIVE LIFE EXHIBITION”
            # 222,  # GRAFICA PRESENTATION CAMPAIGN "NOISE"
            # 223,  # GRAFICA PRESENTATION CAMPAIGN "PATISSERIE ROUGE"
            # 224,  # GRAFICA PRESENTATION CAMPAIGN "GUNSLINGER"
            # 145,  # MUKIPARA UNLOCKS
            # 146,  # MUKIPARA UNLOCKS
            # 147,  # MUKIPARA UNLOCKS
            # 148,  # MUKIPARA UNLOCKS
            # 149,  # MUKIPARA UNLOCKS

        # Makes special missions available on grafica that have them.
        extend = Node.void('extend')
        game.add_child(extend)
        info = Node.void('info')
        extend.add_child(info)
        info.add_child(Node.u32('extend_id', 1))
        info.add_child(Node.u32('extend_type', 9))
        info.add_child(Node.s32('param_num_1', 2))
        info.add_child(Node.s32('param_num_2', 50))
        info.add_child(Node.s32('param_num_3', 59))
        info.add_child(Node.s32('param_num_4', 64))
        info.add_child(Node.s32('param_num_5', 86))
        info.add_child(Node.string('param_str_1', 'available_ex: 1'))
        info.add_child(Node.string('param_str_2', 'available_ex: 1'))
        info.add_child(Node.string('param_str_3', 'available_ex: 1'))
        info.add_child(Node.string('param_str_4', 'available_ex: 1'))
        info.add_child(Node.string('param_str_5', 'available_ex: 1'))

        if self.omnimix:
            info = Node.void('info')
            extend.add_child(info)
            info.add_child(Node.u32('extend_id', 2))
            info.add_child(Node.u32('extend_type', 9))
            info.add_child(Node.s32('param_num_1', 210))
            info.add_child(Node.s32('param_num_2', 0))
            info.add_child(Node.s32('param_num_3', 0))
            info.add_child(Node.s32('param_num_4', 0))
            info.add_child(Node.s32('param_num_5', 0))
            info.add_child(Node.string('param_str_1', ''))
            info.add_child(Node.string('param_str_2', ''))
            info.add_child(Node.string('param_str_3', ''))
            info.add_child(Node.string('param_str_4', ''))
            info.add_child(Node.string('param_str_5', ''))

        return game

    def handle_game_3_lounge_request(self, request: Node) -> Node:
        game = Node.void('game_3')
        # Refresh interval in seconds.
        game.add_child(Node.u32('interval', 10))
        return game

    def handle_game_3_exception_request(self, request: Node) -> Node:
        return Node.void('game_3')

    def handle_game_3_load_request(self, request: Node) -> Node:
        refid = request.child_value('refid')
        root = self.get_profile_by_refid(refid)
        if root is not None:
            return root

        # Figure out if this user has an older profile or not
        userid = self.data.remote.user.from_refid(self.game, self.version, refid)

        if userid is not None:
            previous_game = self.previous_version()
        else:
            previous_game = None

        if previous_game is not None:
            profile = previous_game.get_profile(userid)
        else:
            profile = None

        if profile is not None:
            # Return the previous formatted profile to the game.
            return previous_game.format_profile(userid, profile)
        else:
            root = Node.void('game_3')
            root.add_child(Node.u8('result', 1))
            return root

    def handle_game_3_load_m_request(self, request: Node) -> Node:
        refid = request.child_value('dataid')

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

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

        # Output to the game
        game = Node.void('game_3')
        new = Node.void('new')
        game.add_child(new)

        for score in scores:
            music = Node.void('music')
            new.add_child(music)
            music.add_child(Node.u32('music_id', score.id))
            music.add_child(Node.u32('music_type', score.chart))
            music.add_child(Node.u32('score', score.points))
            music.add_child(Node.u32('cnt', score.plays))
            music.add_child(Node.u32('combo', score.data.get_int('combo')))
            music.add_child(Node.u32('clear_type', self.db_to_game_clear_type(score.data.get_int('clear_type'))))
            music.add_child(Node.u32('score_grade', self.db_to_game_grade(score.data.get_int('grade'))))
            stats = score.data.get_dict('stats')
            music.add_child(Node.u32('btn_rate', stats.get_int('btn_rate')))
            music.add_child(Node.u32('long_rate', stats.get_int('long_rate')))
            music.add_child(Node.u32('vol_rate', stats.get_int('vol_rate')))

        return game

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

        # Generic profile stuff
        game.add_child(Node.string('name', profile.get_str('name')))
        game.add_child(Node.string('code', ID.format_extid(profile.extid)))
        game.add_child(Node.u32('gamecoin_packet', profile.get_int('packet')))
        game.add_child(Node.u32('gamecoin_block', profile.get_int('block')))
        game.add_child(Node.s16('skill_name_id', profile.get_int('skill_name_id', -1)))
        game.add_child(Node.s32_array('hidden_param', profile.get_int_array('hidden_param', 20)))
        game.add_child(Node.u32('blaster_energy', profile.get_int('blaster_energy')))
        game.add_child(Node.u32('blaster_count', profile.get_int('blaster_count')))

        # Enable Ryusei Festa
        ryusei_festa = Node.void('ryusei_festa')
        game.add_child(ryusei_festa)
        ryusei_festa.add_child(Node.bool('ryusei_festa_trigger', True))

        # Play statistics
        statistics = self.get_play_statistics(userid)
        game.add_child(Node.u32('play_count', statistics.total_plays))
        game.add_child(Node.u32('daily_count', statistics.today_plays))
        game.add_child(Node.u32('play_chain', statistics.consecutive_days))

        # Last played stuff
        if 'last' in profile:
            lastdict = profile.get_dict('last')
            last = Node.void('last')
            game.add_child(last)
            last.add_child(Node.s32('music_id', lastdict.get_int('music_id', -1)))
            last.add_child(Node.u8('music_type', lastdict.get_int('music_type')))
            last.add_child(Node.u8('sort_type', lastdict.get_int('sort_type')))
            last.add_child(Node.u8('narrow_down', lastdict.get_int('narrow_down')))
            last.add_child(Node.u8('headphone', lastdict.get_int('headphone')))
            last.add_child(Node.u16('appeal_id', lastdict.get_int('appeal_id', 1001)))
            last.add_child(Node.u16('comment_id', lastdict.get_int('comment_id')))
            last.add_child(Node.u8('gauge_option', lastdict.get_int('gauge_option')))

        # Item unlocks
        itemnode = Node.void('item')
        game.add_child(itemnode)

        game_config = self.get_game_config()
        achievements = self.data.local.user.get_achievements(self.game, self.version, userid)

        for item in achievements:
            if item.type[:5] != 'item_':
                continue
            itemtype = int(item.type[5:])

            if game_config.get_bool('force_unlock_songs') and itemtype == self.GAME_CATALOG_TYPE_SONG:
                # Don't echo unlocked songs, we will add all of them later
                continue

            info = Node.void('info')
            itemnode.add_child(info)
            info.add_child(Node.u8('type', itemtype))
            info.add_child(Node.u32('id', item.id))
            info.add_child(Node.u32('param', item.data.get_int('param')))
            if 'diff_param' in item.data:
                info.add_child(Node.s32('diff_param', item.data.get_int('diff_param')))

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

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

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

                info = Node.void('info')
                itemnode.add_child(info)
                info.add_child(Node.u8('type', self.GAME_CATALOG_TYPE_SONG))
                info.add_child(Node.u32('id', itemid))
                info.add_child(Node.u32('param', ids[itemid]))

        return game

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

        # Update blaster energy and in-game currencies
        earned_gamecoin_packet = request.child_value('earned_gamecoin_packet')
        if earned_gamecoin_packet is not None:
            newprofile.replace_int('packet', newprofile.get_int('packet') + earned_gamecoin_packet)
        earned_gamecoin_block = request.child_value('earned_gamecoin_block')
        if earned_gamecoin_block is not None:
            newprofile.replace_int('block', newprofile.get_int('block') + earned_gamecoin_block)
        earned_blaster_energy = request.child_value('earned_blaster_energy')
        if earned_blaster_energy is not None:
            newprofile.replace_int('blaster_energy', newprofile.get_int('blaster_energy') + earned_blaster_energy)

        # Miscelaneous stuff
        newprofile.replace_int('blaster_count', request.child_value('blaster_count'))
        newprofile.replace_int('skill_name_id', request.child_value('skill_name_id'))
        newprofile.replace_int_array('hidden_param', 20, request.child_value('hidden_param'))

        # Update user's unlock status if we aren't force unlocked
        game_config = self.get_game_config()

        if request.child('item') is not None:
            for child in request.child('item').children:
                if child.name != 'info':
                    continue

                item_id = child.child_value('id')
                item_type = child.child_value('type')
                param = child.child_value('param')
                diff_param = child.child_value('diff_param')

                if game_config.get_bool('force_unlock_songs') and item_type == self.GAME_CATALOG_TYPE_SONG:
                    # Don't save back songs, because they were force unlocked
                    continue

                if diff_param is not None:
                    paramvals = {
                        'diff_param': diff_param,
                        'param': param,
                    }
                else:
                    paramvals = {
                        'param': param,
                    }

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

        # Grab last information.
        lastdict = newprofile.get_dict('last')
        lastdict.replace_int('headphone', request.child_value('headphone'))
        lastdict.replace_int('appeal_id', request.child_value('appeal_id'))
        lastdict.replace_int('comment_id', request.child_value('comment_id'))
        lastdict.replace_int('music_id', request.child_value('music_id'))
        lastdict.replace_int('music_type', request.child_value('music_type'))
        lastdict.replace_int('sort_type', request.child_value('sort_type'))
        lastdict.replace_int('narrow_down', request.child_value('narrow_down'))
        lastdict.replace_int('gauge_option', request.child_value('gauge_option'))

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

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

        return newprofile
