from enum import Enum, Flag, IntFlag

from circleguard.exceptions import (UnknownAPIException, RatelimitException,
                InvalidKeyException, ReplayUnavailableException, InvalidJSONException)

# strings taken from osu api error responses
# [api response, exception class type, details to pass to an exception]
class Error(Enum):
    NO_REPLAY         = ["Replay not available.", ReplayUnavailableException, "Could not find any replay data. Skipping"]
    RATELIMITED       = ["Requesting too fast! Slow your operation, cap'n!", RatelimitException, "We were ratelimited. Waiting it out"]
    RETRIEVAL_FAILED  = ["Replay retrieval failed.", ReplayUnavailableException, "Replay retrieval failed. Skipping"]
    INVALID_KEY       = ["Please provide a valid API key.", InvalidKeyException, "Please provide a valid api key"]
    INVALID_JSON      = ["The api broke.", InvalidJSONException, "The api returned an invalid json response, retrying"]
    UNKNOWN           = ["Unknown error.", UnknownAPIException, "Unknown error when requesting a replay. Please report this "
                                                                "to the developers at https://github.com/circleguard/circlecore"]

int_to_mod = {
    0          : ["NM",       "NoMod"],
    1 << 0     : ["NF",      "NoFail"],
    1 << 1     : ["EZ",        "Easy"],
    1 << 2     : ["TD", "TouchDevice"],
    1 << 3     : ["HD",      "Hidden"],
    1 << 4     : ["HR",    "HardRock"],
    1 << 5     : ["SD", "SuddenDeath"],
    1 << 6     : ["DT",  "DoubleTime"],
    1 << 7     : ["RX",       "Relax"],
    1 << 8     : ["HT",    "HalfTime"],
    1 << 9     : ["NC",   "Nightcore"],
    1 << 10    : ["FL",  "Flashlight"],
    1 << 11    : ["AT",    "Autoplay"],
    1 << 12    : ["SO",     "SpunOut"],
    1 << 13    : ["AP",   "Autopilot"],
    1 << 14    : ["PF",     "Perfect"],
    1 << 15    : ["K4",        "Key4"],
    1 << 16    : ["K5",        "Key5"],
    1 << 17    : ["K6",        "Key6"],
    1 << 18    : ["K7",        "Key7"],
    1 << 19    : ["K8",        "Key8"],
    1 << 20    : ["FI",      "FadeIn"],
    1 << 21    : ["RD",      "Random"],
    1 << 22    : ["CN",      "Cinema"],
    1 << 23    : ["TP",      "Target"],
    1 << 24    : ["K9",        "Key9"],
    1 << 25    : ["CO",     "KeyCoop"],
    1 << 26    : ["K1",        "Key1"],
    1 << 27    : ["K3",        "Key3"],
    1 << 28    : ["K2",        "Key2"],
    1 << 29    : ["V2",     "ScoreV2"],
    1 << 30    : ["MR",      "Mirror"]

}

