from .constants import PropertyAccess, ArgDirection
from .signature import SignatureTree, SignatureType
from .validators import assert_member_name_valid, assert_interface_name_valid
from .errors import InvalidIntrospectionError

from typing import List, Union

import xml.etree.ElementTree as ET

# https://dbus.freedesktop.org/doc/dbus-specification.html#introspection-format
# TODO annotations


class Arg:
    """A class that represents an input or output argument to a signal or a method.

    :ivar name: The name of this arg.
    :vartype name: str
    :ivar direction: Whether this is an input or an output argument.
    :vartype direction: :class:`ArgDirection <dbus_next.ArgDirection>`
    :ivar type: The parsed signature type of this argument.
    :vartype type: :class:`SignatureType <dbus_next.SignatureType>`
    :ivar signature: The signature string of this argument.
    :vartype signature: str

    :raises:
        - :class:`InvalidMemberNameError <dbus_next.InvalidMemberNameError>` - If the name of the arg is not valid.
        - :class:`InvalidSignatureError <dbus_next.InvalidSignatureError>` - If the signature is not valid.
        - :class:`InvalidIntrospectionError <dbus_next.InvalidIntrospectionError>` - If the signature is not a single complete type.
    """
    def __init__(self,
                 signature: Union[SignatureType, str],
                 direction: List[ArgDirection] = None,
                 name: str = None):
        if name is not None:
            assert_member_name_valid(name)

        type_ = None
        if type(signature) is SignatureType:
            type_ = signature
            signature = signature.signature
        else:
            tree = SignatureTree._get(signature)
            if len(tree.types) != 1:
                raise InvalidIntrospectionError(
                    f'an argument must have a single complete type. (has {len(tree.types)} types)')
            type_ = tree.types[0]

        self.type = type_
        self.signature = signature
        self.name = name
        self.direction = direction

    def from_xml(element: ET.Element, direction: ArgDirection) -> 'Arg':
        """Convert a :class:`xml.etree.ElementTree.Element` into a
        :class:`Arg`.

        The element must be valid DBus introspection XML for an ``arg``.

        :param element: The parsed XML element.
        :type element: :class:`xml.etree.ElementTree.Element`
        :param direction: The direction of this arg. Must be specified because it can default to different values depending on if it's in a method or signal.
        :type direction: :class:`ArgDirection <dbus_next.ArgDirection>`

        :raises:
            - :class:`InvalidIntrospectionError <dbus_next.InvalidIntrospectionError>` - If the XML tree is not valid introspection data.
        """
        name = element.attrib.get('name')
        signature = element.attrib.get('type')

        if not signature:
            raise InvalidIntrospectionError('a method argument must have a "type" attribute')

        return Arg(signature, direction, name)

    def to_xml(self) -> ET.Element:
        """Convert this :class:`Arg` into an :class:`xml.etree.ElementTree.Element`.
        """
        element = ET.Element('arg')
        if self.name:
            element.set('name', self.name)

        if self.direction:
            element.set('direction', self.direction.value)
        element.set('type', self.signature)

        return element


class Signal:
    """A class that represents a signal exposed on an interface.

    :ivar name: The name of this signal
    :vartype name: str
    :ivar args: A list of output arguments for this signal.
    :vartype args: list(Arg)
    :ivar signature: The collected signature of the output arguments.
    :vartype signature: str

    :raises:
        - :class:`InvalidMemberNameError <dbus_next.InvalidMemberNameError>` - If the name of the signal is not a valid member name.
    """
    def __init__(self, name: str, args: List[Arg] = None):
        if name is not None:
            assert_member_name_valid(name)

        self.name = name
        self.args = args or []
        self.signature = ''.join(arg.signature for arg in self.args)

    def from_xml(element):
        """Convert an :class:`xml.etree.ElementTree.Element` to a :class:`Signal`.

        The element must be valid DBus introspection XML for a ``signal``.

        :param element: The parsed XML element.
        :type element: :class:`xml.etree.ElementTree.Element`
        :param is_root: Whether this is the root node
        :type is_root: bool

        :raises:
            - :class:`InvalidIntrospectionError <dbus_next.InvalidIntrospectionError>` - If the XML tree is not valid introspection data.
        """
        name = element.attrib.get('name')
        if not name:
            raise InvalidIntrospectionError('signals must have a "name" attribute')

        args = []
        for child in element:
            if child.tag == 'arg':
                args.append(Arg.from_xml(child, ArgDirection.OUT))

        signal = Signal(name, args)

        return signal

    def to_xml(self) -> ET.Element:
        """Convert this :class:`Signal` into an :class:`xml.etree.ElementTree.Element`.
        """
        element = ET.Element('signal')
        element.set('name', self.name)

        for arg in self.args:
            element.append(arg.to_xml())

        return element


