import xml.etree.ElementTree as ET
from typing import List, Union

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

# 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_fast.ArgDirection>`
    :ivar type: The parsed signature type of this argument.
    :vartype type: :class:`SignatureType <dbus_fast.SignatureType>`
    :ivar signature: The signature string of this argument.
    :vartype signature: str

    :raises:
        - :class:`InvalidMemberNameError <dbus_fast.InvalidMemberNameError>` - If the name of the arg is not valid.
        - :class:`InvalidSignatureError <dbus_fast.InvalidSignatureError>` - If the signature is not valid.
        - :class:`InvalidIntrospectionError <dbus_fast.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 = get_signature_tree(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_fast.ArgDirection>`

        :raises:
            - :class:`InvalidIntrospectionError <dbus_fast.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_fast.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_fast.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_fast.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_fast.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_fast.PropertyAccess>`
    :ivar type: The parsed type of this property.
    :vartype type: :class:`SignatureType <dbus_fast.SignatureType>`

    :raises:
        - :class:`InvalidIntrospectionError <dbus_fast.InvalidIntrospectionError>` - If the property is not a single complete type.
        - :class `InvalidSignatureError <dbus_fast.InvalidSignatureError>` - If the given signature is not valid.
        - :class: `InvalidMemberNameError <dbus_fast.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 = get_signature_tree(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_fast.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_fast.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_fast.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_fast.proxy_object.BaseProxyInterface>`.  A :class:`ServiceInterface
    <dbus_fast.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_fast.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_fast.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_fast.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_fast.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"),
                            ],
                        ),
                    ],
                ),
            ],
        )