class ModCombination():
    """
    An ingame osu! mod, or combination of mods.

    Notes
    -----
    This class is not meant to be instantiated. Use :class:`~.Mod` and combine
    them as necessary instead.

    A full list of mods and their specification can be found at
    https://osu.ppy.sh/help/wiki/Game_Modifiers.
    """

    def __init__(self, value):
        self.value = value

    def short_name(self):
        """
        The acronym-ized names of the component mods.

        Returns
        -------
        str
            The short name of this ModCombination.

        Examples
        --------
        >>> ModCombination(576).short_name()
        "NC"
        >>> ModCombination(24).short_name()
        "HDHR"

        Notes
        -----
        This is a function instead of an attribute set at initialization time
        because otherwise we couldn't refer to  :class:`~.Mod`\s as its class
        body isn't loaded while it's instantiating :class:`~.ModCombination`\s.

        Although technically mods such as NC are represented with two bits -
        DT and NC - being set, short_name removes DT and so returns "NC"
        rather than "DTNC".
        """
        if self.value in int_to_mod:
            # avoid infinite recursion with every mod decomposing into itself
            # ad infinitum
            return int_to_mod[self.value][0]
        else:
            component_mods = self.decompose(clean=True)
            return "".join(mod.short_name() for mod in component_mods)

    def long_name(self):
        """
        The spelled out names of the component mods.

        Returns
        -------
        str
            The long name of this ModCombination.

        Examples
        --------
        >>> ModCombination(576).long_name()
        "Nightcore"
        >>> ModCombination(24).long_name()
        "Hidden HardRock"

        Notes
        -----
        This is a function instead of an attribute set at initialization time
        because otherwise we couldn't refer to  :class:`~.Mod`\s as its class
        body isn't loaded while it's instantiating :class:`~.ModCombination`\s.

        Although technically mods such as NC are represented with two bits -
        DT and NC - being set, long_name removes DT and so returns "Nightcore"
        rather than "DoubleTime Nightcore".
        """

        if self.value in int_to_mod:
            return int_to_mod[self.value][1]
        else:
            component_mods = self.decompose(clean=True)
            return " ".join(mod.long_name() for mod in component_mods)

    def __eq__(self, other):
        """Compares the ``value`` of each object"""
        if type(other) is not ModCombination:
            return False
        return self.value == other.value

    def __ne__(self, other):
        """Compares the ``value`` of each object"""
        return self.value != other.value

    def __add__(self, other):
        """Returns a Mod representing the bitwise OR of the two Mods"""
        return ModCombination(self.value | other.value)

    def __sub__(self, other):
        return ModCombination(self.value & ~other.value)

    def __hash__(self):
        return self.value

    def __repr__(self):
        return f"ModCombination(value={self.value})"

    def __str__(self):
        return self.short_name()

    def __contains__(self, other):
        return bool(self.value & other.value)

    def decompose(self, clean=False):
        """
        Decomposes this mod into its base component mods, which are
        :class:`~.ModCombination`\s with a ``value`` of a power of two.

        Arguments
        ---------
        clean: bool
            If true, removes mods that we would think of as duplicate - if both
            NC and DT are component mods, remove DT. If both PF and SD are
            component mods, remove SD.

        Returns
        -------
        list[:class:`~.ModCombination`]
            A list of the component :class:`~.ModCombination`\s of this mod,
            ordered according to :const:`~circleguard.enums.Mod.ORDER`.
        """

        mods = [ModCombination(mod) for mod in int_to_mod.keys() if self.value & mod]
        mods = [mod for mod in Mod.ORDER if mod in mods] # order the mods by Mod.ORDER
        if not clean:
            return mods

        if Mod._NC in mods and Mod.DT in mods:
            mods.remove(Mod.DT)
        if Mod._PF in mods and Mod.SD in mods:
            mods.remove(Mod.SD)
        return mods