class Method:
    """A class that represents a method exposed on an :class:`Interface`.

    :ivar name: The name of this method.
    :vartype name: str
    :ivar in_args: A list of input arguments to this method.
    :vartype in_args: list(Arg)
    :ivar out_args: A list of output arguments to this method.
    :vartype out_args: list(Arg)
    :ivar in_signature: The collected signature string of the input arguments.
    :vartype in_signature: str
    :ivar out_signature: The collected signature string of the output arguments.
    :vartype out_signature: str

    :raises:
        - :class:`InvalidMemberNameError <dbus_next.InvalidMemberNameError>` - If the name of this method is not valid.
    """
    def __init__(self, name: str, in_args: List[Arg] = [], out_args: List[Arg] = []):
        assert_member_name_valid(name)

        self.name = name
        self.in_args = in_args
        self.out_args = out_args
        self.in_signature = ''.join(arg.signature for arg in in_args)
        self.out_signature = ''.join(arg.signature for arg in out_args)

    def from_xml(element: ET.Element) -> 'Method':
        """Convert an :class:`xml.etree.ElementTree.Element` to a :class:`Method`.

        The element must be valid DBus introspection XML for a ``method``.

        :param element: The parsed XML element.
        :type element: :class:`xml.etree.ElementTree.Element`
        :param is_root: Whether this is the root node
        :type is_root: bool

        :raises:
            - :class:`InvalidIntrospectionError <dbus_next.InvalidIntrospectionError>` - If the XML tree is not valid introspection data.
        """
        name = element.attrib.get('name')
        if not name:
            raise InvalidIntrospectionError('interfaces must have a "name" attribute')

        in_args = []
        out_args = []

        for child in element:
            if child.tag == 'arg':
                direction = ArgDirection(child.attrib.get('direction', 'in'))
                arg = Arg.from_xml(child, direction)
                if direction == ArgDirection.IN:
                    in_args.append(arg)
                elif direction == ArgDirection.OUT:
                    out_args.append(arg)

        return Method(name, in_args, out_args)

    def to_xml(self) -> ET.Element:
        """Convert this :class:`Method` into an :class:`xml.etree.ElementTree.Element`.
        """
        element = ET.Element('method')
        element.set('name', self.name)

        for arg in self.in_args:
            element.append(arg.to_xml())
        for arg in self.out_args:
            element.append(arg.to_xml())

        return element


class Property:
    """A class that represents a DBus property exposed on an
    :class:`Interface`.

    :ivar name: The name of this property.
    :vartype name: str
    :ivar signature: The signature string for this property. Must be a single complete type.
    :vartype signature: str
    :ivar access: Whether this property is readable and writable.
    :vartype access: :class:`PropertyAccess <dbus_next.PropertyAccess>`
    :ivar type: The parsed type of this property.
    :vartype type: :class:`SignatureType <dbus_next.SignatureType>`

    :raises:
        - :class:`InvalidIntrospectionError <dbus_next.InvalidIntrospectionError>` - If the property is not a single complete type.
        - :class `InvalidSignatureError <dbus_next.InvalidSignatureError>` - If the given signature is not valid.
        - :class: `InvalidMemberNameError <dbus_next.InvalidMemberNameError>` - If the member name is not valid.
    """
    def __init__(self,
                 name: str,
                 signature: str,
                 access: PropertyAccess = PropertyAccess.READWRITE):
        assert_member_name_valid(name)

        tree = SignatureTree._get(signature)
        if len(tree.types) != 1:
            raise InvalidIntrospectionError(
                f'properties must have a single complete type. (has {len(tree.types)} types)')

        self.name = name
        self.signature = signature
        self.access = access
        self.type = tree.types[0]

    def from_xml(element):
        """Convert an :class:`xml.etree.ElementTree.Element` to a :class:`Property`.

        The element must be valid DBus introspection XML for a ``property``.

        :param element: The parsed XML element.
        :type element: :class:`xml.etree.ElementTree.Element`

        :raises:
            - :class:`InvalidIntrospectionError <dbus_next.InvalidIntrospectionError>` - If the XML tree is not valid introspection data.
        """
        name = element.attrib.get('name')
        signature = element.attrib.get('type')
        access = PropertyAccess(element.attrib.get('access', 'readwrite'))

        if not name:
            raise InvalidIntrospectionError('properties must have a "name" attribute')
        if not signature:
            raise InvalidIntrospectionError('properties must have a "type" attribute')

        return Property(name, signature, access)

    def to_xml(self) -> ET.Element:
        """Convert this :class:`Property` into an :class:`xml.etree.ElementTree.Element`.
        """
        element = ET.Element('property')
        element.set('name', self.name)
        element.set('type', self.signature)
        element.set('access', self.access.value)
        return element


