from collections import OrderedDict
from copy import copy
from inspect import getfullargspec, ismethod

from coala_decorators.decorators import enforce_signature
from coalib.settings.DocstringMetadata import DocstringMetadata


class FunctionMetadata:
    str_nodesc = "No description given."
    str_optional = "Optional, defaults to '{}'."

    @enforce_signature
    def __init__(self,
                 name: str,
                 desc: str="",
                 retval_desc: str="",
                 non_optional_params: (dict, None)=None,
                 optional_params: (dict, None)=None,
                 omit: (set, tuple, list, frozenset)=frozenset()):
        """
        Creates the FunctionMetadata object.

        :param name:                The name of the function.
        :param desc:                The description of the function.
        :param retval_desc:         The retval description of the function.
        :param non_optional_params: A dict containing the name of non optional
                                    parameters as the key and a tuple of a
                                    description and the python annotation. To
                                    preserve the order, use OrderedDict.
        :param optional_params:     A dict containing the name of optional
                                    parameters as the key and a tuple
                                    of a description, the python annotation and
                                    the default value. To preserve the order,
                                    use OrderedDict.
        :param omit:                A set of parameters to omit.
        """
        if non_optional_params is None:
            non_optional_params = OrderedDict()
        if optional_params is None:
            optional_params = OrderedDict()

        self.name = name
        self._desc = desc
        self.retval_desc = retval_desc
        self._non_optional_params = non_optional_params
        self._optional_params = optional_params
        self.omit = set(omit)

    @property
    def desc(self):
        """
        Returns description of the function.
        """
        return self._desc

    @desc.setter
    @enforce_signature
    def desc(self, new_desc: str):
        """
        Set's the description to the new_desc.
        """
        self._desc = new_desc

    def _filter_out_omitted(self, params):
        """
        Filters out parameters that are to omit. This is a helper method for
        the param related properties.

        :param params: The parameter dictionary to filter.
        :return:       The filtered dictionary.
        """
        return OrderedDict(filter(lambda p: p[0] not in self.omit,
                                  tuple(params.items())))

    @property
    def non_optional_params(self):
        """
        Retrieves a dict containing the name of non optional parameters as the
        key and a tuple of a description and the python annotation. Values that
        are present in self.omit will be omitted.
        """
        return self._filter_out_omitted(self._non_optional_params)

    @property
    def optional_params(self):
        """
        Retrieves a dict containing the name of optional parameters as the key
        and a tuple of a description, the python annotation and the default
        value. Values that are present in self.omit will be omitted.
        """
        return self._filter_out_omitted(self._optional_params)

    def create_params_from_section(self, section):
        """
        Create a params dictionary for this function that holds all values the
        function needs plus optional ones that are available.

        :param section:    The section to retrieve the values from.
        :return:           The params dictionary.
        """
        params = {}

        for param in self.non_optional_params:
            _, annotation = self.non_optional_params[param]
            params[param] = self._get_param(param, section, annotation)

        for param in self.optional_params:
            if param in section:
                _, annotation, _ = self.optional_params[param]
                params[param] = self._get_param(param, section, annotation)

        return params

    @staticmethod
    def _get_param(param, section, annotation):
        if annotation is None:
            annotation = lambda x: x

        try:
            return annotation(section[param])
        except (TypeError, ValueError):
            raise ValueError("Unable to convert parameter {!r} into type "
                             "{}.".format(param, annotation))

    @classmethod
    def from_function(cls, func, omit=frozenset()):
        """
        Creates a FunctionMetadata object from a function. Please note that any
        variable argument lists are not supported. If you do not want the
        first (usual named 'self') argument to appear please pass the method of
        an actual INSTANCE of a class; passing the method of the class isn't
        enough. Alternatively you can add "self" to the omit set.

        :param func: The function. If __metadata__ of the unbound function is
                     present it will be copied and used, otherwise it will be
                     generated.
        :param omit: A set of parameter names that are to be ignored.
        :return:     The FunctionMetadata object corresponding to the given
                     function.
        """
        if hasattr(func, "__metadata__"):
            metadata = copy(func.__metadata__)
            metadata.omit = omit
            return metadata

        doc = func.__doc__ or ""
        doc_comment = DocstringMetadata.from_docstring(doc)

        non_optional_params = OrderedDict()
        optional_params = OrderedDict()

        argspec = getfullargspec(func)
        args = argspec.args or ()
        defaults = argspec.defaults or ()
        num_non_defaults = len(args) - len(defaults)
        for i, arg in enumerate(args):
            # Implicit self argument or omitted explicitly
            if i < 1 and ismethod(func):
                continue

            if i < num_non_defaults:
                non_optional_params[arg] = (
                    doc_comment.param_dict.get(arg, cls.str_nodesc),
                    argspec.annotations.get(arg, None))
            else:
                optional_params[arg] = (
                    doc_comment.param_dict.get(arg, cls.str_nodesc) + " (" +
                    cls.str_optional.format(
                        defaults[i-num_non_defaults]) + ")",
                    argspec.annotations.get(arg, None),
                    defaults[i-num_non_defaults])

        return cls(name=func.__name__,
                   desc=doc_comment.desc,
                   retval_desc=doc_comment.retval_desc,
                   non_optional_params=non_optional_params,
                   optional_params=optional_params,
                   omit=omit)

    def filter_parameters(self, dct):
        """
        Filters the given dict for keys that are declared as parameters inside
        this metadata (either optional or non-optional).

        You can use this function to safely pass parameters from a given
        dictionary:

        >>> def multiply(a, b=2, c=0):
        ...     return a * b + c
        >>> metadata = FunctionMetadata.from_function(multiply)
        >>> args = metadata.filter_parameters({'a': 10, 'b': 20, 'd': 30})

        You can safely pass the arguments to the function now:

        >>> multiply(**args)  # 10 * 20
        200

        :param dct:
            The dict to filter.
        :return:
            A new dict containing the filtered items.
        """
        return {key: dct[key]
                for key in (self.non_optional_params.keys() |
                            self.optional_params.keys())
                if key in dct}

    @classmethod
    def merge(cls, *metadatas):
        """
        Merges signatures of ``FunctionMetadata`` objects.

        Parameter (either optional or non-optional) and non-parameter
        descriptions are merged from left to right, meaning the right hand
        metadata overrides the left hand one.

        >>> def a(x, y):
        ...     '''
        ...     desc of *a*
        ...     :param x: x of a
        ...     :param y: y of a
        ...     :return:  5*x*y
        ...     '''
        ...     return 5 * x * y
        >>> def b(x):
        ...     '''
        ...     desc of *b*
        ...     :param x: x of b
        ...     :return:  100*x
        ...     '''
        ...     return 100 * x
        >>> metadata1 = FunctionMetadata.from_function(a)
        >>> metadata2 = FunctionMetadata.from_function(b)
        >>> merged = FunctionMetadata.merge(metadata1, metadata2)
        >>> merged.name
        "<Merged signature of 'a', 'b'>"
        >>> merged.desc
        'desc of *b*'
        >>> merged.retval_desc
        '100*x'
        >>> merged.non_optional_params['x'][0]
        'x of b'
        >>> merged.non_optional_params['y'][0]
        'y of a'

        :param metadatas:
            The sequence of metadatas to merge.
        :return:
            A ``FunctionMetadata`` object containing the merged signature of
            all given metadatas.
        """
        # Collect the metadatas, as we operate on them more often and we want
        # to support arbitrary sequences.
        metadatas = tuple(metadatas)

        merged_name = ("<Merged signature of " +
                       ", ".join(repr(metadata.name)
                                 for metadata in metadatas) +
                       ">")

        merged_desc = next((m.desc for m in reversed(metadatas) if m.desc), "")
        merged_retval_desc = next(
            (m.retval_desc for m in reversed(metadatas) if m.retval_desc), "")
        merged_non_optional_params = {}
        merged_optional_params = {}

        for metadata in metadatas:
            # Use the fields and not the properties to get also omitted
            # parameters.
            merged_non_optional_params.update(metadata._non_optional_params)
            merged_optional_params.update(metadata._optional_params)

        merged_omit = set.union(*(metadata.omit for metadata in metadatas))

        return cls(merged_name,
                   merged_desc,
                   merged_retval_desc,
                   merged_non_optional_params,
                   merged_optional_params,
                   merged_omit)