class Mod(ModCombination):
    """
    An ingame osu! mod.

    Common combinations are available as ``HDDT``, ``HDHR``, and ``HDDTHR``.

    Notes
    -----
    The nightcore mod is never set by itself. When we see plays set with ``NC``,
    we are really seeing a ``DT + NC`` play. ``NC`` by itself is ``512``, but
    what we expect to see is ``576`` (``512 + 64``; ``DT`` is ``64``). As such
    ``Mod.NC`` is defined to be the more intuitive version—``DT + NC``. We
    provide the true, technical version of the ``NC`` mod (``512``) as
    ``Mod._NC``.

    This same treatment and reasoning applies to ``Mod.PF``, which we define
    as ``PF + SD``. The technical version of PF is available as ``Mod._PF``.

    A full list of mods and their specification can be found at
    https://osu.ppy.sh/help/wiki/Game_Modifiers, or a more technical list at
    https://github.com/ppy/osu-api/wiki#mods.

    Warnings
    --------
    The fact that this class subclasses ModCombination is slightly misleading.
    This is only done so that this class can be instantiated directly, backed
    by an internal ModCombination, instead of exposing ModCombination to users.
    """

    NM  = NoMod        = ModCombination(0)
    NF  = NoFail       = ModCombination(1 << 0)
    EZ  = Easy         = ModCombination(1 << 1)
    TD  = TouchDevice  = ModCombination(1 << 2)
    HD  = Hidden       = ModCombination(1 << 3)
    HR  = HardRock     = ModCombination(1 << 4)
    SD  = SuddenDeath  = ModCombination(1 << 5)
    DT  = DoubleTime   = ModCombination(1 << 6)
    RX  = Relax        = ModCombination(1 << 7)
    HT  = HalfTime     = ModCombination(1 << 8)
    _NC = _Nightcore   = ModCombination(1 << 9)
    # most people will find it more useful for NC to be defined as it is ingame
    NC  = Nightcore    = _NC + DT
    FL  = Flashlight   = ModCombination(1 << 10)
    AT  = Autoplay     = ModCombination(1 << 11)
    SO  = SpunOut      = ModCombination(1 << 12)
    AP  = Autopilot    = ModCombination(1 << 13)
    _PF = _Perfect     = ModCombination(1 << 14)
    PF  = Perfect      = _PF + SD
    K4  = Key4         = ModCombination(1 << 15)
    K5  = Key5         = ModCombination(1 << 16)
    K6  = Key6         = ModCombination(1 << 17)
    K7  = Key7         = ModCombination(1 << 18)
    K8  = Key8         = ModCombination(1 << 19)
    FI  = FadeIn       = ModCombination(1 << 20)
    RD  = Random       = ModCombination(1 << 21)
    CN  = Cinema       = ModCombination(1 << 22)
    TP  = Target       = ModCombination(1 << 23)
    K9  = Key9         = ModCombination(1 << 24)
    CO  = KeyCoop      = ModCombination(1 << 25)
    K1  = Key1         = ModCombination(1 << 26)
    K3  = Key3         = ModCombination(1 << 27)
    K2  = Key2         = ModCombination(1 << 28)
    V2  = ScoreV2      = ModCombination(1 << 29)
    MR  = Mirror       = ModCombination(1 << 30)

    KM  = KeyMod       = K1+K2+K3+K4+K5+K6+K7+K8+K9+KeyCoop

    # common mod combinations
    HDDT = HD + DT
    HDHR = HD + HR
    HDDTHR = HD + DT + HR

    # how people naturally sort mods in combinations (HDDTHR, not DTHRHD)
    # sphinx uses repr() here
    # (see https://github.com/sphinx-doc/sphinx/issues/3857), so provide
    # our own, more human readable docstrings. #: denotes sphinx docstrings.
    #: [NM, EZ, HD, HT, DT, _NC, HR, FL, NF, SD, _PF, RX, AP, SO, AT, V2, TD,
    #: FI, RD, CN, TP, K1, K2, K3, K4, K5, K6, K7, K8, K9, CO, MR]
    ORDER = [NM, EZ, HD, HT, DT, _NC, HR, FL, NF, SD, _PF, RX, AP, SO, AT,
             V2, TD, # we stop caring about order after this point
             FI, RD, CN, TP, K1, K2, K3, K4, K5, K6, K7, K8, K9, CO, MR]

    def __init__(self, value):
        if isinstance(value, int):
            super().__init__(value)
        if isinstance(value, str):
            super().__init__(Mod._parse_mod_string(value))

    @staticmethod
    def _parse_mod_string(mod_string):
        """
        Creates an integer representation of a mod string made up of two letter
        mod names ("HDHR", for example).

        Arguments
        ---------
        mod_string: str
            The mod string to represent as an int.

        Returns
        -------
        int
            The integer representation of the mod string.

        Raises
        ------
        ValueError
            If mod_string is empty, not of even length, or any of its 2-length
            substrings do not correspond to a ModCombination in Mod.ORDER.
        """
        if mod_string == "":
            raise ValueError("Invalid mod string (cannot be empty)")
        if len(mod_string) % 2 != 0:
            raise ValueError(f"Invalid mod string {mod_string} (not of even length)")
        mod_value = 0
        for i in range(0, len(mod_string) - 1, 2):
            single_mod = mod_string[i: i + 2]
            # there better only be one Mod that has an acronym matching ours,
            # but a comp + 0 index works too
            matching_mods = [mod for mod in Mod.ORDER if mod.short_name() == single_mod]
            if not matching_mods:
                raise ValueError(f"Invalid mod string (no matching mod found for {single_mod})")
            mod_value += matching_mods[0].value
        return mod_value

