import importlib
import json
from typing import Callable, List
from typing import Any
from copy import deepcopy
from functools import partial

from madas import Material

from .utils import report_error, JSONNumpyEncoder

def import_builtin_module(fp_type: str, 
                          file_suffix: str = '_fingerprint', 
                          class_suffix: str = 'Fingerprint', 
                          similarity_measure_suffix: str = '_similarity'):
    """
    Function to import built-in fingerprint classes and similarity measures from different files.
    """
    module_name = 'madas.fingerprints.' + fp_type + file_suffix
    module = importlib.import_module(module_name)
    class_name = fp_type + class_suffix
    similarity_measure_name = fp_type + similarity_measure_suffix
    fingerprint_class = getattr(module, class_name)
    similarity_function = getattr(module, similarity_measure_name)
    return fingerprint_class, similarity_function

class Fingerprint():
    """
    Generic description of a fingerprint.

    Intended use case is subclassing to integrate with the `madas` framework.

    Arbitrary data can be passed to the Fingerprint object by using `Fingerprint().set_data(<key>: str, <data>: Any)`. 
    It can be retrieved either through the property `Fingerprint().data` as a dictionary, or directly via `Fingerprint()[<key>]`.

    **Parameters:**

    fp_type: `str`, `type`, or `None`
        Type of fingerprint. The behavior changes based on the type of the argument:

            - `None`: Don't intialize the fingerprint type. The object will be a generic `Fingerprint` 
            - `type`: Intialize as a fingerprint of the given type. All keyword arguments will be passed to the child class.
            - `str`: Initialize as a built-in fingerprint type using the `importfunction`. All keyword arguments will be passed to the child class.

        default: `None`

    name: `str`
        Name of the fingerprint. Can be used to distinguish between fingerprints of the same type with different parameters.

    similarity_function: `Callable` or `None`
        Function used to evaluate the similarity to other fingerprints.

        This function will be called when `Fingerprint().get_similarity()` is called.

        If set to `None` upon initialization and a fingerprint type is given, the respective
        similarity function will be set automatically.

        If set to a `callable`, *use this similarity function instead of any other*, 
        even if a fingerprint type is given.

        default: `None`

    pass_on_exceptions: `bool`
        In order to allow processing of big amounts of data without interruptions,
        the fingerprint can return 0 similarity instead of raising exceptions.
        A warning will be printed if the calculation of similarity is not successful.

        default: `False`

    importfunction: `callable`
        Function that imports fingerprint classes if the fingerprint type is provided as a string.

        default: `madas.fingerprint.import_builtin_module`

    **Methods:**
    """

    def __init__(self, fp_type: str | type | None = None, 
                       name: str | None = None,
                       similarity_function: Callable | None = None,
                       pass_on_exceptions: bool = False,
                       importfunction: Callable = import_builtin_module, 
                       **kwargs) -> None:
        if isinstance(fp_type, str) or fp_type is None:
            fingerprint_class = None
        elif isinstance(fp_type, type):
            fingerprint_class = fp_type
            fp_type = fp_type.__name__
        else:
            raise TypeError("Attibute fp_type must be either a string or a Fingerprint child type")
        name = self._name_from_type(name, fp_type)
        self._safe_set_attribute("similarity_function", similarity_function)
        self._safe_set_attribute("_fp_type", fp_type)
        self._safe_set_attribute("_name", name)
        self._safe_set_attribute("_pass_on_exceptions", pass_on_exceptions)
        self._safe_set_attribute("_data", {})
        self._safe_set_attribute("_mid", "unknown")
        if self.fp_type is not None and type(self) == Fingerprint:
            if fingerprint_class is None:
                self.set_import_function(importfunction)
                fingerprint_class, similarity_function_imported = self.importfunction(fp_type)
                similarity_function = similarity_function if similarity_function is not None else similarity_function_imported
            self.specify(fingerprint_class, **kwargs)
            #if self.similarity_function is None:
            self.set_similarity_function(similarity_function)
 
    @property
    def data(self) -> dict:
        """
        Get data dictionary of the Fingerprint.
        """
        try:
            return self._data
        except AttributeError:
            return {}

    @property
    def mid(self):
        """
        Material identifier property.
        """
        try:
            return self._mid
        except AttributeError:
            return "unknown"

    @property
    def pass_on_exceptions(self):
        """
        Property that controls if exceptions should be raised.
        """
        try:
            return self._pass_on_exceptions
        except AttributeError:
            return True

    @property
    def fp_type(self):
        """
        Property that contains the type of fingerprint.
        """
        try:
            return self._fp_type
        except AttributeError:
            return self.__class__.__name__

    @property 
    def name(self):
        """
        Property that contains the name of fingerprint.
        """
        try:
            return self._name
        except AttributeError:
            return self.fp_type

    def set_pass_on_exceptions(self, behavior: bool) -> None:
        """
        Set if fingerprint should pass and return None if an exception occures during calculation of similarities, or raise an Exception.

        **Arguments:**

        behavior: *bool*
            Set behavior.

            True -> Return *None* if exceptions are raised during calculation of similarities.

            False -> Raise exceptions.

        """
        self._pass_on_exceptions = behavior

    def set_data(self, key: str, data: Any) -> None:
        """
        Set data entries of the Fingerprint.

        **Arguments:**
        
        key: *str*
            Name of the property that is meant to be stored
        
        data: *Any*
            Properties to be stored as fingerprint data 

        """
        try:
            self._data[key] = data
        except AttributeError:
            self._data = {}
            self._data[key] = data

    def set_mid(self, mid: str | Material) -> None:
        """
        Set material id of Fingerprint.

        **Arguments:**

        mid: *str* or *Material*
            Material id to be set.

            If a Material is given, the mid will be extracted from there.

        **Returns:**

        None
        """
        if isinstance(mid, Material):
            mid = mid.mid
        self._mid = mid

    def set_fp_type(self, fp_type: str):
        if not isinstance(fp_type, str):
            raise TypeError("This function only sets the string describing the fingerprint. To specify the type of fingerprint, use the function specify().")
        self._fp_type = fp_type

    def set_name(self, name: str):
        self._name = name

    def get_similarity(self, other: object) -> float:
        """
        Calculate similarity to another fingerprint. 
        
        **Arguments:**
    
        other: *Fingerpint*
            Other fingerprint to calculate similarity

        **Returns:**

        similarity: *float*
            Similarity between both fingerprints

        **Raises:**

        TypeError: Different fingerprint types are used.
        """
        if not self._check_type_compatibility(other):
            return 0
        try:
            similarity = self.similarity_function(self, other)
            return similarity
        except Exception as exception:
            error_message = f'Could not calculate similarity for materials: {self.mid} and {other.mid} because of error: {str(exception)}'
            report_error(None, error_message)
            if self.pass_on_exceptions:
                report_error(None, "Setting similarity to 0.")
                return 0
            else:
                raise exception

    def get_similarities(self, fingerprint_list: list) -> List[float]:
        """
        Calculate similarity to all other fingerprints in *fingerprint_list*. 
        
        **Arguments:**
    
        fingerprint_list: *List[Fingerpint]*
            Other fingerprints to calculate similarity

        **Returns:**

        similarities: *List[float]*
            Similarities of self to all other fingerprints

        **Raises:**

        TypeError: Different fingerprint types are used.
        """
        similarities = [self.get_similarity(fp) for fp in fingerprint_list]
        return similarities

    def set_similarity_function(self, similarity_function: Callable, **kwargs) -> object:
        """
        Set the function for calculating similarity and return self.

        **Arguments:**
        
        similarity_function: *Callable* 
            Function that takes two fingerprint objects as input and calculates the similarity between them

        Keyword arguments are passed to the function.
        """
        if len(kwargs) != 0:
            self.similarity_function = partial(similarity_function, **kwargs)
        else:
           self.similarity_function = similarity_function
        return self

    def set_import_function(self, import_function: Callable) -> None:
        """
        Set the function used to import specialized fingerprint objects.
        
        **Arguments:**

        import_function: *Callable*; 
            A function that returns a specialized Fingerprint class and a similarity function.
        """
        self.importfunction = import_function

    def specify(self, fingerprint_class, **kwargs):
        """
        Change self to class given by ``fingerprint_class``.
        
        **Arguments:**

        fingerprint_class: *type*
            Subclass of *Fingerprint*

        Keyword arguments are passed to the __init__ function of the specialized fingerprint.
        """
        self.__class__ = fingerprint_class
        self.__init__(**kwargs)

    def calculate(self, mid: str, *args, **kwargs) -> object | None:
        """
        To be defined in child class. Calculate the fingerprint from values.
        
        **Expected return value:**
        
        self: *Fingerprint*
            It is expected that the object returns itself after calling *calculate*.
        """
        self.set_mid(mid)
        return self
    
    def from_material(self, material: Material, *args, **kwargs):
        """
        To be defined in child class. Calculate the fingerprint from a Material object.
        
        **Expected return value:**
        
        self: *Fingerprint*
            It is expected that the object returns itself after calling *calculate*.
        """
        self.set_mid(material)
        return self

    def get_data_json(self) -> str:
        """
        Return fingerprint data in as a json-coded string.
        """
        json_data = json.dumps(self.data, cls=JSONNumpyEncoder)
        return json_data
    
    def copy(self) -> object:
        """
        Return a deep copy of the fingerprint.
        """
        return deepcopy(self)

    def serialize(self) -> str:
        """
        Write fingerprint data and metadata to dictionary or string.

        **Returns:**

        data: *str*
            json dump of fingerprint
        """
        data = {
            "mid" : self.mid, 
            "name" : self.name,
            "fp_type" : self.fp_type,
            "data" : self.get_data_json(),
            "pass_on_exceptions" : self.pass_on_exceptions 
            }
        return json.dumps(data)

    @classmethod
    def deserialize(cls, data: str) -> object:
        """
        Read a fingerprint object from string data.
        This returns a *Fingerprint* object, _not_ a subclass of it. 
        The similarity function needs to be set separately, and calculate() won't work.

        **Arguments:**
        
        data: *str*
            Sting data of fingerprint (json serialized), obtained by Fingerprint().serialize().

        **Returns:**

        fingerprint: *Fingerprint*
            Fingerprint object with all fields set. 
        """
        data = json.loads(data)
        self = cls()
        self.set_mid(data["mid"])
        self.set_name(data["name"])
        self.set_fp_type(data["fp_type"])
        self.set_pass_on_exceptions(data["pass_on_exceptions"])
        self.from_data(json.loads(data["data"]))
        return self

    @staticmethod
    def _name_from_type(name, fp_type):
        if name is None:
            if isinstance(fp_type, type):
                name = fp_type.__name__
            else:
                name = str(fp_type)
        return name

    def from_data(self, data: dict): #TODO test, and make sure this is what you want
        self._data = data
        return self

    def _check_type_compatibility(self, other):
        if not isinstance(other, Fingerprint):
            error_message = f"Object of type {type(other)} is not a Fingerprint(). Can not calculate similarity"
            if self.pass_on_exceptions:
                report_error(None, error_message)
                return False
            else:
                raise TypeError(error_message)
        if not self.fp_type == other.fp_type:
            error_message = f"Can not calculate similarity for fingerprints of different types. self: {self.fp_type}, other: {other.fp_type}"
            if self.pass_on_exceptions:
                report_error(None, error_message)
                return False
            else:
                raise TypeError(error_message)
        return True

    def _safe_set_attribute(self, attibute_name: str, default: Any):
        if not hasattr(self, attibute_name):
            self.__setattr__(attibute_name, default)

    def __getitem__(self, key):
        return self.data[key]

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, Fingerprint):
            return False
        if self.fp_type == other.fp_type:
            if self.mid == other.mid:
                if self.name == other.name:
                    if self.get_data_json() == other.get_data_json():
                        return True
        return False

    def __repr__(self) -> str:
        return f"Fingerprint({self.fp_type}, mid = {self.mid}, name = {self.name})"
