# vim: set fileencoding=utf-8
from typing import Dict, List, Optional
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, GameConstants, DBConstants, Time
from bemani.data import Machine, ScoreSaveException, UserID
from bemani.protocol import Node


class ReflecBeatBase(CoreHandler, CardManagerHandler, PASELIHandler, Base):
    """
    Base game class for all Reflec Beat version that we support.
    """

    game: GameConstants = GameConstants.REFLEC_BEAT

    # Chart types, as stored in the DB
    CHART_TYPE_BASIC: Final[int] = 0
    CHART_TYPE_MEDIUM: Final[int] = 1
    CHART_TYPE_HARD: Final[int] = 2
    CHART_TYPE_SPECIAL: Final[int] = 3

    # Clear types, as saved/loaded from the DB
    CLEAR_TYPE_NO_PLAY: Final[int] = DBConstants.REFLEC_BEAT_CLEAR_TYPE_NO_PLAY
    CLEAR_TYPE_FAILED: Final[int] = DBConstants.REFLEC_BEAT_CLEAR_TYPE_FAILED
    CLEAR_TYPE_CLEARED: Final[int] = DBConstants.REFLEC_BEAT_CLEAR_TYPE_CLEARED
    CLEAR_TYPE_HARD_CLEARED: Final[int] = DBConstants.REFLEC_BEAT_CLEAR_TYPE_HARD_CLEARED
    CLEAR_TYPE_S_HARD_CLEARED: Final[int] = DBConstants.REFLEC_BEAT_CLEAR_TYPE_S_HARD_CLEARED

    # Combo types, as saved/loaded from the DB
    COMBO_TYPE_NONE: Final[int] = DBConstants.REFLEC_BEAT_COMBO_TYPE_NONE
    COMBO_TYPE_ALMOST_COMBO: Final[int] = DBConstants.REFLEC_BEAT_COMBO_TYPE_ALMOST_COMBO
    COMBO_TYPE_FULL_COMBO: Final[int] = DBConstants.REFLEC_BEAT_COMBO_TYPE_FULL_COMBO
    COMBO_TYPE_FULL_COMBO_ALL_JUST: Final[int] = DBConstants.REFLEC_BEAT_COMBO_TYPE_FULL_COMBO_ALL_JUST

    # Return the local2 and lobby2 service so that matching will work on newer
    # Reflec Beat games.
    extra_services: List[str] = [
        'local2',
        'lobby2',
    ]

    def previous_version(self) -> Optional['ReflecBeatBase']:
        """
        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('pc')

    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]) -> 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.
        """
        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

        # Trying to import from current version
        profile = self.get_profile(userid)
        if profile is None:
            return None
        return self.format_profile(userid, profile)

    def put_profile_by_refid(self, refid: Optional[str], request: Node) -> Optional[Profile]:
        """
        Given a RefID and a request node, unformat the profile and save it.
        """
        userid = self.data.remote.user.from_refid(self.game, self.version, refid)
        if userid is None:
            return None

        oldprofile = self.get_profile(userid)
        if oldprofile is None:
            # Create one so we can get refid/extid
            oldprofile = Profile(self.game, self.version, refid, 0)
            self.put_profile(userid, oldprofile)
        newprofile = self.unformat_profile(userid, request, oldprofile)
        if newprofile is not None:
            self.put_profile(userid, newprofile)
            return newprofile
        else:
            return oldprofile

    def get_machine_by_id(self, shop_id: int) -> Optional[Machine]:
        pcbid = self.data.local.machine.from_machine_id(shop_id)
        if pcbid is not None:
            return self.data.local.machine.get_machine(pcbid)
        else:
            return None

    def update_score(
        self,
        userid: UserID,
        songid: int,
        chart: int,
        points: int,
        achievement_rate: int,
        clear_type: int,
        combo_type: int,
        miss_count: int,
        combo: Optional[int]=None,
        stats: Optional[Dict[str, int]]=None,
        param: Optional[int]=None,
        kflag: Optional[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 Reflec series can expect
        the same attributes in a score. Note that the clear_types 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.
        """
        # Range check clear type
        if clear_type not in [
            self.CLEAR_TYPE_NO_PLAY,
            self.CLEAR_TYPE_FAILED,
            self.CLEAR_TYPE_CLEARED,
            self.CLEAR_TYPE_HARD_CLEARED,
            self.CLEAR_TYPE_S_HARD_CLEARED,
        ]:
            raise Exception(f"Invalid clear_type value {clear_type}")

        # Range check combo type
        if combo_type not in [
            self.COMBO_TYPE_NONE,
            self.COMBO_TYPE_ALMOST_COMBO,
            self.COMBO_TYPE_FULL_COMBO,
            self.COMBO_TYPE_FULL_COMBO_ALL_JUST,
        ]:
            raise Exception(f"Invalid combo_type value {combo_type}")

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

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

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

        # Update the last played time
        scoredata.replace_int('last_played_time', now)

        # Replace clear type with highest value and timestamps
        if clear_type >= scoredata.get_int('clear_type'):
            scoredata.replace_int('clear_type', max(scoredata.get_int('clear_type'), clear_type))
            scoredata.replace_int('best_clear_type_time', now)
        history.replace_int('clear_type', clear_type)

        # Replace combo type with highest value and timestamps
        if combo_type >= scoredata.get_int('combo_type'):
            scoredata.replace_int('combo_type', max(scoredata.get_int('combo_type'), combo_type))
            scoredata.replace_int('best_clear_type_time', now)
        history.replace_int('combo_type', combo_type)

        # Update the combo for this song
        if combo is not None:
            scoredata.replace_int('combo', max(scoredata.get_int('combo'), combo))
            history.replace_int('combo', combo)

        # Update the param for this song
        if param is not None:
            scoredata.replace_int('param', max(scoredata.get_int('param'), param))
            history.replace_int('param', param)

        # Update the kflag for this song
        if kflag is not None:
            scoredata.replace_int('kflag', max(scoredata.get_int('kflag'), kflag))
            history.replace_int('kflag', kflag)

        # Update win/lost/draw stats for this song
        if stats is not None:
            scoredata.replace_dict('stats', stats)
            history.replace_dict('stats', stats)

        # Update the achievement rate with timestamps
        if achievement_rate >= scoredata.get_int('achievement_rate'):
            scoredata.replace_int('achievement_rate', max(scoredata.get_int('achievement_rate'), achievement_rate))
            scoredata.replace_int('best_achievement_rate_time', now)
        history.replace_int('achievement_rate', achievement_rate)

        # Update the miss count with timestamps, either if it was lowered, or if the old value was blank.
        # If the new value is -1 (we didn't get a miss count this time), never update the old value.
        if miss_count >= 0:
            if miss_count <= scoredata.get_int('miss_count', 999999) or scoredata.get_int('miss_count') == -1:
                scoredata.replace_int('miss_count', min(scoredata.get_int('miss_count', 999999), miss_count))
                scoredata.replace_int('best_miss_count_time', now)
        history.replace_int('miss_count', miss_count)

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

        # Reflec Beat happens to send all songs that were played by a player
        # at the end of the round. It sends timestamps for the songs, but as of
        # Colette they were identical for each song in the round. 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.
        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,
                    highscore,
                    timestamp=timestamp,
                )
            except ScoreSaveException:
                # Try again one second in the future
                continue

            # We saved successfully
            break