class Detect():
    """
    A cheat, or set of cheats, to run tests to detect.

    Parameters
    ----------
    value: int
        One (or a bitwise combination) of :data:`~.Detect.STEAL`,
        :data:`~.Detect.RELAX`, :data:`~.Detect.CORRECTION`,
        :data:`~.Detect.ALL`. What cheats to detect.
    """
    STEAL = 1 << 0
    RELAX = 1 << 1
    CORRECTION = 1 << 2
    ALL = STEAL + RELAX + CORRECTION

    def __init__(self, value):
        self.value = value

        # so we can reference them in :func:`~.__add__`
        self.steal_thresh = None
        self.ur_thresh = None
        self.max_angle = None
        self.min_distance = None

    def __contains__(self, other):
        return bool(self.value & other)

    def __add__(self, other):
        ret = Detect(self.value | other.value)
        d = self if Detect.STEAL in self else other if Detect.STEAL in other else None
        if d:
            ret.steal_thresh = d.steal_thresh
        d = self if Detect.RELAX in self else other if Detect.RELAX in other else None
        if d:
            ret.ur_thresh = d.ur_thresh
        d = self if Detect.CORRECTION in self else other if Detect.CORRECTION in other else None
        if d:
            ret.max_angle = d.max_angle
            ret.min_distance = d.min_distance
        return ret

class StealDetect(Detect):
    """
    Defines a detection of replay stealing.

    Look at the average distance between the cursors of two replays.

    Parameters
    ----------
    steal_thresh: float
        If the average distance in pixels of two replays is smaller than
        this value, they are labeled cheated (stolen replays). Default 18.
    """
    def __init__(self, steal_thresh=18):
        super().__init__(Detect.STEAL)
        self.steal_thresh = steal_thresh

class RelaxDetect(Detect):
    """
    Defines a detection of relax.

    Look at the ur of a replay.

    Parameters
    ----------
    ur_thresh: float
        If the ur of a replay is less than this value, it is labeled cheated
        (relaxed).
    """
    def __init__(self, ur_thresh=50):
        super().__init__(Detect.RELAX)
        self.ur_thresh = ur_thresh

class CorrectionDetect(Detect):
    """
    Defines a detection of aim correction.

    Look at each set of three points (a,b,c) in a replay and calculate the
    angle between them. Look at points where this angle is extremely acute
    and neither ``|ab|`` or ``|bc|`` are small.

    Parameters
    ----------
    max_angle: float
        Consider only (a,b,c) where ``∠abc < max_angle``.
    min_distance: float
        Consider only (a,b,c) where ``|ab| > min_distance`` and
        ``|ab| > min_distance``.

    Notes
    -----
    A replay is considered cheated (aim corrected) by this detect if there
    is a single datapoint that satsfies both ``max_angle`` and
    ``min_distance``.
    """
    def __init__(self, max_angle=10, min_distance=8):
        super().__init__(Detect.CORRECTION)
        self.max_angle = max_angle
        self.min_distance = min_distance


class RatelimitWeight(Enum):
    """
    How much it 'costs' to load a replay from the api.

    :data:`~.RatelimitWeight.NONE` if the load method of a replay makes no api
    calls.

    :data:`~.RatelimitWeight.LIGHT` if the load method of a replay makes only
    light api calls (anything but ``get_replay``).

    :data:`~.RatelimitWeight.HEAVY` if the load method of a replay makes any
    heavy api calls (``get_replay``).

    Notes
    -----
    This value currently has no effect on the program and is reserved for
    future functionality.
    """

    NONE  = "None"
    LIGHT = "Light"
    HEAVY = "Heavy"

class ResultType(Enum):
    """
    What type of cheat test to represent the results for.
    """

    STEAL = "Replay Stealing"
    REMOD = "Remodding"
    RELAX = "Relax"
    CORRECTION = "Aim Correction"
    TIMEWARP = "Timewarp"

class Key(IntFlag):
    M1 = 1
    M2 = 2
    K1 = 4
    K2 = 8
    SMOKE = 16

# TODO remove in 4.x
# @deprecated
Keys = Key
