#! python3

""" Collection of mixins for additional functionality """

# Built-in libraries
import math
import json

from pathlib import Path
from typing import List, Dict, Union, Any, NewType
from copy import deepcopy
from functools import lru_cache
from abc import ABCMeta, abstractmethod

# 3rd-party libraries
import toml

# Local libraries
from settings import *


class DataFileMixin(metaclass=ABCMeta):
    """ Contains methods for getting game data from files """

    @staticmethod
    @lru_cache(maxsize=128, typed=True)
    def _get_by_ID(ID: int, obj_type: str, file: str, file_format: str=DATA_FORMAT) -> Dict:
        """ 'Low-level' access to filedata """
        with open(file) as f:
            if file_format == "json":
                data = json.load(f, parse_int=int, parse_float=float)
            elif file_format == "toml":
                data = toml.load(f)
            else:
                raise NotImplementedError(f"Missing support for opening files of type: {file_format}")
        return data[obj_type][str(ID)]

    def get_item_by_ID(self, ID: int, file: str=ITEM_FILE) -> Dict:
        """ Returns a dictionary representation of a given item ID """
        return self._get_by_ID(ID, 'items', file)

    def get_enemy_by_ID(self, ID: int, file: str=ENEMY_FILE) -> Dict:
        """ Returns a dictionary representation of a given enemy ID """
        return self._get_by_ID(ID, 'enemies', file)

    def get_npc_by_ID(self, ID: int, file: str=NPC_FILE) -> Dict:
        """ Returns a dictionary representation of a given NPC ID """
        return self._get_by_ID(ID, 'NPCs', file)

    def get_entity_by_ID(self, ID: int, file: str=ENTITY_FILE) -> Dict:
        """ Returns a dictionary representation of a given entity ID """
        return self._get_by_ID(ID, 'entities', file)


class ReprMixin(metaclass=ABCMeta):
    """ Automatically generates a __repr__-method for any class """

    def __repr__(self):
        """ Automatically generated __repr__-method """

        attributes = [f"{key}={value}" if type(value) != str
                     else f'{key}="{value}"'
                     for key, value in vars(self).items()]
        v_string = ", ".join(attributes)
        class_name = self.__class__.__name__
        return f"{class_name}({v_string})"


class LevelMixin(metaclass=ABCMeta):
    """ Gives standard level-up mechanics for the child class """
    def __init__(self, **kwargs):
        """
        Initialises the child class' attributes

        Accepted kwargs:
        - level: combat level, defaults to 1
        - max_level: sets a level cap for the object, defaults to None
        - exp: current experience points, defaults to 0
        - exponent: modifies the XP curve required to level-up, defaults to 1.6
        - base_exp: sets the EXP required to reach level 2, defaults to 85
        """
        #super(LevelMixin, self).__init__()
        self.level: int = int(kwargs.get("level", 1))
        self.experience: int = int(kwargs.get("exp", 0))
        self.exponent: float = float(kwargs.get("exponent", 1.6))
        self._base_exp: int = int(kwargs.get("base_exp", 85))
        self.max_level: Union[int, None] = kwargs.get("max_level", None)

    @property
    def next_level(self) -> int:
        """
        Returns the amount of EXP needed for the next level.

        No built-in level cap.
        """

        return math.floor(self._base_exp * (self.level**self.exponent))

    def level_up(self, print_exp: bool=False) -> Union[str, None]:
        """
        Checks if object has acquired enough EXP to level up.

        If it has, levelup() will increment the player's level and run again until
        the object's EXP isn't enough for the next level, or until max_level is reached
        (unless max_level is None).

        Ends by printing the amount of EXP required to reach the next level.
        If max_level has been set and level >= max_level, nothing is printed.

        This method has an optional argument, print_exp, which will determine if
        the EXP is printed in the end. By default, it will be printed if a level is gained.
        By changing it to True, the EXP required is printed regardless.
        By changing it to None, the EXP is never printed.
        """

        results = []
        gained_levels = 0

        while self.next_level <= self.experience:
            if self.level == self.max_level:
                break

            if self.max_level is None or self.level < self.max_level:
                self.level += 1
                gained_levels += 1
                if print_exp is not None:
                    print_exp = True

        if gained_levels == 1:
            results.append(f"Congratulations! You've levelled up; your new level is {self.level}")
        elif gained_levels > 1:
            results.append(f"Congratulations! You levelled up {gained_levels} times in one go. Your new level is {self.level}")

        if print_exp and (self.max_level is None or self.level < self.max_level):
            results.append(f"EXP required for next level: {int(self.next_level-self.experience)}")

        if print_exp:
            results.append(f"Current EXP: {self.experience}")

        formatted_results = "\n".join(results)
        return formatted_results

    def give_exp(self, amount: int, check_level_up=True, print_exp=False) -> Union[str, None]:
        """
        Give the object experience points.

        Automatically checks for level-up unless set to False.
        """
        self.experience += amount
        if check_level_up:
            return self.level_up(print_exp)
        return None

class SpritesMixin(metaclass=ABCMeta):
    """
    Contains methods for loading sprites for game objects

    Future: Implement other general sprite functionality
    """

    @staticmethod
    def __load_sprites(type: str, obj: str) -> List: #TODO: Fill in the sprite object type
        path = Path(__file__).parent / "img" / type / obj
        sprites = []
        for sprite in path.resolve().glob('**/*'):
            #TODO: Implement file loading as file objects
            sprites.append(str(sprite))
        return sprites

    def load_char_sprites(self, name: str):
        return self.__load_sprites('chars', name.lower())

    def load_item_sprites(self, ID: int):
        return self.__load_sprites('items', str(ID))