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

from bemani.backend.reflec.base import ReflecBeatBase

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


class ReflecBeat(ReflecBeatBase):

    name: str = "REFLEC BEAT"
    version: int = VersionConstants.REFLEC_BEAT

    # Clear types according to the game
    GAME_CLEAR_TYPE_NO_PLAY: Final[int] = 0
    GAME_CLEAR_TYPE_PLAYED: Final[int] = 2
    GAME_CLEAR_TYPE_FULL_COMBO: Final[int] = 3

    # Reflec Beat has no profile succession
    supports_expired_profiles: bool = False

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

    def __db_to_game_clear_type(self, db_clear_type: int, db_combo_type: int) -> int:
        if db_clear_type == self.CLEAR_TYPE_NO_PLAY:
            return self.GAME_CLEAR_TYPE_NO_PLAY
        if db_clear_type == self.CLEAR_TYPE_FAILED:
            return self.GAME_CLEAR_TYPE_PLAYED
        if db_clear_type in [
            self.CLEAR_TYPE_CLEARED,
            self.CLEAR_TYPE_HARD_CLEARED,
            self.CLEAR_TYPE_S_HARD_CLEARED,
        ]:
            if db_combo_type in [
                self.COMBO_TYPE_NONE,
                self.COMBO_TYPE_ALMOST_COMBO,
            ]:
                return self.GAME_CLEAR_TYPE_PLAYED
            if db_combo_type in [
                self.COMBO_TYPE_FULL_COMBO,
                self.COMBO_TYPE_FULL_COMBO_ALL_JUST,
            ]:
                return self.GAME_CLEAR_TYPE_FULL_COMBO

            raise Exception(f'Invalid db_combo_type {db_combo_type}')
        raise Exception(f'Invalid db_clear_type {db_clear_type}')

    def __game_to_db_clear_type(self, game_clear_type: int, game_achievement_rate: int) -> Tuple[int, int]:
        if game_clear_type == self.GAME_CLEAR_TYPE_NO_PLAY:
            return (self.CLEAR_TYPE_NO_PLAY, self.COMBO_TYPE_NONE)
        if game_clear_type == self.GAME_CLEAR_TYPE_PLAYED:
            if game_achievement_rate >= 7000:
                return (self.CLEAR_TYPE_CLEARED, self.COMBO_TYPE_NONE)
            else:
                return (self.CLEAR_TYPE_FAILED, self.COMBO_TYPE_NONE)
        if game_clear_type == self.GAME_CLEAR_TYPE_FULL_COMBO:
            return (self.CLEAR_TYPE_CLEARED, self.COMBO_TYPE_FULL_COMBO)

        raise Exception(f'Invalid game_clear_type {game_clear_type}')

    def handle_log_pcb_status_request(self, request: Node) -> Node:
        return Node.void('log')

    def handle_log_opsetting_request(self, request: Node) -> Node:
        return Node.void('log')

    def handle_log_play_request(self, request: Node) -> Node:
        return Node.void('log')

    def handle_pcbinfo_get_request(self, request: Node) -> Node:
        shop_id = ID.parse_machine_id(request.child_value('lid'))
        machine = self.get_machine_by_id(shop_id)
        if machine is not None:
            machine_name = machine.name
            close = machine.data.get_bool('close')
            hour = machine.data.get_int('hour')
            minute = machine.data.get_int('minute')
            pref = machine.data.get_int('pref', self.get_machine_region())
        else:
            machine_name = ''
            close = False
            hour = 0
            minute = 0
            pref = self.get_machine_region()

        root = Node.void('pcbinfo')
        info = Node.void('info')
        root.add_child(info)

        info.add_child(Node.string('name', machine_name))
        info.add_child(Node.s16('pref', pref))
        info.add_child(Node.bool('close', close))
        info.add_child(Node.u8('hour', hour))
        info.add_child(Node.u8('min', minute))

        return root

    def handle_pcbinfo_set_request(self, request: Node) -> Node:
        self.update_machine_name(request.child_value('info/name'))
        self.update_machine_data({
            'close': request.child_value('info/close'),
            'hour': request.child_value('info/hour'),
            'minute': request.child_value('info/min'),
            'pref': request.child_value('info/pref'),
        })
        return Node.void('pcbinfo')

    def __add_event_info(self, request: Node) -> None:
        events: Dict[int, int] = {}

        for (_eventid, _phase) in events.items():
            data = Node.void('data')
            request.add_child(data)
            data.add_child(Node.s32('type', -1))
            data.add_child(Node.s32('value', -1))

    def handle_sysinfo_get_request(self, request: Node) -> Node:
        root = Node.void('sysinfo')
        trd = Node.void('trd')
        root.add_child(trd)

        # Add event info
        self.__add_event_info(trd)

        return root

    def handle_sysinfo_fan_request(self, request: Node) -> Node:
        sysinfo = Node.void('sysinfo')
        sysinfo.add_child(Node.u8('pref', self.get_machine_region()))
        sysinfo.add_child(Node.string('lid', request.child_value('lid')))
        return sysinfo

    def handle_lobby_entry_request(self, request: Node) -> Node:
        root = Node.void('lobby')

        # Create a lobby entry for this user
        extid = request.child_value('e/uid')
        userid = self.data.remote.user.from_extid(self.game, self.version, extid)
        if userid is not None:
            profile = self.get_profile(userid)
            self.data.local.lobby.put_lobby(
                self.game,
                self.version,
                userid,
                {
                    'mid': request.child_value('e/mid'),
                    'ng': request.child_value('e/ng'),
                    'lid': request.child_value('e/lid'),
                    'sn': request.child_value('e/sn'),
                    'pref': request.child_value('e/pref'),
                    'ga': request.child_value('e/ga'),
                    'gp': request.child_value('e/gp'),
                    'la': request.child_value('e/la'),
                }
            )
            lobby = self.data.local.lobby.get_lobby(
                self.game,
                self.version,
                userid,
            )
            root.add_child(Node.s32('eid', lobby.get_int('id')))
            e = Node.void('e')
            root.add_child(e)
            e.add_child(Node.s32('eid', lobby.get_int('id')))
            e.add_child(Node.u16('mid', lobby.get_int('mid')))
            e.add_child(Node.u8('ng', lobby.get_int('ng')))
            e.add_child(Node.s32('uid', profile.extid))
            e.add_child(Node.string('pn', profile.get_str('name')))
            e.add_child(Node.s32('exp', profile.get_int('exp')))
            e.add_child(Node.u8('mg', profile.get_int('mg')))
            e.add_child(Node.s32('tid', lobby.get_int('tid')))
            e.add_child(Node.string('tn', lobby.get_str('tn')))
            e.add_child(Node.string('lid', lobby.get_str('lid')))
            e.add_child(Node.string('sn', lobby.get_str('sn')))
            e.add_child(Node.u8('pref', lobby.get_int('pref')))
            e.add_child(Node.u8_array('ga', lobby.get_int_array('ga', 4)))
            e.add_child(Node.u16('gp', lobby.get_int('gp')))
            e.add_child(Node.u8_array('la', lobby.get_int_array('la', 4)))

        return root

    def handle_lobby_read_request(self, request: Node) -> Node:
        root = Node.void('lobby')

        # Look up all lobbies matching the criteria specified
        mg = request.child_value('m_grade')  # noqa: F841
        extid = request.child_value('uid')
        limit = request.child_value('max')
        userid = self.data.remote.user.from_extid(self.game, self.version, extid)
        if userid is not None:
            lobbies = self.data.local.lobby.get_all_lobbies(self.game, self.version)
            for (user, lobby) in lobbies:
                if limit <= 0:
                    break

                if user == userid:
                    # If we have our own lobby, don't return it
                    continue

                profile = self.get_profile(user)
                if profile is None:
                    # No profile info, don't return this lobby
                    continue

                e = Node.void('e')
                root.add_child(e)
                e.add_child(Node.s32('eid', lobby.get_int('id')))
                e.add_child(Node.u16('mid', lobby.get_int('mid')))
                e.add_child(Node.u8('ng', lobby.get_int('ng')))
                e.add_child(Node.s32('uid', profile.extid))
                e.add_child(Node.string('pn', profile.get_str('name')))
                e.add_child(Node.s32('exp', profile.get_int('exp')))
                e.add_child(Node.u8('mg', profile.get_int('mg')))
                e.add_child(Node.s32('tid', lobby.get_int('tid')))
                e.add_child(Node.string('tn', lobby.get_str('tn')))
                e.add_child(Node.string('lid', lobby.get_str('lid')))
                e.add_child(Node.string('sn', lobby.get_str('sn')))
                e.add_child(Node.u8('pref', lobby.get_int('pref')))
                e.add_child(Node.u8_array('ga', lobby.get_int_array('ga', 4)))
                e.add_child(Node.u16('gp', lobby.get_int('gp')))
                e.add_child(Node.u8_array('la', lobby.get_int_array('la', 4)))

                limit = limit - 1

        return root

    def handle_lobby_delete_request(self, request: Node) -> Node:
        eid = request.child_value('eid')
        self.data.local.lobby.destroy_lobby(eid)
        return Node.void('lobby')

    def handle_player_start_request(self, request: Node) -> Node:
        # Add a dummy entry into the lobby setup so we can clean up on end play
        refid = request.child_value('rid')
        userid = self.data.remote.user.from_refid(self.game, self.version, refid)
        if userid is not None:
            self.data.local.lobby.put_play_session_info(
                self.game,
                self.version,
                userid,
                {}
            )

        root = Node.void('player')
        root.add_child(Node.bool('is_suc', True))

        # Add event info
        self.__add_event_info(root)

        return root

    def handle_player_delete_request(self, request: Node) -> Node:
        return Node.void('player')

    def handle_player_end_request(self, request: Node) -> Node:
        # Destroy play session based on info from the request
        refid = request.child_value('rid')
        userid = self.data.remote.user.from_refid(self.game, self.version, refid)
        if userid is not None:
            # Kill any lingering lobbies by this user
            lobby = self.data.local.lobby.get_lobby(
                self.game,
                self.version,
                userid,
            )
            if lobby is not None:
                self.data.local.lobby.destroy_lobby(lobby.get_int('id'))
            self.data.local.lobby.destroy_play_session_info(self.game, self.version, userid)

        return Node.void('player')

    def handle_player_read_request(self, request: Node) -> Node:
        refid = request.child_value('rid')
        profile = self.get_profile_by_refid(refid)
        if profile:
            return profile
        return Node.void('player')

    def handle_player_write_request(self, request: Node) -> Node:
        refid = request.child_value('rid')
        profile = self.put_profile_by_refid(refid, request)
        root = Node.void('player')

        if profile is None:
            root.add_child(Node.s32('uid', 0))
        else:
            root.add_child(Node.s32('uid', profile.extid))
        root.add_child(Node.s32('time', Time.now()))
        return root

    def format_profile(self, userid: UserID, profile: Profile) -> Node:
        statistics = self.get_play_statistics(userid)
        game_config = self.get_game_config()
        achievements = self.data.local.user.get_achievements(self.game, self.version, userid)
        scores = self.data.remote.music.get_scores(self.game, self.version, userid)
        root = Node.void('player')
        pdata = Node.void('pdata')
        root.add_child(pdata)

        base = Node.void('base')
        pdata.add_child(base)
        base.add_child(Node.s32('uid', profile.extid))
        base.add_child(Node.string('name', profile.get_str('name')))
        base.add_child(Node.s16('lv', profile.get_int('lvl')))
        base.add_child(Node.s32('exp', profile.get_int('exp')))
        base.add_child(Node.s16('mg', profile.get_int('mg')))
        base.add_child(Node.s16('ap', profile.get_int('ap')))
        base.add_child(Node.s32('flag', profile.get_int('flag')))

        con = Node.void('con')
        pdata.add_child(con)
        con.add_child(Node.s32('day', statistics.today_plays))
        con.add_child(Node.s32('cnt', statistics.total_plays))
        con.add_child(Node.s32('last', statistics.last_play_timestamp))
        con.add_child(Node.s32('now', Time.now()))

        team = Node.void('team')
        pdata.add_child(team)
        team.add_child(Node.s32('id', -1))
        team.add_child(Node.string('name', ''))

        custom = Node.void('custom')
        customdict = profile.get_dict('custom')
        pdata.add_child(custom)
        custom.add_child(Node.u8('bgm_m', customdict.get_int('bgm_m')))
        custom.add_child(Node.u8('st_f', customdict.get_int('st_f')))
        custom.add_child(Node.u8('st_bg', customdict.get_int('st_bg')))
        custom.add_child(Node.u8('st_bg_b', customdict.get_int('st_bg_b')))
        custom.add_child(Node.u8('eff_e', customdict.get_int('eff_e')))
        custom.add_child(Node.u8('se_s', customdict.get_int('se_s')))
        custom.add_child(Node.u8('se_s_v', customdict.get_int('se_s_v')))

        released = Node.void('released')
        pdata.add_child(released)

        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 == 0:
                # Don't echo unlocks when we're force unlocking, we'll do it later
                continue

            info = Node.void('info')
            released.add_child(info)
            info.add_child(Node.u8('type', itemtype))
            info.add_child(Node.u16('id', item.id))

        if game_config.get_bool('force_unlock_songs'):
            songs = {song.id for song in self.data.local.music.get_all_songs(self.game, self.version)}

            for songid in songs:
                info = Node.void('info')
                released.add_child(info)
                info.add_child(Node.u8('type', 0))
                info.add_child(Node.u16('id', songid))

        # Scores
        record = Node.void('record')
        pdata.add_child(record)

        for score in scores:
            rec = Node.void('rec')
            record.add_child(rec)
            rec.add_child(Node.u16('mid', score.id))
            rec.add_child(Node.u8('ng', score.chart))
            rec.add_child(Node.s32('win', score.data.get_dict('stats').get_int('win')))
            rec.add_child(Node.s32('lose', score.data.get_dict('stats').get_int('lose')))
            rec.add_child(Node.s32('draw', score.data.get_dict('stats').get_int('draw')))
            rec.add_child(Node.u8('ct', self.__db_to_game_clear_type(score.data.get_int('clear_type'), score.data.get_int('combo_type'))))
            rec.add_child(Node.s16('ar', int(score.data.get_int('achievement_rate') / 10)))
            rec.add_child(Node.s16('bs', score.points))
            rec.add_child(Node.s16('mc', score.data.get_int('combo')))
            rec.add_child(Node.s16('bmc', score.data.get_int('miss_count')))

        # In original ReflecBeat, the entire battle log was returned for each battle.
        # We don't support storing all of that info, so don't return anything here.
        blog = Node.void('blog')
        pdata.add_child(blog)

        # Comment (seems unused?)
        pdata.add_child(Node.string('cmnt', ''))

        return root

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

        newprofile.replace_int('lid', ID.parse_machine_id(request.child_value('lid')))
        newprofile.replace_str('name', request.child_value('pdata/base/name'))
        newprofile.replace_int('lvl', request.child_value('pdata/base/lv'))
        newprofile.replace_int('exp', request.child_value('pdata/base/exp'))
        newprofile.replace_int('mg', request.child_value('pdata/base/mg'))
        newprofile.replace_int('ap', request.child_value('pdata/base/ap'))
        newprofile.replace_int('flag', request.child_value('pdata/base/flag'))

        customdict = newprofile.get_dict('custom')
        custom = request.child('pdata/custom')
        if custom:
            customdict.replace_int('bgm_m', custom.child_value('bgm_m'))
            customdict.replace_int('st_f', custom.child_value('st_f'))
            customdict.replace_int('st_bg', custom.child_value('st_bg'))
            customdict.replace_int('st_bg_b', custom.child_value('st_bg_b'))
            customdict.replace_int('eff_e', custom.child_value('eff_e'))
            customdict.replace_int('se_s', custom.child_value('se_s'))
            customdict.replace_int('se_s_v', custom.child_value('se_s_v'))
        newprofile.replace_dict('custom', customdict)

        # Music unlocks and other stuff
        released = request.child('pdata/released')
        if released:
            for child in released.children:
                if child.name != 'info':
                    continue

                item_id = child.child_value('id')
                item_type = child.child_value('type')
                if game_config.get_bool('force_unlock_songs') and item_type == 0:
                    # Don't save unlocks when we're force unlocking
                    continue

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

        # Grab any new records set during this play session. Reflec Beat original only sends
        # the top record back for songs that were played at least once during the session.
        # Note that it sends the top record, so if you play the song twice, it will return
        # only one record. Also, if you get a lower score than a previous try, it will return
        # the previous try. So, we must also look at the battle log for the actual play scores,
        # and combine the data if we can.
        savedrecords: Dict[int, Dict[int, Dict[str, int]]] = {}
        songplays = request.child('pdata/record')
        if songplays:
            for child in songplays.children:
                if child.name != 'rec':
                    continue

                songid = child.child_value('mid')
                chart = child.child_value('ng')

                # These don't get sent with the battle logs, so we try to construct
                # the values here.
                if songid not in savedrecords:
                    savedrecords[songid] = {}
                savedrecords[songid][chart] = {
                    'achievement_rate': child.child_value('ar') * 10,
                    'points': child.child_value('bs'),
                    'combo': child.child_value('mc'),
                    'miss_count': child.child_value('bmc'),
                    'win': child.child_value('win'),
                    'lose': child.child_value('lose'),
                    'draw': child.child_value('draw'),
                }

        # Now, see the actual battles that were played. If we can, unify the data with a record.
        # We only do that when the record achievement rate and score matches the battle achievement
        # rate and score, so we know for a fact that that record was generated by this battle.
        battlelogs = request.child('pdata/blog')
        if battlelogs:
            for child in battlelogs.children:
                if child.name != 'log':
                    continue

                songid = child.child_value('mid')
                chart = child.child_value('ng')

                clear_type = child.child_value('myself/ct')
                achievement_rate = child.child_value('myself/ar') * 10
                points = child.child_value('myself/s')

                clear_type, combo_type = self.__game_to_db_clear_type(clear_type, achievement_rate)

                combo = None
                miss_count = -1
                stats = None

                if songid in savedrecords:
                    if chart in savedrecords[songid]:
                        data = savedrecords[songid][chart]

                        if (
                            data['achievement_rate'] == achievement_rate and
                            data['points'] == points
                        ):
                            # This is the same record! Use the stats from it to update our
                            # internal representation.
                            combo = data['combo']
                            miss_count = data['miss_count']
                            stats = {
                                'win': data['win'],
                                'lose': data['lose'],
                                'draw': data['draw'],
                            }

                self.update_score(
                    userid,
                    songid,
                    chart,
                    points,
                    achievement_rate,
                    clear_type,
                    combo_type,
                    miss_count,
                    combo=combo,
                    stats=stats,
                )

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

        return newprofile
