# Copyright 2017-2020 Kamil Sindi
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import inspect
import types
import sys

from pkg_resources import get_distribution, DistributionNotFound


__title__ = 'implements'
__author__ = ('Kamil Sindi <ksindi@ksindi.com>, '
              'Praveen G Shirali <praveengshirali@gmail.com>')
__license__ = 'Apache License, Version 2.0'


try:
    __version__ = get_distribution(__name__).version
except DistributionNotFound:
    # package is not installed
    pass


__all__ = ['Interface', 'implements']


class Interface:
    pass


def implements(interface_cls):
    """Verifies whether the decorated class implements the interface as
    defined by the `interface_cls`.
    """
    def _decorator(cls):
        verify_class_hierarchy(interface_cls, cls)
        errors = []
        errors.extend(verify_methods(interface_cls, cls))
        errors.extend(verify_properties(interface_cls, cls))
        errors.extend(verify_attributes(interface_cls, cls))
        if errors:
            raise NotImplementedError(
                'Found {} errors in implementation:\n- {}\nwith {}'.format(
                    len(errors), '\n- '.join(errors), cls))
        return cls

    return _decorator


def get_mro(cls):
    return cls.mro()[:-1] if cls.mro()[-1] is object else cls.mro()


def verify_class_hierarchy(ifc, cls):
    ifc_mro = get_mro(ifc)
    cls_mro = get_mro(cls)
    common = set(ifc_mro) & set(cls_mro)
    if len(common):
        raise ValueError(
            "Found {} common classes between the implementation and the "
            "interface. Expected none. The implementation class and any "
            "class in its class-hierarchy, must not inherit from the "
            "interface class, or any class from the interface hierarchy. "
            "Common classes: [{}]"
            "".format(len(common), ", ".join([str(s) for s in common]))
        )


def getobj_via_dict(cls, name):
    for c in cls.__mro__:
        if name in c.__dict__:
            return c.__dict__[name]
    return None


def is_classmethod(obj):
    if sys.version_info < (3, 7):
        clsmethod_ident = classmethod
    else:
        clsmethod_ident = (classmethod, types.ClassMethodDescriptorType)
    return isinstance(obj, clsmethod_ident)


def is_staticmethod(obj):
    return isinstance(obj, (staticmethod, types.BuiltinMethodType))


def verify_method_type(method_typer, expected_type,
                       name, ifc_obj, cls_obj, ifc_name, cls_name):
    """Verify a method's type across interface and implementation. Raises
    an exception if they don't match.

    Args:
        method_typer (callable):
            A method type checker (single argument callable) which returns
            True if the supplied argument matches the method type
        expected_type (string):
            A string representation of the expected method type. This is
            used in the exception string
        name (string):
            Name of the attribute being checked
        ifc_obj (object):
            The fetched object from the interface, matched by name
        cls_obj (object):
            The fetched object from the implementation, matched by name
        ifc_name (string):
            Name of the interface class
        cls_name (string):
            Name of the implementation class
    """
    errors = []
    if method_typer(ifc_obj):
        if not method_typer(cls_obj):
            errors.append(
                "'{}' must implement '{}' as {} as defined in interface '{}'"
                "".format(cls_name, name, expected_type, ifc_name)
            )
    return errors


def verify_methods(interface_cls, cls):
    def methods_predicate(m):
        return inspect.isfunction(m) or inspect.ismethod(m)

    errors = []
    for name, method in inspect.getmembers(interface_cls, methods_predicate):
        signature = inspect.signature(method)
        cls_method = getattr(cls, name, None)
        cls_signature = None
        ifc_name = interface_cls.__name__
        cls_name = cls.__name__
        if cls_method and callable(cls_method):
            cls_signature = inspect.signature(cls_method)
        ifc_obj = getobj_via_dict(interface_cls, name)
        cls_obj = getobj_via_dict(cls, name)

        method_types_to_check = [
            (is_classmethod, "a classmethod"),
            (is_staticmethod, "a staticmethod"),
            (inspect.isasyncgenfunction, "an async genenerator-function"),
            (inspect.isgeneratorfunction, "a generator-function"),
            (inspect.iscoroutinefunction, "a coroutine-function")
        ]
        for (method_typer, expected_type) in method_types_to_check:
            errors.extend(
                verify_method_type(method_typer, expected_type,
                                   name, ifc_obj, cls_obj, ifc_name, cls_name)
            )

        if cls_signature != signature:
            errors.append(
                "'{}' must implement method '{}{}' defined in interface '{}'"
                .format(cls_name, name, signature, ifc_name)
            )
    return errors


def verify_properties(interface_cls, cls):
    errors = []
    prop_attrs = dict(fget='getter', fset='setter', fdel='deleter')
    descriptors = inspect.getmembers(interface_cls, inspect.isdatadescriptor)
    for name, prop in descriptors:
        cls_prop = getattr(cls, name, None)
        for attr in prop_attrs:
            # instanceof doesn't work for class function comparison
            ifc_prop_obj = getattr(prop, attr, None)
            cls_prop_obj = getattr(cls_prop, attr, None)
            if ifc_prop_obj:
                cls_name = cls.__name__
                ifc_name = interface_cls.__name__
                proptype = prop_attrs[attr]

                # -- verify presence and type of data-descriptors
                if type(ifc_prop_obj) != type(cls_prop_obj):
                    errors.append(
                        "'{}' must implement a {} for property '{}' defined "
                        "in interface '{}'"
                        "".format(cls_name, proptype, name, ifc_name)
                    )
                    continue

                # -- verify signatures of data-descriptors
                ifc_prop_sig = inspect.signature(ifc_prop_obj)
                cls_prop_sig = None
                if callable(cls_prop_obj):
                    cls_prop_sig = inspect.signature(cls_prop_obj)
                if ifc_prop_sig != cls_prop_sig:
                    errors.append(
                        "'{}' must implement a {} for property '{}' with the "
                        "same signature as defined in interface '{}'"
                        "".format(cls_name, proptype, name, ifc_name)
                    )
    return errors


def verify_attributes(interface_cls, cls):
    errors = []
    interface_attributes = get_attributes(interface_cls)
    cls_attributes = get_attributes(cls)
    for missing_attr in interface_attributes - cls_attributes:
        errors.append(
            "'{}' must have class attribute '{}' defined in interface '{}'"
            .format(cls.__name__, missing_attr, interface_cls.__name__)
        )
    return errors


def get_attributes(cls):
    boring = dir(type('dummy', (object,), {}))
    return set(item[0] for item in inspect.getmembers(cls)  # skipcq: PTC-W0015
               if item[0] not in boring and not callable(item[1]))
