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

from bemani.backend.reflec.base import ReflecBeatBase

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


class ReflecBeatVolzzaBase(ReflecBeatBase):
    # Clear types according to the game
    GAME_CLEAR_TYPE_NO_PLAY: Final[int] = 0
    GAME_CLEAR_TYPE_EARLY_FAILED: Final[int] = 1
    GAME_CLEAR_TYPE_FAILED: Final[int] = 2
    GAME_CLEAR_TYPE_CLEARED: Final[int] = 9
    GAME_CLEAR_TYPE_HARD_CLEARED: Final[int] = 10
    GAME_CLEAR_TYPE_S_HARD_CLEARED: Final[int] = 11

    # Combo types according to the game (actually a bitmask, where bit 0 is
    # full combo status, and bit 2 is just reflec status). But we don't support
    # saving just reflec without full combo, so we downgrade it.
    GAME_COMBO_TYPE_NONE: Final[int] = 0
    GAME_COMBO_TYPE_ALL_JUST: Final[int] = 2
    GAME_COMBO_TYPE_FULL_COMBO: Final[int] = 1
    GAME_COMBO_TYPE_FULL_COMBO_ALL_JUST: Final[int] = 3

    def _db_to_game_clear_type(self, db_status: int) -> int:
        return {
            self.CLEAR_TYPE_NO_PLAY: self.GAME_CLEAR_TYPE_NO_PLAY,
            self.CLEAR_TYPE_FAILED: self.GAME_CLEAR_TYPE_FAILED,
            self.CLEAR_TYPE_CLEARED: self.GAME_CLEAR_TYPE_CLEARED,
            self.CLEAR_TYPE_HARD_CLEARED: self.GAME_CLEAR_TYPE_HARD_CLEARED,
            self.CLEAR_TYPE_S_HARD_CLEARED: self.GAME_CLEAR_TYPE_S_HARD_CLEARED,
        }[db_status]

    def _game_to_db_clear_type(self, status: int) -> int:
        return {
            self.GAME_CLEAR_TYPE_NO_PLAY: self.CLEAR_TYPE_NO_PLAY,
            self.GAME_CLEAR_TYPE_EARLY_FAILED: self.CLEAR_TYPE_FAILED,
            self.GAME_CLEAR_TYPE_FAILED: self.CLEAR_TYPE_FAILED,
            self.GAME_CLEAR_TYPE_CLEARED: self.CLEAR_TYPE_CLEARED,
            self.GAME_CLEAR_TYPE_HARD_CLEARED: self.CLEAR_TYPE_HARD_CLEARED,
            self.GAME_CLEAR_TYPE_S_HARD_CLEARED: self.CLEAR_TYPE_S_HARD_CLEARED,
        }[status]

    def _db_to_game_combo_type(self, db_combo: int) -> int:
        return {
            self.COMBO_TYPE_NONE: self.GAME_COMBO_TYPE_NONE,
            self.COMBO_TYPE_ALMOST_COMBO: self.GAME_COMBO_TYPE_NONE,
            self.COMBO_TYPE_FULL_COMBO: self.GAME_COMBO_TYPE_FULL_COMBO,
            self.COMBO_TYPE_FULL_COMBO_ALL_JUST: self.GAME_COMBO_TYPE_FULL_COMBO_ALL_JUST,
        }[db_combo]

    def _game_to_db_combo_type(self, game_combo: int, miss_count: int) -> int:
        if game_combo in [
            self.GAME_COMBO_TYPE_NONE,
            self.GAME_COMBO_TYPE_ALL_JUST,
        ]:
            if miss_count >= 0 and miss_count <= 2:
                return self.COMBO_TYPE_ALMOST_COMBO
            else:
                return self.COMBO_TYPE_NONE
        if game_combo == self.GAME_COMBO_TYPE_FULL_COMBO:
            return self.COMBO_TYPE_FULL_COMBO
        if game_combo == self.GAME_COMBO_TYPE_FULL_COMBO_ALL_JUST:
            return self.COMBO_TYPE_FULL_COMBO_ALL_JUST
        raise Exception(f"Invalid game_combo value {game_combo}")

    def _add_event_info(self, root: Node) -> None:
        # Overridden in subclasses
        pass

    def _add_shop_score(self, root: Node) -> None:
        shop_score = Node.void("shop_score")
        root.add_child(shop_score)
        today = Node.void("today")
        shop_score.add_child(today)
        yesterday = Node.void("yesterday")
        shop_score.add_child(yesterday)

        all_profiles = self.data.local.user.get_all_profiles(self.game, self.version)
        all_attempts = self.data.local.music.get_all_attempts(
            self.game,
            self.version,
            timelimit=(Time.beginning_of_today() - Time.SECONDS_IN_DAY),
        )
        machine = self.data.local.machine.get_machine(self.config.machine.pcbid)
        if machine.arcade is not None:
            lids = [
                machine.id
                for machine in self.data.local.machine.get_all_machines(machine.arcade)
            ]
        else:
            lids = [machine.id]

        relevant_profiles = [
            profile for profile in all_profiles if profile[1].get_int("lid", -1) in lids
        ]

        for rootnode, timeoffset in [
            (today, 0),
            (yesterday, Time.SECONDS_IN_DAY),
        ]:
            # Grab all attempts made in the relevant day
            relevant_attempts = [
                attempt
                for attempt in all_attempts
                if (
                    attempt[1].timestamp >= (Time.beginning_of_today() - timeoffset)
                    and attempt[1].timestamp <= (Time.end_of_today() - timeoffset)
                )
            ]

            # Calculate scores based on attempt
            scores_by_user: Dict[UserID, Dict[int, Dict[int, Attempt]]] = {}
            for userid, attempt in relevant_attempts:
                if userid not in scores_by_user:
                    scores_by_user[userid] = {}
                if attempt.id not in scores_by_user[userid]:
                    scores_by_user[userid][attempt.id] = {}
                if attempt.chart not in scores_by_user[userid][attempt.id]:
                    # No high score for this yet, just use this attempt
                    scores_by_user[userid][attempt.id][attempt.chart] = attempt
                else:
                    # If this attempt is better than the stored one, replace it
                    if (
                        scores_by_user[userid][attempt.id][attempt.chart].points
                        < attempt.points
                    ):
                        scores_by_user[userid][attempt.id][attempt.chart] = attempt

            # Calculate points earned by user in the day
            points_by_user: Dict[UserID, int] = {}
            for userid in scores_by_user:
                points_by_user[userid] = 0
                for mid in scores_by_user[userid]:
                    for chart in scores_by_user[userid][mid]:
                        points_by_user[userid] = (
                            points_by_user[userid]
                            + scores_by_user[userid][mid][chart].points
                        )

            # Output that day's earned points
            for userid, profile in relevant_profiles:
                data = Node.void("data")
                rootnode.add_child(data)
                data.add_child(
                    Node.s16(
                        "day_id", int((Time.now() - timeoffset) / Time.SECONDS_IN_DAY)
                    )
                )
                data.add_child(Node.s32("user_id", profile.extid))
                data.add_child(
                    Node.s16("icon_id", profile.get_dict("config").get_int("icon_id"))
                )
                data.add_child(
                    Node.s16("point", min(points_by_user.get(userid, 0), 32767))
                )
                data.add_child(Node.s32("update_time", Time.now()))
                data.add_child(Node.string("name", profile.get_str("name")))

            rootnode.add_child(Node.s32("time", Time.beginning_of_today() - timeoffset))

    def handle_info_rb5_info_read_request(self, request: Node) -> Node:
        root = Node.void("info")
        self._add_event_info(root)

        return root

    def handle_info_rb5_info_read_hit_chart_request(self, request: Node) -> Node:
        version = request.child_value("ver")

        root = Node.void("info")
        root.add_child(Node.s32("ver", version))
        ranking = Node.void("ranking")
        root.add_child(ranking)

        def add_hitchart(
            name: str, start: int, end: int, hitchart: List[Tuple[int, int]]
        ) -> None:
            base = Node.void(name)
            ranking.add_child(base)
            base.add_child(Node.s32("bt", start))
            base.add_child(Node.s32("et", end))
            new = Node.void("new")
            base.add_child(new)

            for mid, plays in hitchart:
                d = Node.void("d")
                new.add_child(d)
                d.add_child(Node.s16("mid", mid))
                d.add_child(Node.s32("cnt", plays))

        # Weekly hit chart
        add_hitchart(
            "weekly",
            Time.now() - Time.SECONDS_IN_WEEK,
            Time.now(),
            self.data.local.music.get_hit_chart(self.game, self.version, 1024, 7),
        )

        # Monthly hit chart
        add_hitchart(
            "monthly",
            Time.now() - Time.SECONDS_IN_DAY * 30,
            Time.now(),
            self.data.local.music.get_hit_chart(self.game, self.version, 1024, 30),
        )

        # All time hit chart
        add_hitchart(
            "total",
            Time.now() - Time.SECONDS_IN_DAY * 365,
            Time.now(),
            self.data.local.music.get_hit_chart(self.game, self.version, 1024, 365),
        )

        return root

    def handle_info_rb5_info_read_shop_ranking_request(self, request: Node) -> Node:
        start_music_id = request.child_value("min")
        end_music_id = request.child_value("max")

        root = Node.void("info")
        shop_score = Node.void("shop_score")
        root.add_child(shop_score)
        shop_score.add_child(Node.s32("time", Time.now()))

        profiles: Dict[UserID, Profile] = {}
        for songid in range(start_music_id, end_music_id + 1):
            allscores = self.data.local.music.get_all_scores(
                self.game,
                self.version,
                songid=songid,
            )

            for ng in [
                self.CHART_TYPE_BASIC,
                self.CHART_TYPE_MEDIUM,
                self.CHART_TYPE_HARD,
                self.CHART_TYPE_SPECIAL,
            ]:
                scores = sorted(
                    [score for score in allscores if score[1].chart == ng],
                    key=lambda score: score[1].points,
                    reverse=True,
                )

                for i in range(len(scores)):
                    userid, score = scores[i]
                    if userid not in profiles:
                        profiles[userid] = self.get_any_profile(userid)
                    profile = profiles[userid]

                    data = Node.void("data")
                    shop_score.add_child(data)
                    data.add_child(Node.s32("rank", i + 1))
                    data.add_child(Node.s16("music_id", songid))
                    data.add_child(Node.s8("note_grade", score.chart))
                    data.add_child(
                        Node.s8(
                            "clear_type",
                            self._db_to_game_clear_type(
                                score.data.get_int("clear_type")
                            ),
                        )
                    )
                    data.add_child(Node.s32("user_id", profile.extid))
                    data.add_child(
                        Node.s16(
                            "icon_id", profile.get_dict("config").get_int("icon_id")
                        )
                    )
                    data.add_child(Node.s32("score", score.points))
                    data.add_child(Node.s32("time", score.timestamp))
                    data.add_child(Node.string("name", profile.get_str("name")))

        return root

    def handle_lobby_rb5_lobby_entry_request(self, request: Node) -> Node:
        root = Node.void("lobby")
        root.add_child(Node.s32("interval", 120))
        root.add_child(Node.s32("interval_p", 120))

        # 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)
            info = self.data.local.lobby.get_play_session_info(
                self.game, self.version, userid
            )
            if profile is None or info is None:
                return root

            self.data.local.lobby.put_lobby(
                self.game,
                self.version,
                userid,
                {
                    "mid": request.child_value("e/mid"),
                    "ng": request.child_value("e/ng"),
                    "mopt": request.child_value("e/mopt"),
                    "lid": request.child_value("e/lid"),
                    "sn": request.child_value("e/sn"),
                    "pref": request.child_value("e/pref"),
                    "stg": request.child_value("e/stg"),
                    "pside": request.child_value("e/pside"),
                    "eatime": request.child_value("e/eatime"),
                    "ga": request.child_value("e/ga"),
                    "gp": request.child_value("e/gp"),
                    "la": request.child_value("e/la"),
                    "ver": request.child_value("e/ver"),
                },
            )
            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.s32("uattr", profile.get_int("uattr")))
            e.add_child(Node.string("pn", profile.get_str("name")))
            e.add_child(Node.s32("plyid", info.get_int("id")))
            e.add_child(Node.s16("mg", profile.get_int("mg")))
            e.add_child(Node.s32("mopt", lobby.get_int("mopt")))
            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.s8("stg", lobby.get_int("stg")))
            e.add_child(Node.s8("pside", lobby.get_int("pside")))
            e.add_child(Node.s16("eatime", lobby.get_int("eatime")))
            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)))
            e.add_child(Node.u8("ver", lobby.get_int("ver")))

        return root

    def handle_lobby_rb5_lobby_read_request(self, request: Node) -> Node:
        root = Node.void("lobby")
        root.add_child(Node.s32("interval", 120))
        root.add_child(Node.s32("interval_p", 120))

        # Look up all lobbies matching the criteria specified
        ver = request.child_value("var")
        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
                if ver != lobby.get_int("ver"):
                    # Don't return lobby data for different versions
                    continue

                profile = self.get_profile(user)
                info = self.data.local.lobby.get_play_session_info(
                    self.game, self.version, userid
                )
                if profile is None or info is None:
                    # No profile info, don't return this lobby
                    return root

                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.s32("uattr", profile.get_int("uattr")))
                e.add_child(Node.string("pn", profile.get_str("name")))
                e.add_child(Node.s32("plyid", info.get_int("id")))
                e.add_child(Node.s16("mg", profile.get_int("mg")))
                e.add_child(Node.s32("mopt", lobby.get_int("mopt")))
                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.s8("stg", lobby.get_int("stg")))
                e.add_child(Node.s8("pside", lobby.get_int("pside")))
                e.add_child(Node.s16("eatime", lobby.get_int("eatime")))
                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)))
                e.add_child(Node.u8("ver", lobby.get_int("ver")))

                limit = limit - 1

        return root

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

    def handle_pcb_rb5_pcb_boot_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")
        else:
            machine_name = ""
            close = False
            hour = 0
            minute = 0

        root = Node.void("pcb")
        sinfo = Node.void("sinfo")
        root.add_child(sinfo)
        sinfo.add_child(Node.string("nm", machine_name))
        sinfo.add_child(Node.bool("cl_enbl", close))
        sinfo.add_child(Node.u8("cl_h", hour))
        sinfo.add_child(Node.u8("cl_m", minute))
        sinfo.add_child(Node.bool("shop_flag", True))
        return root

    def handle_pcb_rb5_pcb_error_request(self, request: Node) -> Node:
        return Node.void("pcb")

    def handle_pcb_rb5_pcb_update_request(self, request: Node) -> Node:
        return Node.void("pcb")

    def handle_shop_rb5_shop_write_setting_request(self, request: Node) -> Node:
        return Node.void("shop")

    def handle_shop_rb5_shop_write_info_request(self, request: Node) -> Node:
        self.update_machine_name(request.child_value("sinfo/nm"))
        self.update_machine_data(
            {
                "close": request.child_value("sinfo/cl_enbl"),
                "hour": request.child_value("sinfo/cl_h"),
                "minute": request.child_value("sinfo/cl_m"),
                "pref": request.child_value("sinfo/prf"),
            }
        )
        return Node.void("shop")

    def handle_player_rb5_player_start_request(self, request: Node) -> Node:
        root = Node.void("player")

        # Create a new 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:
            self.data.local.lobby.put_play_session_info(
                self.game,
                self.version,
                userid,
                {
                    "ga": request.child_value("ga"),
                    "gp": request.child_value("gp"),
                    "la": request.child_value("la"),
                    "pnid": request.child_value("pnid"),
                },
            )
            info = self.data.local.lobby.get_play_session_info(
                self.game,
                self.version,
                userid,
            )
            if info is not None:
                play_id = info.get_int("id")
            else:
                play_id = 0
        else:
            play_id = 0

        # Session stuff, and resend global defaults
        root.add_child(Node.s32("plyid", play_id))
        root.add_child(Node.u64("start_time", Time.now() * 1000))
        self._add_event_info(root)

        return root

    def handle_player_rb5_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_rb5_player_delete_request(self, request: Node) -> Node:
        return Node.void("player")

    def handle_player_rb5_player_succeed_request(self, request: Node) -> Node:
        refid = request.child_value("rid")
        userid = self.data.remote.user.from_refid(self.game, self.version, refid)
        if userid is not None:
            previous_version = self.previous_version()
            profile = previous_version.get_profile(userid)
        else:
            profile = None

        root = Node.void("player")

        if profile is None:
            # Return empty succeed to say this is new
            root.add_child(Node.string("name", ""))
            root.add_child(Node.s32("grd", -1))
            root.add_child(Node.s32("ap", -1))
            root.add_child(Node.s32("uattr", 0))
        else:
            # Return previous profile formatted to say this is data succession
            root.add_child(Node.string("name", profile.get_str("name")))
            root.add_child(Node.s32("grd", profile.get_int("mg")))  # This is a guess
            root.add_child(Node.s32("ap", profile.get_int("ap")))
            root.add_child(Node.s32("uattr", profile.get_int("uattr")))
        return root

    def handle_player_rb5_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")