#!/usr/bin/python
from collections import OrderedDict

from airunner.vendor.nodegraphqt.base.commands import (
    NodeVisibleCmd,
    NodeWidgetVisibleCmd,
)
from airunner.vendor.nodegraphqt.base.node import NodeObject
from airunner.vendor.nodegraphqt.base.port import Port
from airunner.vendor.nodegraphqt.constants import (
    NodePropWidgetEnum,
    PortTypeEnum,
)
from airunner.vendor.nodegraphqt.errors import (
    PortError,
    PortRegistrationError,
    NodeWidgetError,
)
from airunner.vendor.nodegraphqt.qgraphics.node_base import NodeItem
from airunner.vendor.nodegraphqt.widgets.node_widgets import (
    NodeBaseWidget,
    NodeCheckBox,
    NodeComboBox,
    NodeLineEdit,
)


class BaseNode(NodeObject):
    """
    The ``airunner.vendor.nodegraphqt.BaseNode`` class is the base class for nodes that allows
    port connections from one node to another.

    .. inheritance-diagram:: airunner.vendor.nodegraphqt.BaseNode

    .. image:: ../_images/node.png
        :width: 250px

    example snippet:

    .. code-block:: python
        :linenos:

        from airunner.vendor.nodegraphqt import BaseNode

        class ExampleNode(BaseNode):

            # unique node identifier domain.
            __identifier__ = 'io.jchanvfx.github'

            # initial default node name.
            NODE_NAME = 'My Node'

            def __init__(self):
                super(ExampleNode, self).__init__()

                # create an input port.
                self.add_input('in')

                # create an output port.
                self.add_output('out')
    """

    NODE_NAME = "Node"

    def __init__(self, qgraphics_item=None):
        super(BaseNode, self).__init__(qgraphics_item or NodeItem)
        self._inputs = []
        self._outputs = []

    def update_model(self):
        """
        Update the node model from view.
        """
        for name, val in self.view.properties.items():
            if name in ["inputs", "outputs"]:
                continue
            self.model.set_property(name, val)

        for name, widget in self.view.widgets.items():
            self.model.set_property(name, widget.get_value())

    def set_property(self, name, value, push_undo=True):
        """
        Set the value on the node custom property.

        Args:
            name (str): name of the property.
            value (object): property data (python built in types).
            push_undo (bool): register the command to the undo stack. (default: True)
        """
        # prevent signals from causing a infinite loop.
        if self.get_property(name) == value:
            return

        if name == "visible":
            if self.graph:
                undo_cmd = NodeVisibleCmd(self, value)
                if push_undo:
                    self.graph.undo_stack().push(undo_cmd)
                else:
                    undo_cmd.redo()
                return
        elif name == "disabled":
            # redraw the connected pipes in the scene.
            ports = self.view.inputs + self.view.outputs
            for port in ports:
                for pipe in port.connected_pipes:
                    pipe.update()
        super(BaseNode, self).set_property(name, value, push_undo)

    def set_layout_direction(self, value=0):
        """
        Sets the node layout direction to either horizontal or vertical on
        the current node only.

        `Implemented in` ``v0.3.0``

        See Also:
            :meth:`NodeGraph.set_layout_direction`,
            :meth:`NodeObject.layout_direction`


        Warnings:
            This function does not register to the undo stack.

        Args:
            value (int): layout direction mode.
        """
        # base logic to update the model and view attributes only.
        super(BaseNode, self).set_layout_direction(value)
        # redraw the node.
        self._view.draw_node()

    def set_icon(self, icon=None):
        """
        Set the node icon.

        Args:
            icon (str): path to the icon image.
        """
        self.set_property("icon", icon)

    def icon(self):
        """
        Node icon path.

        Returns:
            str: icon image file path.
        """
        return self.model.icon

    def widgets(self):
        """
        Returns all embedded widgets from this node.

        See Also:
            :meth:`BaseNode.get_widget`

        Returns:
            dict: embedded node widgets. {``property_name``: ``node_widget``}
        """
        return self.view.widgets

    def get_widget(self, name):
        """
        Returns the embedded widget associated with the property name.

        See Also:
            :meth:`BaseNode.add_combo_menu`,
            :meth:`BaseNode.add_text_input`,
            :meth:`BaseNode.add_checkbox`,

        Args:
            name (str): node property name.

        Returns:
            NodeBaseWidget: embedded node widget.
        """
        return self.view.widgets.get(name)

    def add_custom_widget(self, widget, widget_type=None, tab=None):
        """
        Add a custom node widget into the node.

        see example :ref:`Embedding Custom Widgets`.

        Note:
            The ``value_changed`` signal from the added node widget is wired
            up to the :meth:`NodeObject.set_property` function.

        Args:
            widget (NodeBaseWidget): node widget class object.
            widget_type: widget flag to display in the
                :class:`airunner.vendor.nodegraphqt.PropertiesBinWidget`
                (default: :attr:`airunner.vendor.nodegraphqt.constants.NodePropWidgetEnum.HIDDEN`).
            tab (str): name of the widget tab to display in.
        """
        if not isinstance(widget, NodeBaseWidget):
            raise NodeWidgetError(
                "'widget' must be an instance of a NodeBaseWidget"
            )

        widget_type = widget_type or NodePropWidgetEnum.HIDDEN.value
        self.create_property(
            widget.get_name(),
            widget.get_value(),
            widget_type=widget_type,
            tab=tab,
        )
        widget.value_changed.connect(lambda k, v: self.set_property(k, v))
        widget._node = self
        self.view.add_widget(widget)
        #: redraw node to address calls outside the "__init__" func.
        self.view.draw_node()

        #: HACK: calling the .parent() function here on the widget as it seems
        #        to address a seg fault issue when exiting the application.
        widget.parent()

    def add_combo_menu(
        self, name, label="", items=None, tooltip=None, tab=None
    ):
        """
        Creates a custom property with the :meth:`NodeObject.create_property`
        function and embeds a :class:`PySide2.QtWidgets.QComboBox` widget
        into the node.

        Note:
            The ``value_changed`` signal from the added node widget is wired
            up to the :meth:`NodeObject.set_property` function.

        Args:
            name (str): name for the custom property.
            label (str): label to be displayed.
            items (list[str]): items to be added into the menu.
            tooltip (str): widget tooltip.
            tab (str): name of the widget tab to display in.
        """
        self.create_property(
            name,
            value=items[0] if items else None,
            items=items or [],
            widget_type=NodePropWidgetEnum.QCOMBO_BOX.value,
            widget_tooltip=tooltip,
            tab=tab,
        )
        widget = NodeComboBox(self.view, name, label, items)
        widget.setToolTip(tooltip or "")
        widget.value_changed.connect(lambda k, v: self.set_property(k, v))
        self.view.add_widget(widget)
        #: redraw node to address calls outside the "__init__" func.
        self.view.draw_node()

    def add_text_input(
        self,
        name,
        label="",
        text="",
        placeholder_text="",
        tooltip=None,
        tab=None,
    ):
        """
        Creates a custom property with the :meth:`NodeObject.create_property`
        function and embeds a :class:`PySide2.QtWidgets.QLineEdit` widget
        into the node.

        Note:
            The ``value_changed`` signal from the added node widget is wired
            up to the :meth:`NodeObject.set_property` function.

        Args:
            name (str): name for the custom property.
            label (str): label to be displayed.
            text (str): pre-filled text.
            placeholder_text (str): placeholder text.
            tooltip (str): widget tooltip.
            tab (str): name of the widget tab to display in.
        """
        self.create_property(
            name,
            value=text,
            widget_type=NodePropWidgetEnum.QLINE_EDIT.value,
            widget_tooltip=tooltip,
            tab=tab,
        )
        widget = NodeLineEdit(self.view, name, label, text, placeholder_text)
        widget.setToolTip(tooltip or "")
        widget.value_changed.connect(lambda k, v: self.set_property(k, v))
        self.view.add_widget(widget)
        #: redraw node to address calls outside the "__init__" func.
        self.view.draw_node()

    def add_checkbox(
        self, name, label="", text="", state=False, tooltip=None, tab=None
    ):
        """
        Creates a custom property with the :meth:`NodeObject.create_property`
        function and embeds a :class:`PySide2.QtWidgets.QCheckBox` widget
        into the node.

        Note:
            The ``value_changed`` signal from the added node widget is wired
            up to the :meth:`NodeObject.set_property` function.

        Args:
            name (str): name for the custom property.
            label (str): label to be displayed.
            text (str): checkbox text.
            state (bool): pre-check.
            tooltip (str): widget tooltip.
            tab (str): name of the widget tab to display in.
        """
        self.create_property(
            name,
            value=state,
            widget_type=NodePropWidgetEnum.QCHECK_BOX.value,
            widget_tooltip=tooltip,
            tab=tab,
        )
        widget = NodeCheckBox(self.view, name, label, text, state)
        widget.setToolTip(tooltip or "")
        widget.value_changed.connect(lambda k, v: self.set_property(k, v))
        self.view.add_widget(widget)
        #: redraw node to address calls outside the "__init__" func.
        self.view.draw_node()

    def hide_widget(self, name, push_undo=True):
        """
        Hide an embedded node widget.

        Args:
            name (str): node property name for the widget.
            push_undo (bool): register the command to the undo stack. (default: True)

        See Also:
            :meth:`BaseNode.add_custom_widget`,
            :meth:`BaseNode.show_widget`,
            :meth:`BaseNode.get_widget`
        """
        if not self.view.has_widget(name):
            return
        undo_cmd = NodeWidgetVisibleCmd(self, name, visible=False)
        if push_undo:
            self.graph.undo_stack().push(undo_cmd)
        else:
            undo_cmd.redo()

    def show_widget(self, name, push_undo=True):
        """
        Show an embedded node widget.

        Args:
            name (str): node property name for the widget.
            push_undo (bool): register the command to the undo stack. (default: True)

        See Also:
            :meth:`BaseNode.add_custom_widget`,
            :meth:`BaseNode.hide_widget`,
            :meth:`BaseNode.get_widget`
        """
        if not self.view.has_widget(name):
            return
        undo_cmd = NodeWidgetVisibleCmd(self, name, visible=True)
        if push_undo:
            self.graph.undo_stack().push(undo_cmd)
        else:
            undo_cmd.redo()

    def add_input(
        self,
        name="input",
        multi_input=False,
        display_name=True,
        color=None,
        locked=False,
        painter_func=None,
    ):
        """
        Add input :class:`Port` to node.

        Warnings:
            Undo is NOT supported for this function.

        Args:
            name (str): name for the input port.
            multi_input (bool): allow port to have more than one connection.
            display_name (bool): display the port name on the node.
            color (tuple): initial port color (r, g, b) ``0-255``.
            locked (bool): locked state see :meth:`Port.set_locked`
            painter_func (function or None): custom function to override the drawing
                of the port shape see example: :ref:`Creating Custom Shapes`

        Returns:
            airunner.vendor.nodegraphqt.Port: the created port object.
        """
        if name in self.inputs().keys():
            raise PortRegistrationError(
                'port name "{}" already registered.'.format(name)
            )

        port_args = [name, multi_input, display_name, locked]
        if painter_func and callable(painter_func):
            port_args.append(painter_func)
        view = self.view.add_input(*port_args)

        if color:
            view.color = color
            view.border_color = [min([255, max([0, i + 80])]) for i in color]

        port = Port(self, view)
        port.model.type_ = PortTypeEnum.IN.value
        port.model.name = name
        port.model.display_name = display_name
        port.model.multi_connection = multi_input
        port.model.locked = locked
        self._inputs.append(port)
        self.model.inputs[port.name()] = port.model
        return port

    def add_output(
        self,
        name="output",
        multi_output=True,
        display_name=True,
        color=None,
        locked=False,
        painter_func=None,
    ):
        """
        Add output :class:`Port` to node.

        Warnings:
            Undo is NOT supported for this function.

        Args:
            name (str): name for the output port.
            multi_output (bool): allow port to have more than one connection.
            display_name (bool): display the port name on the node.
            color (tuple): initial port color (r, g, b) ``0-255``.
            locked (bool): locked state see :meth:`Port.set_locked`
            painter_func (function or None): custom function to override the drawing
                of the port shape see example: :ref:`Creating Custom Shapes`

        Returns:
            airunner.vendor.nodegraphqt.Port: the created port object.
        """
        if name in self.outputs().keys():
            raise PortRegistrationError(
                'port name "{}" already registered.'.format(name)
            )

        port_args = [name, multi_output, display_name, locked]
        if painter_func and callable(painter_func):
            port_args.append(painter_func)
        view = self.view.add_output(*port_args)

        if color:
            view.color = color
            view.border_color = [min([255, max([0, i + 80])]) for i in color]
        port = Port(self, view)
        port.model.type_ = PortTypeEnum.OUT.value
        port.model.name = name
        port.model.display_name = display_name
        port.model.multi_connection = multi_output
        port.model.locked = locked
        self._outputs.append(port)
        self.model.outputs[port.name()] = port.model
        return port

    def get_input(self, port):
        """
        Get input port by the name or index.

        Args:
            port (str or int): port name or index.

        Returns:
            airunner.vendor.nodegraphqt.Port: node port.
        """
        if type(port) is int:
            if port < len(self._inputs):
                return self._inputs[port]
        elif type(port) is str:
            return self.inputs().get(port, None)

    def get_output(self, port):
        """
        Get output port by the name or index.

        Args:
            port (str or int): port name or index.

        Returns:
            airunner.vendor.nodegraphqt.Port: node port.
        """
        if type(port) is int:
            if port < len(self._outputs):
                return self._outputs[port]
        elif type(port) is str:
            return self.outputs().get(port, None)

    def delete_input(self, port):
        """
        Delete input port.

        Warnings:
            Undo is NOT supported for this function.

            You can only delete ports if :meth:`BaseNode.port_deletion_allowed`
            returns ``True`` otherwise a port error is raised see also
            :meth:`BaseNode.set_port_deletion_allowed`.

        Args:
            port (str or int): port name or index.
        """
        if type(port) in [int, str]:
            port = self.get_input(port)
            if port is None:
                return
        if not self.port_deletion_allowed():
            raise PortError(
                'Port "{}" can\'t be deleted on this node because '
                '"ports_removable" is not enabled.'.format(port.name())
            )
        if port.locked():
            raise PortError("Error: Can't delete a port that is locked!")
        self._inputs.remove(port)
        self._model.inputs.pop(port.name())
        self._view.delete_input(port.view)
        port.model.node = None
        self._view.draw_node()

    def delete_output(self, port):
        """
        Delete output port.

        Warnings:
            Undo is NOT supported for this function.

            You can only delete ports if :meth:`BaseNode.port_deletion_allowed`
            returns ``True`` otherwise a port error is raised see also
            :meth:`BaseNode.set_port_deletion_allowed`.

        Args:
            port (str or int): port name or index.
        """
        if type(port) in [int, str]:
            port = self.get_output(port)
            if port is None:
                return
        if not self.port_deletion_allowed():
            raise PortError(
                'Port "{}" can\'t be deleted on this node because '
                '"ports_removable" is not enabled.'.format(port.name())
            )
        if port.locked():
            raise PortError("Error: Can't delete a port that is locked!")
        self._outputs.remove(port)
        self._model.outputs.pop(port.name())
        self._view.delete_output(port.view)
        port.model.node = None
        self._view.draw_node()

    def set_port_deletion_allowed(self, mode=False):
        """
        Allow ports to be removable on this node.

        See Also:
            :meth:`BaseNode.port_deletion_allowed` and
            :meth:`BaseNode.set_ports`

        Args:
            mode (bool): true to allow.
        """
        self.model.port_deletion_allowed = mode

    def port_deletion_allowed(self):
        """
        Return true if ports can be deleted on this node.

        See Also:
            :meth:`BaseNode.set_port_deletion_allowed`

        Returns:
            bool: true if ports can be deleted.
        """
        return self.model.port_deletion_allowed

    def set_ports(self, port_data):
        """
        Create node input and output ports from serialized port data.

        Warnings:
            You can only use this function if the node has
            :meth:`BaseNode.port_deletion_allowed` is `True`
            see :meth:`BaseNode.set_port_deletion_allowed`

        Hint:
            example snippet of port data.

            .. highlight:: python
            .. code-block:: python

                {
                    'input_ports':
                        [{
                            'name': 'input',
                            'multi_connection': True,
                            'display_name': 'Input',
                            'locked': False
                        }],
                    'output_ports':
                        [{
                            'name': 'output',
                            'multi_connection': True,
                            'display_name': 'Output',
                            'locked': False
                        }]
                }

        Args:
            port_data(dict): port data.
        """
        if not self.port_deletion_allowed():
            raise PortError(
                "Ports cannot be set on this node because "
                '"set_port_deletion_allowed" is not enabled on this node.'
            )

        for port in self._inputs:
            self._view.delete_input(port.view)
            port.model.node = None
        for port in self._outputs:
            self._view.delete_output(port.view)
            port.model.node = None
        self._inputs = []
        self._outputs = []
        self._model.outputs = {}
        self._model.inputs = {}

        [
            self.add_input(
                name=port["name"],
                multi_input=port["multi_connection"],
                display_name=port["display_name"],
                locked=port.get("locked") or False,
            )
            for port in port_data["input_ports"]
        ]
        [
            self.add_output(
                name=port["name"],
                multi_output=port["multi_connection"],
                display_name=port["display_name"],
                locked=port.get("locked") or False,
            )
            for port in port_data["output_ports"]
        ]
        self._view.draw_node()

    def inputs(self):
        """
        Returns all the input ports from the node.

        Returns:
            dict: {<port_name>: <port_object>}
        """
        return {p.name(): p for p in self._inputs}

    def input_ports(self):
        """
        Return all input ports.

        Returns:
            list[airunner.vendor.nodegraphqt.Port]: node input ports.
        """
        return self._inputs

    def outputs(self):
        """
        Returns all the output ports from the node.

        Returns:
            dict: {<port_name>: <port_object>}
        """
        return {p.name(): p for p in self._outputs}

    def output_ports(self):
        """
        Return all output ports.

        Returns:
            list[airunner.vendor.nodegraphqt.Port]: node output ports.
        """
        return self._outputs

    def input(self, index):
        """
        Return the input port with the matching index.

        Args:
            index (int): index of the input port.

        Returns:
            airunner.vendor.nodegraphqt.Port: port object.
        """
        return self._inputs[index]

    def set_input(self, index, port):
        """
        Creates a connection pipe to the targeted output :class:`Port`.

        Args:
            index (int): index of the port.
            port (airunner.vendor.nodegraphqt.Port): port object.
        """
        src_port = self.input(index)
        src_port.connect_to(port)

    def output(self, index):
        """
        Return the output port with the matching index.

        Args:
            index (int): index of the output port.

        Returns:
            airunner.vendor.nodegraphqt.Port: port object.
        """
        return self._outputs[index]

    def set_output(self, index, port):
        """
        Creates a connection pipe to the targeted input :class:`Port`.

        Args:
            index (int): index of the port.
            port (airunner.vendor.nodegraphqt.Port): port object.
        """
        src_port = self.output(index)
        src_port.connect_to(port)

    def connected_input_nodes(self):
        """
        Returns all nodes connected from the input ports.

        Returns:
            dict: {<input_port>: <node_list>}
        """
        nodes = OrderedDict()
        for p in self.input_ports():
            nodes[p] = [cp.node() for cp in p.connected_ports()]
        return nodes

    def connected_output_nodes(self):
        """
        Returns all nodes connected from the output ports.

        Returns:
            dict: {<output_port>: <node_list>}
        """
        nodes = OrderedDict()
        for p in self.output_ports():
            nodes[p] = [cp.node() for cp in p.connected_ports()]
        return nodes

    def add_accept_port_type(self, port, port_type_data):
        """
        Add an accept constrain to a specified node port.

        Once a constraint has been added only ports of that type specified will
        be allowed a pipe connection.

        port type data example

        .. highlight:: python
        .. code-block:: python

            {
                'port_name': 'foo'
                'port_type': PortTypeEnum.IN.value
                'node_type': 'io.github.jchanvfx.NodeClass'
            }

        See Also:
            :meth:`airunner.vendor.nodegraphqt.BaseNode.accepted_port_types`

        Args:
            port (airunner.vendor.nodegraphqt.Port): port to assign constrain to.
            port_type_data (dict): port type data to accept a connection
        """
        node_ports = self._inputs + self._outputs
        if port not in node_ports:
            raise PortError('Node does not contain port: "{}"'.format(port))

        self._model.add_port_accept_connection_type(
            port_name=port.name(),
            port_type=port.type_(),
            node_type=self.type_,
            accept_pname=port_type_data["port_name"],
            accept_ptype=port_type_data["port_type"],
            accept_ntype=port_type_data["node_type"],
        )

    def accepted_port_types(self, port):
        """
        Returns a dictionary of connection constrains of the port types
        that allow for a pipe connection to this node.

        Args:
            port (airunner.vendor.nodegraphqt.Port): port object.

        Returns:
            dict: {<node_type>: {<port_type>: [<port_name>]}}
        """
        ports = self._inputs + self._outputs
        if port not in ports:
            raise PortError('Node does not contain port "{}"'.format(port))

        accepted_types = self.graph.model.port_accept_connection_types(
            node_type=self.type_, port_type=port.type_(), port_name=port.name()
        )
        return accepted_types

    def add_reject_port_type(self, port, port_type_data):
        """
        Add a reject constrain to a specified node port.

        Once a constraint has been added only ports of that type specified will
        NOT be allowed a pipe connection.

        port type data example

        .. highlight:: python
        .. code-block:: python

            {
                'port_name': 'foo'
                'port_type': PortTypeEnum.IN.value
                'node_type': 'io.github.jchanvfx.NodeClass'
            }

        See Also:
            :meth:`airunner.vendor.nodegraphqt.Port.rejected_port_types`

        Args:
            port (airunner.vendor.nodegraphqt.Port): port to assign constrain to.
            port_type_data (dict): port type data to reject a connection
        """
        node_ports = self._inputs + self._outputs
        if port not in node_ports:
            raise PortError('Node does not contain port: "{}"'.format(port))

        self._model.add_port_reject_connection_type(
            port_name=port.name(),
            port_type=port.type_(),
            node_type=self.type_,
            reject_pname=port_type_data["port_name"],
            reject_ptype=port_type_data["port_type"],
            reject_ntype=port_type_data["node_type"],
        )

    def rejected_port_types(self, port):
        """
        Returns a dictionary of connection constrains of the port types
        that are NOT allowed for a pipe connection to this node.

        Args:
            port (airunner.vendor.nodegraphqt.Port): port object.

        Returns:
            dict: {<node_type>: {<port_type>: [<port_name>]}}
        """
        ports = self._inputs + self._outputs
        if port not in ports:
            raise PortError('Node does not contain port "{}"'.format(port))

        rejected_types = self.graph.model.port_reject_connection_types(
            node_type=self.type_, port_type=port.type_(), port_name=port.name()
        )
        return rejected_types

    def on_input_connected(self, in_port, out_port):
        """
        Callback triggered when a new pipe connection is made.

        *The default of this function does nothing re-implement if you require
        logic to run for this event.*

        Note:
            to work with undo & redo for this method re-implement
            :meth:`BaseNode.on_input_disconnected` with the reverse logic.

        Args:
            in_port (airunner.vendor.nodegraphqt.Port): source input port from this node.
            out_port (airunner.vendor.nodegraphqt.Port): output port that connected to this node.
        """
        return

    def on_input_disconnected(self, in_port, out_port):
        """
        Callback triggered when a pipe connection has been disconnected
        from a INPUT port.

        *The default of this function does nothing re-implement if you require
        logic to run for this event.*

        Note:
            to work with undo & redo for this method re-implement
            :meth:`BaseNode.on_input_connected` with the reverse logic.

        Args:
            in_port (airunner.vendor.nodegraphqt.Port): source input port from this node.
            out_port (airunner.vendor.nodegraphqt.Port): output port that was disconnected.
        """
        return
