#
# Server support for DBus interfaces
#
# Copyright (C) 2019  Red Hat, Inc.  All rights reserved.
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301
# USA
#
# For more info about DBus specification see:
# https://dbus.freedesktop.org/doc/dbus-specification.html#introspection-format
#
import inspect
import re

from inspect import Parameter
from typing import get_type_hints

from dasbus.namespace import get_dbus_name
from dasbus.signal import Signal
from dasbus.specification import DBusSpecificationError, DBusSpecification
from dasbus.typing import get_dbus_type
from dasbus.xml import XMLGenerator

__all__ = [
    "dbus_class",
    "dbus_interface",
    "dbus_signal",
    "get_xml",
    "accepts_additional_arguments",
    "are_additional_arguments_supported"
]

# Class attribute for the XML specification.
DBUS_XML_ATTRIBUTE = "__dbus_xml__"

# Method attribute for the @accepts_additional_arguments decorator.
ACCEPTS_ADDITIONAL_ARGUMENTS_ATTRIBUTE = \
    "__dbus_handler_accepts_additional_arguments__"


def accepts_additional_arguments(method):
    """Decorator for accepting extra arguments in a DBus method.

    The decorator allows the server object handler to propagate
    additional information about the DBus call into the decorated
    method.

    Use a dictionary of keyword arguments:

    .. code-block:: python

        @accepts_additional_arguments
        def Method(x: Int, y: Str, **info):
            pass

    Or use keyword only parameters:

    .. code-block:: python

        @accepts_additional_arguments
        def Method(x: Int, y: Str, *, call_info):
            pass

    At this moment, the library provides only the call_info argument
    generated by GLibServer.get_call_info, but the additional arguments
    can be customized in the _get_additional_arguments method of the
    server object handler.

    :param method: a DBus method
    :return: a DBus method with a flag
    """
    setattr(method, ACCEPTS_ADDITIONAL_ARGUMENTS_ATTRIBUTE, True)
    return method


def are_additional_arguments_supported(method):
    """Does the given DBus method accept additional arguments?

    :param method: a DBus method
    :return: True or False
    """
    return getattr(method, ACCEPTS_ADDITIONAL_ARGUMENTS_ATTRIBUTE, False)


class dbus_signal(object):
    """DBus signal.

    Can be used as:

    .. code-block:: python

        Signal = dbus_signal()

    Or as a method decorator:

    .. code-block:: python

        @dbus_signal
        def Signal(x: Int, y: Double):
            pass

    Signal is defined by the type hints of a decorated method.
    This method is accessible as: signal.definition

    If the signal is not defined by a method, it is expected to
    have no arguments and signal.definition is equal to None.
    """
    def __init__(self, definition=None, factory=Signal):
        """Create a signal descriptor.

        :param definition: a definition of the emit function
        :param factory: a signal factory
        """
        self.definition = definition
        self.factory = factory
        self.name = None

    def __set_name__(self, owner, name):
        """Set a name of the descriptor

        The descriptor has been assigned to the specified name.
        Generate a name of a private attribute that will be set
        to a signal in the ``__get__`` method.

        For example: ``__dbus_signal_my_name``

        :param owner: the owning class
        :param name: the descriptor name
        """
        if self.name is not None:
            return

        self.name = "__{}_{}".format(
            type(self).__name__.lower(),
            name.lower()
        )

    def __get__(self, instance, owner):
        """Get a value of the descriptor.

        If the descriptor is accessed as a class attribute,
        return the descriptor.

        If the descriptor is accessed as an instance attribute,
        return a signal created by the signal factory.

        :param instance: an instance of the owning class
        :param owner: an owning class
        :return: a value of the attribute
        """
        if instance is None:
            return self

        signal = getattr(instance, self.name, None)

        if signal is None:
            signal = self.factory()
            setattr(instance, self.name, signal)

        return signal

    def __set__(self, instance, value):
        """Set a value of the descriptor."""
        raise AttributeError("Can't set DBus signal.")


