import base64
import struct
from typing import Any, Dict
from typing_extensions import Final

from bemani.backend.ess import EventLogHandler
from bemani.backend.danevo.base import DanceEvolutionBase
from bemani.common import VersionConstants, Profile, CardCipher, Time
from bemani.data import ScoreSaveException
from bemani.protocol import Node


class DanceEvolution(
    EventLogHandler,
    DanceEvolutionBase,
):
    name: str = "Dance Evolution"
    version: int = VersionConstants.DANCE_EVOLUTION

    # Dance Evolution is a mess that only uses profile blobs for everything from the server.
    # This includes scores/records, and your profile and class advancement. DATA01 includes
    # standard profile info in the CSV portion much like every ESS game. DATA03 includes, for
    # some reason, the ID of the songs you just played as well as the score, but not the chart.
    # DATA04 includes your cumulative score earned across all plays. DATA01-DATA05 as well as
    # DATA11-DATA15 hold the records for songs in the binary portion, where 01-05 indicate the
    # chart difficulty for the song and the offset into the binary data can be calculated by
    # the song's offset in the music DB. RDATA01 appears to be for attempts, and seems to
    # include information in the binary of previous attempts at songs, ordered by attempt.
    # This, however, does not include the chart played, so it can't be used to inform the backend
    # of attempts.

    # RDATA running score list includes history chunks that are 32 bytes in length. The game
    # never sends trailing zeros for a data structure, even if it internally recognizes them,
    # so this may have length not divisible by 32 if there are no non-zero bytes after a certain
    # location. The correct thing to do is to fill with assumed zero bytes to a 32-byte boundary.
    # The first 4 bytes of any history chunk are the score in little endian. The next byte is the
    # song ID. Note that the chart does not appear anywhere in this structure to my knowledge.
    # I have no idea what the rest of the bytes are, but most stay zeros and many are the same
    # no matter what.

    # DATA01-05 store the records for songs, including the high score, the letter grade earned,
    # and whether the song has been played (a record exists), cleared and whether a full combo
    # has been earned. The first 63 songs are stored in DATA01-05 (songs with ID 0-62). Song
    # ID 63 and above are stored in DATA11-15 instead, but in an identical format. Each record
    # is 8 bytes long and can be found by multiplying the music ID by 8. To get to songs in the
    # second chunk, first subtract 63 from the song ID and then multiply that value by 8. The
    # first 4 bytes are the high score, stored in little-endian. The next byte is the combo
    # achieved. The next byte is always observed to be 0x04, and the one after that 0x00. Finally,
    # the last byte is the letter grade and full combo marker. Bit 4 set indicates the player
    # earned a full combo. Bits 3-1 are a 3 bit integer indicating the letter grade. Bit 0 is
    # unused. The game considers any letter grade other than 0 to be a pass.

    # The game stores records for each chart in a different DATAXX location. See the below chart
    # for exact storage locations. Note that yes, stealth and master are reversed from how they
    # are presented in game.
    #
    # DATA01/11 - Light
    # DATA02/12 - Standard
    # DATA03/13 - Extreme
    # DATA04/14 - Stealth
    # DATA05/15 - Master

    # The letter grades are believed to be as follows.
    #
    # 0 - Failed. Game displays a white gem to indicate the song was played.
    # 1 - E. Game displays a colored gem matching the chart to indicate the song was cleared.
    # 2 - D. Game displays a colored gem matching the chart to indicate the song was cleared.
    # 3 - C. Game displays a colored gem matching the chart to indicate the song was cleared.
    # 4 - B. Game displays a colored gem matching the chart to indicate the song was cleared.
    # 5 - A. Game displays a colored gem matching the chart to indicate the song was cleared.
    # 6 - AA. Game displays a colored gem matching the chart to indicate the song was cleared.
    # 7 - AAA. Game displays a colored gem matching the chart to indicate the song was cleared.

    DATA01_CLASS_OFFSET: Final[int] = 2
    DATA01_GOLD_OFFSET: Final[int] = 3
    DATA01_NAME_OFFSET: Final[int] = 25
    DATA01_AREA_OFFSET: Final[int] = 26
    DATA01_SHOP_OFFSET: Final[int] = 27

    DATA03_FIRST_SONG_OFFSET: Final[int] = 13
    DATA03_FIRST_HIGH_SCORE_OFFSET: Final[int] = 14
    DATA03_SECOND_SONG_OFFSET: Final[int] = 15
    DATA03_SECOND_HIGH_SCORE_OFFSET: Final[int] = 16

    DATA04_TOTAL_SCORE_EARNED_OFFSET: Final[int] = 9

    GAME_GRADE_FAILED: Final[int] = 0
    GAME_GRADE_E: Final[int] = 1
    GAME_GRADE_D: Final[int] = 2
    GAME_GRADE_C: Final[int] = 3
    GAME_GRADE_B: Final[int] = 4
    GAME_GRADE_A: Final[int] = 5
    GAME_GRADE_AA: Final[int] = 6
    GAME_GRADE_AAA: Final[int] = 7

    @classmethod
    def get_settings(cls) -> Dict[str, Any]:
        """
        Return all of our front-end modifiably settings.
        """
        return {}

    def __update_shop_name(self, profiledata: bytes) -> None:
        # Figure out the profile type
        csvs = profiledata.split(b",")
        if len(csvs) < 2:
            # Not long enough to care about
            return
        datatype = csvs[1].decode("ascii")
        if datatype != "DATA01":
            # Not the right profile type requested
            return

        # Grab the shop name, which is offset based on storage, not based on the
        # game's sending this to us now.
        try:
            shopname = csvs[self.DATA01_SHOP_OFFSET + 2].decode("shift-jis")
        except Exception:
            return
        self.update_machine_name(shopname)

    def handle_tax_get_phase_request(self, request: Node) -> Node:
        tax = Node.void("tax")
        tax.add_child(Node.s32("phase", 0))
        return tax

    def handle_system_convcardnumber_request(self, request: Node) -> Node:
        cardid = request.child_value("data/card_id")
        cardnumber = CardCipher.encode(cardid)

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

        system.add_child(Node.s32("result", 0))
        data.add_child(Node.string("card_number", cardnumber))
        return system

    def handle_system_getmaster_request(self, request: Node) -> Node:
        # See if we can grab the request
        data = request.child("data")
        if not data:
            root = Node.void("system")
            root.add_child(Node.s32("result", 0))
            return root

        # Figure out what type of messsage this is
        reqtype = data.child_value("datatype")
        reqkey = data.child_value("datakey")  # noqa

        # System message
        root = Node.void("system")

        if reqtype == "S_SRVMSG":
            # Known keys include: INFO, ARK_ARR0, ARK_HAS0, SONGOPEN, IRDATA, EVTMSG3, WEEKLYSO
            strdata = ""

            root.add_child(Node.string("strdata1", base64.b64encode(strdata.encode("ascii")).decode("ascii")))
            root.add_child(Node.string("strdata2", ""))
            root.add_child(Node.u64("updatedate", Time.now() * 1000))
            root.add_child(Node.s32("result", 1))
        else:
            # Unknown message.
            root.add_child(Node.s32("result", 0))

        return root

    def _to_hex(self, number: int) -> str:
        return hex(number)[2:]

    def handle_playerdata_usergamedata_recv_request(self, request: Node) -> Node:
        playerdata = Node.void("playerdata")

        player = Node.void("player")
        playerdata.add_child(player)

        refid = request.child_value("data/refid")
        userid = self.data.remote.user.from_refid(self.game, self.version, refid)
        if userid is not None:
            profile = self.get_profile(userid)
            records = 0

            record = Node.void("record")
            player.add_child(record)

            def danevohex(val: int) -> str:
                return hex(val)[2:]

            if profile is None:
                # Figure out what profiles are being requested
                profiletypes = request.child_value("data/recv_csv").split(",")[::2]
                for ptype in profiletypes:
                    # Just return a default empty node
                    record.add_child(Node.string("d", "<NODATA>"))
                    records += 1

            else:
                # Figure out what profiles are being requested
                profiletypes = request.child_value("data/recv_csv").split(",")[::2]
                usergamedata = profile.get_dict("usergamedata")
                for ptype in profiletypes:
                    if ptype in usergamedata:
                        splits = usergamedata[ptype]["strdata"].split(b",")

                        if ptype == "DATA01":
                            # Common profile stuff.
                            splits[self.DATA01_NAME_OFFSET] = profile.get_str("name").encode('shift-jis')
                            splits[self.DATA01_AREA_OFFSET] = profile.get_str("area").encode('shift-jis')
                            splits[self.DATA01_CLASS_OFFSET] = self._to_hex(profile.get_int("class", 1)).encode('shift-jis')
                            splits[self.DATA01_GOLD_OFFSET] = self._to_hex(profile.get_int("gold", 0)).encode('shift-jis')
                        elif ptype == "DATA04":
                            # Cumulative score.
                            splits[self.DATA04_TOTAL_SCORE_EARNED_OFFSET] = self._to_hex(profile.get_int("cumulative_score", 0)).encode('shift-jis')

                        dnode = Node.string(
                            "d",
                            base64.b64encode(b",".join(splits)).decode("ascii"),
                        )
                        dnode.add_child(
                            Node.string(
                                "bin1",
                                base64.b64encode(usergamedata[ptype]["bindata"]).decode("ascii"),
                            )
                        )
                        record.add_child(dnode)

                    else:
                        # Just return a default empty node
                        record.add_child(Node.string("d", "<NODATA>"))

                    records += 1

            player.add_child(Node.u32("record_num", records))

        playerdata.add_child(Node.s32("result", 0))
        return playerdata

    def handle_playerdata_usergamedata_send_request(self, request: Node) -> Node:
        playerdata = Node.void("playerdata")
        refid = request.child_value("data/refid")

        userid = self.data.remote.user.from_refid(self.game, self.version, refid)
        if userid is not None:
            profile = self.get_profile(userid)
            is_new = False
            if profile is None:
                profile = Profile(self.game, self.version, refid, 0)
                is_new = True
            usergamedata = profile.get_dict("usergamedata")

            for record in request.child("data/record").children:
                if record.name != "d":
                    continue

                strdata = base64.b64decode(record.value)
                bindata = base64.b64decode(record.child_value("bin1"))

                # Update the shop name if this is a new profile, since we know on new profiles that
                # this value came from the game itself.
                if is_new:
                    self.__update_shop_name(strdata)

                # Grab and format the profile objects
                strdatalist = strdata.split(b",")
                profiletype = strdatalist[1].decode("utf-8")
                strdatalist = strdatalist[2:]

                if profiletype == "DATA01":
                    # Extract relevant info so that it's in the profile normally.
                    profile.replace_str("name", strdatalist[self.DATA01_NAME_OFFSET].decode('shift-jis'))
                    profile.replace_int("class", int(strdatalist[self.DATA01_CLASS_OFFSET].decode('shift-jis'), 16))
                    profile.replace_int("gold", int(strdatalist[self.DATA01_GOLD_OFFSET].decode('shift-jis'), 16))
                    profile.replace_str("area", strdatalist[self.DATA01_AREA_OFFSET].decode('shift-jis'))
                elif profiletype == "DATA04":
                    # Keep track of this for fun, because hey, why not?
                    profile.replace_int("cumulative_score", int(strdatalist[self.DATA04_TOTAL_SCORE_EARNED_OFFSET].decode('shift-jis'), 16))

                usergamedata[profiletype] = {
                    "strdata": b",".join(strdatalist),
                    "bindata": bindata,
                }

            profile.replace_dict("usergamedata", usergamedata)
            profile.replace_int("write_time", Time.now())
            self.put_profile(userid, profile)

            # Keep track of play statistics across all versions, but don't do it for the initial profile save.
            if not is_new:
                self.update_play_statistics(userid)

            # Now that we've got a fully updated profile to look at, let's see if we can't extract the last played songs
            # and link those to records if they were a record. Unfortunately the game does not specify what chart was
            # played in RDATA so we can't use that. We can, however, look at the song played positions in DATA03 and
            # compare the score achieved to the various locations in DATAXX to see if it was a record thie time or not.
            valid_ids = {song.id for song in self.data.local.music.get_all_songs(self.game, self.version)}

            if "DATA03" in usergamedata:
                strdatalist = usergamedata["DATA03"]["strdata"].split(b",")

                first_song_played = int(strdatalist[self.DATA03_FIRST_SONG_OFFSET].decode('shift-jis'), 16)
                second_song_played = int(strdatalist[self.DATA03_SECOND_SONG_OFFSET].decode('shift-jis'), 16)
                first_song_scored = int(strdatalist[self.DATA03_FIRST_HIGH_SCORE_OFFSET].decode('shift-jis'), 16)
                second_song_scored = int(strdatalist[self.DATA03_SECOND_HIGH_SCORE_OFFSET].decode('shift-jis'), 16)

                for played, scored in [(first_song_played, first_song_scored), (second_song_played, second_song_scored)]:
                    if played not in valid_ids:
                        # Game might be set to 1 song.
                        continue

                    # For the purpose of popularity tracking, save an attempt for this song into the virtual
                    # attempt chart, since we can't know for certain what chart an attempt was associated with.
                    now = Time.now()
                    lid = self.get_machine_id()

                    for bump in range(10):
                        timestamp = now + bump

                        self.data.local.music.put_score(
                            self.game,
                            self.version,
                            userid,
                            played,
                            self.CHART_TYPE_PLAYTRACKING,
                            lid,
                            scored,
                            {},
                            False,
                            timestamp=timestamp,
                        )

                        try:
                            self.data.local.music.put_attempt(
                                self.game,
                                self.version,
                                userid,
                                played,
                                self.CHART_TYPE_PLAYTRACKING,
                                lid,
                                scored,
                                {},
                                False,
                                timestamp=timestamp,
                            )
                        except ScoreSaveException:
                            # Try again one second in the future
                            continue

                        # We saved successfully
                        break

                    # First, calculate whether we're going to look at DATA01-05 or DATA11-15.
                    if played < 63:
                        mapping = {
                            "DATA01": self.CHART_TYPE_LIGHT,
                            "DATA02": self.CHART_TYPE_STANDARD,
                            "DATA03": self.CHART_TYPE_EXTREME,
                            "DATA04": self.CHART_TYPE_STEALTH,
                            "DATA05": self.CHART_TYPE_MASTER,
                        }
                        offset = played * 8
                    else:
                        mapping = {
                            "DATA11": self.CHART_TYPE_LIGHT,
                            "DATA12": self.CHART_TYPE_STANDARD,
                            "DATA13": self.CHART_TYPE_EXTREME,
                            "DATA14": self.CHART_TYPE_STEALTH,
                            "DATA15": self.CHART_TYPE_MASTER,
                        }
                        offset = (played - 63) * 8

                    # Now, grab that data and offset into it to decode the record if it is there.
                    for key in mapping:
                        if key not in usergamedata:
                            # Haven't played any charts that filled this in, so the game never sent it, skip this
                            # because it couldn't possibly be it.
                            continue

                        # They could have played some other songs and the game truncated this because it
                        # only ever sends back until the last nonzero value, so we need to fill in the blanks
                        # so to speak with all zero bytes.
                        chunk = usergamedata[key]["bindata"][offset:(offset + 8)]
                        if len(chunk) < 8:
                            chunk = chunk + (b"\x00" * (8 - len(chunk)))

                        record, combo, playmarker, _, stats = struct.unpack("<Ibbbb", chunk)

                        if record != scored:
                            # This wasn't the record we just earned.
                            continue

                        # Found it!
                        full_combo = bool(stats & 0x10)
                        letter_grade = (stats >> 1) & 0x7
                        grade = {
                            self.GAME_GRADE_FAILED: self.GRADE_FAILED,
                            self.GAME_GRADE_E: self.GRADE_E,
                            self.GAME_GRADE_D: self.GRADE_D,
                            self.GAME_GRADE_C: self.GRADE_C,
                            self.GAME_GRADE_B: self.GRADE_B,
                            self.GAME_GRADE_A: self.GRADE_A,
                            self.GAME_GRADE_AA: self.GRADE_AA,
                            self.GAME_GRADE_AAA: self.GRADE_AAA,
                        }[letter_grade]

                        self.update_score(userid, played, mapping[key], scored, grade, combo, full_combo)

        playerdata.add_child(Node.s32("result", 0))
        return playerdata