class Interface:
    """A class that represents a DBus interface exported on on object path.

    Contains information about the methods, signals, and properties exposed on
    this interface.

    :ivar name: The name of this interface.
    :vartype name: str
    :ivar methods: A list of methods exposed on this interface.
    :vartype methods: list(:class:`Method`)
    :ivar signals: A list of signals exposed on this interface.
    :vartype signals: list(:class:`Signal`)
    :ivar properties: A list of properties exposed on this interface.
    :vartype properties: list(:class:`Property`)

    :raises:
        - :class:`InvalidInterfaceNameError <dbus_next.InvalidInterfaceNameError>` - If the name is not a valid interface name.
    """
    def __init__(self,
                 name: str,
                 methods: List[Method] = None,
                 signals: List[Signal] = None,
                 properties: List[Property] = None):
        assert_interface_name_valid(name)

        self.name = name
        self.methods = methods if methods is not None else []
        self.signals = signals if signals is not None else []
        self.properties = properties if properties is not None else []

    @staticmethod
    def from_xml(element: ET.Element) -> 'Interface':
        """Convert a :class:`xml.etree.ElementTree.Element` into a
        :class:`Interface`.

        The element must be valid DBus introspection XML for an ``interface``.

        :param element: The parsed XML element.
        :type element: :class:`xml.etree.ElementTree.Element`

        :raises:
            - :class:`InvalidIntrospectionError <dbus_next.InvalidIntrospectionError>` - If the XML tree is not valid introspection data.
        """
        name = element.attrib.get('name')
        if not name:
            raise InvalidIntrospectionError('interfaces must have a "name" attribute')

        interface = Interface(name)

        for child in element:
            if child.tag == 'method':
                interface.methods.append(Method.from_xml(child))
            elif child.tag == 'signal':
                interface.signals.append(Signal.from_xml(child))
            elif child.tag == 'property':
                interface.properties.append(Property.from_xml(child))

        return interface

    def to_xml(self) -> ET.Element:
        """Convert this :class:`Interface` into an :class:`xml.etree.ElementTree.Element`.
        """
        element = ET.Element('interface')
        element.set('name', self.name)

        for method in self.methods:
            element.append(method.to_xml())
        for signal in self.signals:
            element.append(signal.to_xml())
        for prop in self.properties:
            element.append(prop.to_xml())

        return element