def dbus_interface(interface_name, namespace=()):
    """DBus interface.

    A new DBus interface can be defined as:

    .. code-block:: python

        @dbus_interface
        class Interface():
            ...

    The interface will be generated from the given class cls
    with a name interface_name and added to the DBus XML
    specification of the class.

    The XML specification is accessible as:
    .. code-block:: python

        Interface.__dbus_xml__

    :param interface_name: a DBus name of the interface
    :param namespace: a sequence of strings
    """
    def decorated(cls):
        name = get_dbus_name(*namespace, interface_name)
        xml = DBusSpecificationGenerator.generate_specification(cls, name)
        setattr(cls, DBUS_XML_ATTRIBUTE, xml)
        return cls

    return decorated


def dbus_class(cls):
    """DBus class.

    A new DBus class can be defined as:

    .. code-block:: python

        @dbus_class
        class Class(Interface):
            ...

    DBus class can implement DBus interfaces, but it cannot
    define a new interface.

    The DBus XML specification will be generated from
    implemented interfaces (inherited) and it will be
    accessible as:

    .. code-block:: python

        Class.__dbus_xml__

    """
    xml = DBusSpecificationGenerator.generate_specification(cls)
    setattr(cls, DBUS_XML_ATTRIBUTE, xml)
    return cls


def get_xml(obj):
    """Return XML specification of an object.

    :param obj: an object decorated with @dbus_interface or @dbus_class
    :return: a string with XML specification
    """
    xml_specification = getattr(obj, DBUS_XML_ATTRIBUTE, None)

    if xml_specification is None:
        raise DBusSpecificationError(
            "XML specification is not defined at '{}'.".format(
                DBUS_XML_ATTRIBUTE
            )
        )

    return xml_specification


