# vim: set fileencoding=utf-8
from typing import Dict, Optional, Sequence
from typing_extensions import Final

from bemani.backend.base import Base
from bemani.backend.core import CoreHandler, CardManagerHandler, PASELIHandler
from bemani.common import Profile, ValidatedDict, Time, GameConstants, DBConstants, BroadcastConstants
from bemani.data import UserID, Achievement, ScoreSaveException
from bemani.protocol import Node


class PopnMusicBase(CoreHandler, CardManagerHandler, PASELIHandler, Base):
    """
    Base game class for all Pop'n Music versions. Handles common functionality for
    getting profiles based on refid, creating new profiles, looking up and saving
    scores.
    """

    game: GameConstants = GameConstants.POPN_MUSIC

    # Play medals, as saved into/loaded from the DB
    PLAY_MEDAL_NO_PLAY: Final[int] = DBConstants.POPN_MUSIC_PLAY_MEDAL_NO_PLAY
    PLAY_MEDAL_CIRCLE_FAILED: Final[int] = DBConstants.POPN_MUSIC_PLAY_MEDAL_CIRCLE_FAILED
    PLAY_MEDAL_DIAMOND_FAILED: Final[int] = DBConstants.POPN_MUSIC_PLAY_MEDAL_DIAMOND_FAILED
    PLAY_MEDAL_STAR_FAILED: Final[int] = DBConstants.POPN_MUSIC_PLAY_MEDAL_STAR_FAILED
    PLAY_MEDAL_EASY_CLEAR: Final[int] = DBConstants.POPN_MUSIC_PLAY_MEDAL_EASY_CLEAR
    PLAY_MEDAL_CIRCLE_CLEARED: Final[int] = DBConstants.POPN_MUSIC_PLAY_MEDAL_CIRCLE_CLEARED
    PLAY_MEDAL_DIAMOND_CLEARED: Final[int] = DBConstants.POPN_MUSIC_PLAY_MEDAL_DIAMOND_CLEARED
    PLAY_MEDAL_STAR_CLEARED: Final[int] = DBConstants.POPN_MUSIC_PLAY_MEDAL_STAR_CLEARED
    PLAY_MEDAL_CIRCLE_FULL_COMBO: Final[int] = DBConstants.POPN_MUSIC_PLAY_MEDAL_CIRCLE_FULL_COMBO
    PLAY_MEDAL_DIAMOND_FULL_COMBO: Final[int] = DBConstants.POPN_MUSIC_PLAY_MEDAL_DIAMOND_FULL_COMBO
    PLAY_MEDAL_STAR_FULL_COMBO: Final[int] = DBConstants.POPN_MUSIC_PLAY_MEDAL_STAR_FULL_COMBO
    PLAY_MEDAL_PERFECT: Final[int] = DBConstants.POPN_MUSIC_PLAY_MEDAL_PERFECT

    # Chart type, as saved into/loaded from the DB, and returned to game
    CHART_TYPE_EASY: Final[int] = 0
    CHART_TYPE_NORMAL: Final[int] = 1
    CHART_TYPE_HYPER: Final[int] = 2
    CHART_TYPE_EX: Final[int] = 3

    # Old profile lookup type, for loading profile by ID
    NEW_PROFILE_ONLY: Final[int] = 0
    OLD_PROFILE_ONLY: Final[int] = 1
    OLD_PROFILE_FALLTHROUGH: Final[int] = 2

    # Pop'n Music in particular requires non-expired profiles to do conversions
    # properly.
    supports_expired_profiles: bool = False

    def previous_version(self) -> Optional['PopnMusicBase']:
        """
        Returns the previous version of the game, based on this game. Should
        be overridden.
        """
        return None

    def format_profile(self, userid: UserID, profile: Profile) -> Node:
        """
        Base handler for a profile. Given a userid and a profile dictionary,
        return a Node representing a profile. Should be overridden.
        """
        return Node.void('playerdata')

    def format_conversion(self, userid: UserID, profile: Profile) -> Node:
        """
        Base handler for profile conversion. Given a userid and a profile
        dictionary, return a node which represents the converted profile for
        the next version of this game. Games will call previous_version to get
        a game class of their previous game version, and then will call
        format_conversion on that previous version to get the profile to
        migrate.
        """
        return Node.void('playerdata')

    def unformat_profile(self, userid: UserID, request: Node, oldprofile: Profile) -> Profile:
        """
        Base handler for profile parsing. Given a request and an old profile,
        return a new profile that's been updated with the contents of the request.
        Should be overridden.
        """
        return oldprofile

    def get_profile_by_refid(self, refid: Optional[str], load_mode: int) -> Optional[Node]:
        """
        Given a RefID, return a formatted profile node. Basically every game
        needs a profile lookup, even if it handles where that happens in
        a different request. This is provided for code deduplication. This
        method handles delegating to either format_profile, or looking up
        the previous game and calling format_conversion, whenever necessary.
        """
        if refid is None:
            return None

        userid = self.data.remote.user.from_refid(self.game, self.version, refid)
        if userid is None:
            # User doesn't exist but should at this point
            return None

        if load_mode == self.OLD_PROFILE_ONLY:
            # Trying to import from older version
            oldversion = self.previous_version()
            profile = oldversion.get_profile(userid)
            if profile is None:
                return None
            return self.format_conversion(userid, profile)
        elif load_mode == self.NEW_PROFILE_ONLY:
            # Trying to import from current version
            profile = self.get_profile(userid)
            if profile is None:
                return None
            return self.format_profile(userid, profile)
        elif load_mode == self.OLD_PROFILE_FALLTHROUGH:
            # Try to load from current, if that fails try to load previous
            profile = self.get_profile(userid)
            if profile is not None:
                return self.format_profile(userid, profile)
            oldversion = self.previous_version()
            oldprofile = oldversion.get_profile(userid)
            if oldprofile is not None:
                return self.format_conversion(userid, oldprofile)
            return None
        else:
            # Unknown value
            raise Exception("Unrecognized value for get profile!")

    def new_profile_by_refid(
        self,
        refid: Optional[str],
        name: Optional[str],
        chara: Optional[int]=None,
        achievements: Sequence[Achievement] = (),
    ) -> Node:
        """
        Given a RefID and an optional name, create a profile and then return
        a formatted profile node. Similar rationale to get_profile_by_refid.
        """
        if refid is None:
            return None

        if name is None:
            name = 'なし'

        userid = self.data.remote.user.from_refid(self.game, self.version, refid)
        if userid is None:
            raise Exception("Logic error! Didn't find user to tie profile to!")
        profile = Profile(
            self.game,
            self.version,
            refid,
            0,
            {
                'name': name,
            },
        )
        if chara is not None:
            profile.replace_int('chara', chara)
        self.put_profile(userid, profile)
        for achievement in achievements:
            self.data.local.user.put_achievement(
                self.game,
                self.version,
                userid,
                achievement.id,
                achievement.type,
                achievement.data,
            )
        return self.format_profile(userid, profile)

    def update_score(
        self,
        userid: UserID,
        songid: int,
        chart: int,
        points: int,
        medal: int,
        combo: Optional[int]=None,
        stats: Optional[Dict[str, int]]=None,
    ) -> None:
        """
        Given various pieces of a score, update the user's high score and score
        history in a controlled manner, so all games in Pop'n series can expect
        the same attributes in a score. Note that the medals passed here are
        expected to be converted from game identifier to our internal identifier,
        so that any game in the series may convert them back. In this way, a song
        played on Pop'n 22 that exists in Pop'n 19 will still have scores/medals
        going back all versions.
        """
        # Range check medals
        if medal not in [
            self.PLAY_MEDAL_NO_PLAY,
            self.PLAY_MEDAL_CIRCLE_FAILED,
            self.PLAY_MEDAL_DIAMOND_FAILED,
            self.PLAY_MEDAL_STAR_FAILED,
            self.PLAY_MEDAL_EASY_CLEAR,
            self.PLAY_MEDAL_CIRCLE_CLEARED,
            self.PLAY_MEDAL_DIAMOND_CLEARED,
            self.PLAY_MEDAL_STAR_CLEARED,
            self.PLAY_MEDAL_CIRCLE_FULL_COMBO,
            self.PLAY_MEDAL_DIAMOND_FULL_COMBO,
            self.PLAY_MEDAL_STAR_FULL_COMBO,
            self.PLAY_MEDAL_PERFECT,
        ]:
            raise Exception(f"Invalid medal value {medal}")

        oldscore = self.data.local.music.get_score(
            self.game,
            self.version,
            userid,
            songid,
            chart,
        )

        # Score history is verbatum, instead of highest score
        history = ValidatedDict({})
        oldpoints = points

        if oldscore is None:
            # If it is a new score, create a new dictionary to add to
            scoredata = ValidatedDict({})
            raised = True
            highscore = True
        else:
            # Set the score to any new record achieved
            raised = points > oldscore.points
            highscore = points >= oldscore.points
            points = max(points, oldscore.points)
            scoredata = oldscore.data

        # Replace medal with highest value
        scoredata.replace_int('medal', max(scoredata.get_int('medal'), medal))
        history.replace_int('medal', medal)

        if stats is not None:
            if raised:
                # We have stats, and there's a new high score, update the stats
                scoredata.replace_dict('stats', stats)
            history.replace_dict('stats', stats)

        if combo is not None:
            # If we have a combo, replace it
            scoredata.replace_int('combo', max(scoredata.get_int('combo'), combo))
            history.replace_int('combo', combo)

        # Look up where this score was earned
        lid = self.get_machine_id()

        # Pop'n Music for all versions before Lapistoria sends all of the songs
        # a player played at the end of the round. It doesn't send timestamps
        # for those songs (Jubeat does). So, if a user plays the same song/chart
        # more than once in a round, we will end up failing to store the attempt
        # since we don't allow two of the same attempt at the same time for the
        # same user and song/chart. So, bump the timestamp by one second and retry
        # well past the maximum number of songs.
        now = Time.now()
        for bump in range(10):
            timestamp = now + bump

            # Write the new score back
            self.data.local.music.put_score(
                self.game,
                self.version,
                userid,
                songid,
                chart,
                lid,
                points,
                scoredata,
                highscore,
                timestamp=timestamp,
            )

            try:
                # Save the history of this score too
                self.data.local.music.put_attempt(
                    self.game,
                    self.version,
                    userid,
                    songid,
                    chart,
                    lid,
                    oldpoints,
                    history,
                    raised,
                    timestamp=timestamp,
                )
            except ScoreSaveException:
                # Try again one second in the future
                continue

            # We saved successfully
            break

    def broadcast_score(self, userid: UserID, songid: int, chart: int, medal: int, points: int, combo: int, stats: Dict[str, int]) -> None:
        # Generate scorecard
        profile = self.get_profile(userid)
        song = self.data.local.music.get_song(self.game, self.version, songid, chart)

        card_medal = {
            self.PLAY_MEDAL_CIRCLE_FAILED: 'Failed',
            self.PLAY_MEDAL_DIAMOND_FAILED: 'Failed',
            self.PLAY_MEDAL_STAR_FAILED: 'Failed',
            self.PLAY_MEDAL_EASY_CLEAR: 'Cleared',
            self.PLAY_MEDAL_CIRCLE_CLEARED: 'Cleared',
            self.PLAY_MEDAL_DIAMOND_CLEARED: 'Cleared',
            self.PLAY_MEDAL_STAR_CLEARED: 'Cleared',
            self.PLAY_MEDAL_CIRCLE_FULL_COMBO: 'Full Combo',
            self.PLAY_MEDAL_DIAMOND_FULL_COMBO: 'Full Combo',
            self.PLAY_MEDAL_STAR_FULL_COMBO: 'Full Combo',
            self.PLAY_MEDAL_PERFECT: 'Perfect',
        }[medal]

        card_chart = {
            self.CHART_TYPE_EASY: 'Easy',
            self.CHART_TYPE_NORMAL: 'Normal',
            self.CHART_TYPE_HYPER: 'Hyper',
            self.CHART_TYPE_EX: 'Ex',
        }[chart]

        # Construct the dictionary for the broadcast
        card_data = {
            BroadcastConstants.PLAYER_NAME: profile.get_str('name', 'なし'),
            BroadcastConstants.SONG_NAME: song.name,
            BroadcastConstants.ARTIST_NAME: song.artist,
            BroadcastConstants.DIFFICULTY: card_chart,
            BroadcastConstants.SCORE: str(points),
            BroadcastConstants.MEDAL: card_medal,
            BroadcastConstants.COOLS: str(stats['cool']),
            BroadcastConstants.GREATS: str(stats['great']),
            BroadcastConstants.GOODS: str(stats['good']),
            BroadcastConstants.BADS: str(stats['bad']),
            BroadcastConstants.COMBO: str(combo),
        }

        # Try to broadcast out the score to our webhook(s)
        self.data.triggers.broadcast_score(card_data, self.game, song)