class Node:
    """A class that represents a node in an object path in introspection data.

    A node contains information about interfaces exported on this path and
    child nodes. A node can be converted to and from introspection XML exposed
    through the ``org.freedesktop.DBus.Introspectable`` standard DBus
    interface.

    This class is an essential building block for a high-level DBus interface.
    This is the underlying data structure for the :class:`ProxyObject
    <dbus_next.proxy_object.BaseProxyInterface>`.  A :class:`ServiceInterface
    <dbus_next.service.ServiceInterface>` definition is converted to this class
    to expose XML on the introspectable interface.

    :ivar interfaces: A list of interfaces exposed on this node.
    :vartype interfaces: list(:class:`Interface <dbus_next.introspection.Interface>`)
    :ivar nodes: A list of child nodes.
    :vartype nodes: list(:class:`Node`)
    :ivar name: The object path of this node.
    :vartype name: str
    :ivar is_root: Whether this is the root node. False if it is a child node.
    :vartype is_root: bool

    :raises:
        - :class:`InvalidIntrospectionError <dbus_next.InvalidIntrospectionError>` - If the name is not a valid node name.
    """
    def __init__(self, name: str = None, interfaces: List[Interface] = None, is_root: bool = True):
        if not is_root and not name:
            raise InvalidIntrospectionError('child nodes must have a "name" attribute')

        self.interfaces = interfaces if interfaces is not None else []
        self.nodes = []
        self.name = name
        self.is_root = is_root

    @staticmethod
    def from_xml(element: ET.Element, is_root: bool = False):
        """Convert an :class:`xml.etree.ElementTree.Element` to a :class:`Node`.

        The element must be valid DBus introspection XML for a ``node``.

        :param element: The parsed XML element.
        :type element: :class:`xml.etree.ElementTree.Element`
        :param is_root: Whether this is the root node
        :type is_root: bool

        :raises:
            - :class:`InvalidIntrospectionError <dbus_next.InvalidIntrospectionError>` - If the XML tree is not valid introspection data.
        """
        node = Node(element.attrib.get('name'), is_root=is_root)

        for child in element:
            if child.tag == 'interface':
                node.interfaces.append(Interface.from_xml(child))
            elif child.tag == 'node':
                node.nodes.append(Node.from_xml(child))

        return node

    @staticmethod
    def parse(data: str) -> 'Node':
        """Parse XML data as a string into a :class:`Node`.

        The string must be valid DBus introspection XML.

        :param data: The XMl string.
        :type data: str

        :raises:
            - :class:`InvalidIntrospectionError <dbus_next.InvalidIntrospectionError>` - If the string is not valid introspection data.
        """
        element = ET.fromstring(data)
        if element.tag != 'node':
            raise InvalidIntrospectionError(
                'introspection data must have a "node" for the root element')

        return Node.from_xml(element, is_root=True)

    def to_xml(self) -> ET.Element:
        """Convert this :class:`Node` into an :class:`xml.etree.ElementTree.Element`.
        """
        element = ET.Element('node')

        if self.name:
            element.set('name', self.name)

        for interface in self.interfaces:
            element.append(interface.to_xml())
        for node in self.nodes:
            element.append(node.to_xml())

        return element

    def tostring(self) -> str:
        """Convert this :class:`Node` into a DBus introspection XML string.
        """
        header = '<!DOCTYPE node PUBLIC "-//freedesktop//DTD D-BUS Object Introspection 1.0//EN"\n"http://www.freedesktop.org/standards/dbus/1.0/introspect.dtd">\n'

        def indent(elem, level=0):
            i = "\n" + level * "    "
            if len(elem):
                if not elem.text or not elem.text.strip():
                    elem.text = i + "  "
                if not elem.tail or not elem.tail.strip():
                    elem.tail = i
                for elem in elem:
                    indent(elem, level + 1)
                if not elem.tail or not elem.tail.strip():
                    elem.tail = i
            else:
                if level and (not elem.tail or not elem.tail.strip()):
                    elem.tail = i

        xml = self.to_xml()
        indent(xml)
        return header + ET.tostring(xml, encoding='unicode').rstrip()

    @staticmethod
    def default(name: str = None) -> 'Node':
        """Create a :class:`Node` with the default interfaces supported by this library.

        The default interfaces include:

        * ``org.freedesktop.DBus.Introspectable``
        * ``org.freedesktop.DBus.Peer``
        * ``org.freedesktop.DBus.Properties``
        * ``org.freedesktop.DBus.ObjectManager``
        """
        return Node(name,
                    is_root=True,
                    interfaces=[
                        Interface('org.freedesktop.DBus.Introspectable',
                                  methods=[
                                      Method('Introspect',
                                             out_args=[Arg('s', ArgDirection.OUT, 'data')])
                                  ]),
                        Interface('org.freedesktop.DBus.Peer',
                                  methods=[
                                      Method('GetMachineId',
                                             out_args=[Arg('s', ArgDirection.OUT, 'machine_uuid')]),
                                      Method('Ping')
                                  ]),
                        Interface('org.freedesktop.DBus.Properties',
                                  methods=[
                                      Method('Get',
                                             in_args=[
                                                 Arg('s', ArgDirection.IN, 'interface_name'),
                                                 Arg('s', ArgDirection.IN, 'property_name')
                                             ],
                                             out_args=[Arg('v', ArgDirection.OUT, 'value')]),
                                      Method('Set',
                                             in_args=[
                                                 Arg('s', ArgDirection.IN, 'interface_name'),
                                                 Arg('s', ArgDirection.IN, 'property_name'),
                                                 Arg('v', ArgDirection.IN, 'value')
                                             ]),
                                      Method('GetAll',
                                             in_args=[Arg('s', ArgDirection.IN, 'interface_name')],
                                             out_args=[Arg('a{sv}', ArgDirection.OUT, 'props')])
                                  ],
                                  signals=[
                                      Signal('PropertiesChanged',
                                             args=[
                                                 Arg('s', ArgDirection.OUT, 'interface_name'),
                                                 Arg('a{sv}', ArgDirection.OUT,
                                                     'changed_properties'),
                                                 Arg('as', ArgDirection.OUT,
                                                     'invalidated_properties')
                                             ])
                                  ]),
                        Interface('org.freedesktop.DBus.ObjectManager',
                                  methods=[
                                      Method('GetManagedObjects',
                                             out_args=[
                                                 Arg('a{oa{sa{sv}}}', ArgDirection.OUT,
                                                     'objpath_interfaces_and_properties')
                                             ]),
                                  ],
                                  signals=[
                                      Signal('InterfacesAdded',
                                             args=[
                                                 Arg('o', ArgDirection.OUT, 'object_path'),
                                                 Arg('a{sa{sv}}', ArgDirection.OUT,
                                                     'interfaces_and_properties'),
                                             ]),
                                      Signal('InterfacesRemoved',
                                             args=[
                                                 Arg('o', ArgDirection.OUT, 'object_path'),
                                                 Arg('as', ArgDirection.OUT, 'interfaces'),
                                             ])
                                  ]),
                    ])