class DBusSpecificationGenerator(object):
    """Class for generating DBus XML specification."""

    # The XML generator.
    xml_generator = XMLGenerator

    # The pattern of a DBus member name.
    NAME_PATTERN = re.compile(r'[A-Z][A-Za-z0-9]*')

    @classmethod
    def generate_specification(cls, interface_cls, interface_name=None):
        """Generates DBus XML specification for given class.

        If class defines a new interface, it will be added to
        the specification.

        :param interface_cls: class object to decorate
        :param str interface_name: name of the interface defined by class
        :return str: DBus specification in XML
        """
        # Collect all interfaces that class inherits.
        interfaces = cls._collect_interfaces(interface_cls)

        # Generate a new interface.
        if interface_name:
            all_interfaces = cls._collect_standard_interfaces()
            all_interfaces.update(interfaces)
            interface = cls._generate_interface(
                interface_cls,
                all_interfaces,
                interface_name
            )
            interfaces[interface_name] = interface

        # Generate XML specification for the given class.
        node = cls._generate_node(interface_cls, interfaces)
        return cls.xml_generator.element_to_xml(node)

    @classmethod
    def _collect_standard_interfaces(cls):
        """Collect standard interfaces.

        Standard interfaces are implemented by default.

        :return: a dictionary of standard interfaces
        """
        node = cls.xml_generator.xml_to_element(
            DBusSpecification.STANDARD_INTERFACES
        )
        return cls.xml_generator.get_interfaces_from_node(node)

    @classmethod
    def _collect_interfaces(cls, interface_cls):
        """Collect interfaces implemented by the class.

        Returns a dictionary that maps interface names
        to interface elements.

        :param interface_cls: a class object
        :return: a dictionary of implemented interfaces
        """
        interfaces = dict()

        # Visit interface_cls and base classes in reversed order.
        for member in reversed(inspect.getmro(interface_cls)):
            # Skip classes with no specification.
            member_xml = getattr(member, DBUS_XML_ATTRIBUTE, None)
            if not member_xml:
                continue

            # Update found interfaces.
            node = cls.xml_generator.xml_to_element(member_xml)
            node_interfaces = cls.xml_generator.get_interfaces_from_node(node)
            interfaces.update(node_interfaces)

        return interfaces

    @classmethod
    def _generate_interface(cls, interface_cls, interfaces, interface_name):
        """Generate interface defined by given class.

        :param interface_cls: a class object that defines the interface
        :param interfaces: a dictionary of implemented interfaces
        :param interface_name: a name of the new interface
        :return: a new interface element

        :raises DBusSpecificationError: if a class member cannot be exported
        """
        interface = cls.xml_generator.create_interface(interface_name)

        # Search class members.
        for member_name, member in inspect.getmembers(interface_cls):
            # Check it the name is exportable.
            if not cls._is_exportable(member_name):
                continue

            # Skip names already defined in implemented interfaces.
            if cls._is_defined(interfaces, member_name):
                continue

            # Generate XML element for exportable member.
            if cls._is_signal(member):
                element = cls._generate_signal(member, member_name)
            elif cls._is_property(member):
                element = cls._generate_property(member, member_name)
            elif cls._is_method(member):
                element = cls._generate_method(member, member_name)
            else:
                raise DBusSpecificationError(
                    "Unsupported definition of DBus member '{}'.".format(
                        member_name
                    )
                )

            # Add generated element to the interface.
            cls.xml_generator.add_child(interface, element)

        return interface

    @classmethod
    def _is_exportable(cls, member_name):
        """Is the name of a class member exportable?

        The name is exportable if it follows the DBus specification.
        Only CamelCase names are allowed.
        """
        return bool(cls.NAME_PATTERN.fullmatch(member_name))

    @classmethod
    def _is_defined(cls, interfaces, member_name):
        """Is the member name defined in given interfaces?

        :param interfaces: a dictionary of interfaces
        :param member_name: a name of the class member
        :return: True if the name is defined, otherwise False
        """
        for interface in interfaces.values():
            for member in interface:
                # Is it a signal, a property or a method?
                if not cls.xml_generator.is_member(member):
                    continue
                # Does it have the same name?
                if not cls.xml_generator.has_name(member, member_name):
                    continue
                # The member is already defined.
                return True

        return False

    @classmethod
    def _is_signal(cls, member):
        """Is the class member a DBus signal?"""
        return isinstance(member, dbus_signal)

    @classmethod
    def _generate_signal(cls, member, member_name):
        """Generate signal defined by a class member.

        :param member: a dbus_signal object.
        :param member_name: a name of the signal
        :return: a signal element

        raises DBusSpecificationError: if signal has defined return type
        """
        element = cls.xml_generator.create_signal(member_name)
        method = member.definition

        if not method:
            return element

        for name, type_hint, direction in cls._iterate_parameters(method):
            # Only input parameters can be defined.
            if direction == DBusSpecification.DIRECTION_OUT:
                raise DBusSpecificationError(
                    "Invalid return type of DBus signal "
                    "'{}'.".format(member_name)
                )

            # All parameters are exported as output parameters
            # (see specification).
            direction = DBusSpecification.DIRECTION_OUT
            parameter = cls.xml_generator.create_parameter(
                name,
                get_dbus_type(type_hint),
                direction
            )
            cls.xml_generator.add_child(element, parameter)

        return element

    @classmethod
    def _iterate_parameters(cls, member):
        """Iterate over method parameters.

        For every parameter returns its name, a type hint and a direction.

        :param member: a method object
        :return: an iterator

        raises DBusSpecificationError: if parameters are invalid
        """
        # Get type hints for parameters.
        type_hints = get_type_hints(member)

        # Get method signature.
        signature = inspect.signature(member)

        # Iterate over method parameters, skip cls.
        for name in list(signature.parameters)[1:]:
            # Check the kind of the parameter
            kind = signature.parameters[name].kind

            # Ignore **kwargs and all arguments after * and *args
            # if the method supports additional arguments.
            if kind in (Parameter.VAR_KEYWORD, Parameter.KEYWORD_ONLY) \
                    and are_additional_arguments_supported(member):
                continue

            if kind != Parameter.POSITIONAL_OR_KEYWORD:
                raise DBusSpecificationError(
                    "Only positional or keyword arguments are allowed."
                )

            # Check if the type is defined.
            if name not in type_hints:
                raise DBusSpecificationError(
                    "Undefined type of parameter '{}'.".format(name)
                )

            yield name, type_hints[name], DBusSpecification.DIRECTION_IN

        # Is the return type defined?
        if signature.return_annotation is signature.empty:
            return

        # Is the return type other than None?
        if signature.return_annotation is None:
            return

        yield (
            DBusSpecification.RETURN_PARAMETER,
            signature.return_annotation,
            DBusSpecification.DIRECTION_OUT
        )

    @classmethod
    def _is_property(cls, member):
        """Is the class member a DBus property?"""
        return isinstance(member, property)

    @classmethod
    def _generate_property(cls, member, member_name):
        """Generate DBus property defined by class member.

        :param member: a property object
        :param member_name: a property name
        :return: a property element

        raises DBusSpecificationError: if the property is invalid
        """
        access = None
        type_hint = None

        try:
            # Process the setter.
            if member.fset:
                [(_, type_hint, _)] = cls._iterate_parameters(member.fset)
                access = DBusSpecification.ACCESS_WRITE

            # Process the getter.
            if member.fget:
                [(_, type_hint, _)] = cls._iterate_parameters(member.fget)
                access = DBusSpecification.ACCESS_READ

        except ValueError:
            raise DBusSpecificationError(
                "Undefined type of DBus property '{}'.".format(member_name)
            ) from None

        # Property has both.
        if member.fget and member.fset:
            access = DBusSpecification.ACCESS_READWRITE

        if access is None:
            raise DBusSpecificationError(
                "DBus property '{}' is not accessible.".format(member_name)
            )

        return cls.xml_generator.create_property(
            member_name,
            get_dbus_type(type_hint),
            access
        )

    @classmethod
    def _is_method(cls, member):
        """Is the class member a DBus method?

        Ignore the difference between instance method and class method.

        For example:

        .. code-block:: python

            class Foo(object):
                def bar(cls, x):
                    pass

            inspect.isfunction(Foo.bar) # True
            inspect.isfunction(Foo().bar) # False

            inspect.ismethod(Foo.bar) # False
            inspect.ismethod(Foo().bar) # True

            _is_method(Foo.bar) # True
            _is_method(Foo().bar) # True

        """
        return inspect.ismethod(member) or inspect.isfunction(member)

    @classmethod
    def _generate_method(cls, member, member_name):
        """Generate method defined by given class member.

        :param member: a method object
        :param member_name: a name of the method
        :return: a method element
        """
        method = cls.xml_generator.create_method(member_name)

        # Process the parameters.
        for name, type_hint, direction in cls._iterate_parameters(member):
            # Create the parameter element.
            parameter = cls.xml_generator.create_parameter(
                name, get_dbus_type(type_hint), direction
            )
            # Add the element to the method element.
            cls.xml_generator.add_child(method, parameter)

        return method

    @classmethod
    def _generate_node(cls, interface_cls, interfaces):
        """Generate node element that specifies the given class.

        :param interface_cls: a class object
        :param interfaces: a dictionary of interfaces
        :return: a node element
        """
        node = cls.xml_generator.create_node()

        # Add comment about specified class.
        cls.xml_generator.add_comment(
            node, "Specifies {}".format(interface_cls.__name__)
        )

        # Add interfaces sorted by their names.
        for interface_name in sorted(interfaces.keys()):
            cls.xml_generator.add_child(node, interfaces[interface_name])

        return node
