import time
from collections import Iterable
import itertools
import re
import warnings
import sys

from .nodes.base_node import BaseNode
from .signal import SignalFunctionType
from .utils import *


class NodeStruct(object):
    def __init__(self, name, node):
        self.name = name
        self.node = node
        self.in_edges = set()
        self.out_edges = set()
        self.cache_hash = 0  # todo
        self.output_cache_value = None


class PipelineError(Exception):
    pass


class Pipeline(object):
    """A directed acylic graph that has specialized nodes representing
    processes. Each node contains a set of functions it can execute.
    When the Pipeline is run it constructs an execution order and
    handles all process execution with appropriate input and output
    type handling.

    Examples
    --------
    Nodes are added to the Pipeline in the following order::

        P = Pipeline()

        P.add_nodes(("foo1",DummyNode0()),
                    ("foo2",DummyNode0()),
                    ("fooo",DummyNode1()),
                    ("barr",DummyNode2()),
                    ("barrr",DummyNode2()),
                    ("baz",DummyNode3()),
                    ("choo",DummyNode4()))

    The nodes are input with pairs of the node's name and the node.
    Then you can construct a path in the graph::

        P << [['foo1','foo2'],'fooo','barr+','baz','choo']

    This creates a path where the each item in the input iterable
    is added as a separate layer of the subgraph generated from the
    path. The above path constructs the sub graph of the following
    layers fully connected going from left to right:

    foo1, foo2  ->  foooo  ->  barr, barrr  ->  choo

    Note that the path was created in careful considiration due to
    nodes having specific input and output signals they can use which
    act puzzle piece parts. This means that the end Pipeline connected
    should have all the nodes aligned in a fashion such that their inputs
    and outputs are valid. Otherwise the Pipeline execution fails.

    To run the Pipeline execute:

    >>> P.run()
    I am a DummyNode0
    I am a DummyNode0
    I am a DummyNode1
    I am a DummyNode2
    I am a DummyNode2
    I am a DummyNode3
    I am a DummyNode4

    This represents the nodes executing in a topological order. The ordering
    in more complex graphs can execute a node multiple times due to using
    different processes the node has.

    .. See also
    .. --------
    .. DataFrame.from_records: constructor from tuples, also record arrays
    .. DataFrame.from_dict: from dicts of Series, arrays, or dicts
    .. DataFrame.from_csv: from CSV files
    .. DataFrame.from_items: from sequence of (key, value) pairs
    .. read_csv / read_table / read_clipboard
    """
    __nodes = {}

    def __init__(self):  # , cache_directory='./Pipeline'):
        pass

    def get_edges(self):
        """Get all the edges in the Pipeline's graph

        Returns
        ------
        A list of pairs of node names representing the edges in the Pipeline's graph
        """
        edges = []
        for node_name in self.__nodes:
            node = self.__nodes[node_name]
            for node_name2 in node.out_edges:
                edges.append((node_name, node_name2))

        return edges

    def add_node(self, name, node):
        """Inserts a named node into the Pipeline.

        Parameters
        ----------
        name : String
            The name assigned to the node added to the Pipeline. Must be unique to the Pipeline.
        node : Subclass of BaseNode
            The node added into the Pipeline.

        Raises
        ------
        ValueError
            If the parameters don't match the specified types
        """
        if(not(isinstance(name, basestring))):
            raise ValueError("Node names must be strings")
        if(not(issubclass(node.__class__, BaseNode))):
            raise ValueError("The node for %s must be a subclass of protoml.nodes.BaseNode" % name)
        if(name in self.__nodes):
            warnings.warn("Node named %s already exists in pipeline, overwriting previous node." % name)
        self.__nodes[name] = NodeStruct(name, node)

    def add_nodes(self, *nodes):
        """Given pairs of a string and a node(subclass of BaseNode) add all the pairs
        into the Pipeline.

        Parameters
        ----------
        nodes : Variadic of (String,Subclass of BaseNode)
            Add each variadic item representing a named node into the Pipeline.

        Examples
        --------
        >>> P.add_nodes(("foo1",DummyNode0()),
                        ("foo2",DummyNode0()),
                        ("fooo",DummyNode1()),
                        ("barr",DummyNode2()),
                        ("barrr",DummyNode2()),
                        ("baz",DummyNode3()),
                        ("choo",DummyNode4()))

        Raises
        ------
        ValueError
            If the parameters don't match the specified types
        """
        if(not all(len(nt) == 2 for nt in nodes)):
            raise ValueError("Input must be an iterable of pairs of a string and a subclass of protoml.BaseNode")
        for nt in nodes:
            self.add_node(nt[0], nt[1])

    def reset_edges(self):
        """Reset all links made in the Pipeline.
        """
        for n in self.__nodes:
            self.__nodes[n].in_edges = set()
            self.__nodes[n].out_edges = set()

    def add_sub_graph(self, path):
        """Given an iterable of strings or nested iterables with strings construct subgraph. The strings in path represent
        the names of nodes in the Pipeline in a regexable form. First all the strings found in the path are used as
        regexes and expanded to all the matched node names in the graph. Then all nodes on index i are connected in feed-forward
        fashion to all the nodes in index i+1. The nested iterables allows one to add multiple regex string names instead of using
        one large one.

        Parameters
        ----------
        path : Variadic of (String,Subclass of BaseNode)
            Add each variadic item representing a named node into the Pipeline.


        Raises
        ------
        ValueError
            If the parameters don't match the specified types
        ValueError
            If any of the strings do not match to any nodes' names in the Pipeline
        """
        if(not isinstance(path, Iterable)):
            raise ValueError("Path structure must be iterable.")
        if(len(path) < 2):
            raise ValueError("Path must at least two nodes and an edge.")
        for n in path:
            if(not(isinstance(n, basestring) or isinstance(n, Iterable))):
                raise ValueError("Path must only contain strings and iterables.")
            if isinstance(n, Iterable) and not isinstance(n, basestring):
                if(not all(isinstance(ln, basestring) for ln in n)):
                    raise ValueError("All nodes in iterables in path must contain strings.")

                non_nodes = [xn for xn in n if (len([node for node in self.__nodes if re.match(xn, node)]) < 1)]
                if(len(non_nodes) != 0):
                    raise ValueError("Node%s %s %s not in the graph." %
                                    ('s' if len(non_nodes) > 1 else '', str_list(non_nodes),
                                     'are' if len(non_nodes) > 1 else 'is'))

        refactored_path = []
        for n in path:
            if isinstance(n, Iterable) and not isinstance(n, basestring):  # Iterable
                refactored_path.append([node for s in n
                                       for node in self.__nodes if re.match(s, node)])
            else:  # String
                regexed_nodes = [node for node in self.__nodes if re.match(n, node)]
                if len(regexed_nodes) < 1:
                    raise ValueError("%s does not match any nodes in the graph." % n)
                refactored_path.append(regexed_nodes)
        # print refactored_path

        # combine layers together
        ref_iter = iter(refactored_path)
        sources = next(ref_iter)
        for terminals in ref_iter:
            edge_product = itertools.product(sources, terminals)
            for edge in edge_product:
                self.__nodes[edge[0]].out_edges.add(edge[1])
                self.__nodes[edge[1]].in_edges.add(edge[0])
            sources = terminals

    def __lshift__(self, path):
        """Syntactic sugar for Pipeline.add_sub_graph.

        Parameters
        ----------
        path : Variadic of (String,Subclass of BaseNode)
            Add each variadic item representing a named node into the Pipeline.

        """
        self.add_sub_graph(path)

    def __topological_order(self):
        """Generates a topological ordering of the nodes in the directed ascyclic graph of the pipeline


        Returns
        -------
        a class object reprsenting this panel

        Raises
        ------
        PipelineError
            If the graph in the Pipeline is not a directed ascyclic graph
        """
        topo_order = []
        visited_nodes = set()
        local_visited_path = set()
        cyclical_edges = set()

        # depth first search explore
        def explore(node):
            visited_nodes.add(node)
            local_visited_path.add(node)
            adjacent_nodes = self.__nodes[node].out_edges
            for terminal in adjacent_nodes:
                if terminal not in visited_nodes:
                    explore(terminal)
                elif terminal in local_visited_path:
                    # if node seen twice in one path down tree then its cyclical
                    cyclical_edges.add((node, terminal))
            local_visited_path.remove(node)
            topo_order.append(node)

        # depth first search
        for node in self.__nodes:
            if node not in visited_nodes:
                local_visited_path = set()
                explore(node)

        if(len(cyclical_edges) != 0):
            raise PipelineError("Cyclical edge%s %s found in graph." %
                               ('s' if len(cyclical_edges) > 1 else '',
                                str_list(map(str, cyclical_edges))))

        topo_order.reverse()
        return topo_order

    def __execution_order(self, topological_order):
        """Goes through an input topological ordering and simulates all nodes executing in
        order to construct paths of execution that occur when one node activates functions of another.
        The final return value is the topological execution order of the nodes and their
        respective functions in which they need to be called.


        Parameters
        ----------
        topological_order: List of Strings
            The topological ordering of the Pipeline's graph represented as a list of node names

        Returns
        -------
        A list made of pairs of a node name and signal function type representing the executions of each node

        Raises
        ------
        PipelineError
            If a node has multiple signals of one type as input but it can only accept one signal of that type
        """
        nodes_outputs = dict((node, []) for node in topological_order)
        exec_order = []

        for node in topological_order:
            node_types = self.__nodes[node].node.signal_types()
            node_inputs_types = [t.signal_inputs for t in node_types]
            node_inputs = set()
            for parent in self.__nodes[node].in_edges:
                for inp in nodes_outputs[parent]:
                    if (inp in node_inputs):
                        # two same signals makes one multi signal type
                        if (not SignalFunctionType.is_signal_list(inp)):
                            node_inputs.remove(inp)
                            node_inputs.add(SignalFunctionType.make_multi_signal_name(inp))
                        # skip adding multi singal type if already added
                        else:
                            continue
                    # skip adding non-multi signal type if multi is added
                    elif (not SignalFunctionType.is_signal_list(inp) and
                            SignalFunctionType.make_multi_signal_name(inp) in node_inputs):
                        continue
                    else:
                        node_inputs.add(inp)

            node_exec = []
            # all_non_multi_signals = set()
            for t in node_types:
                # types = t.signal_inputs
                # multiple_signals = set(SignalFunctionType.get_multi_signal_name(sig)
                #                        for sig in node_inputs
                #                        if SignalFunctionType.is_signal_list(sig))
                # non_multi_signals = types - t.signal_inputs_multiple
                # # check for multi signal type check
                # if len(non_multi_signals & multiple_signals):
                #     all_non_multi_signals |= non_multi_signals & multiple_signals
                #     continue
                if t.signal_inputs <= node_inputs:
                    node_exec.append((node, t))
                    nodes_outputs[node].extend(t.signal_outputs)

            # if len(all_non_multi_signals):
            #     raise PipelineError("Node %s is receiving multiple signals of %s while it cannot use them." %
            #                        (node, str_list(all_non_multi_signals)))
            if len(node_exec) < 1:
                str_node_input_types = str_list(str(list(t)) for t in node_inputs_types)
                str_parent_outputs = str_list(map(str, ((pnode, nodes_outputs[pnode])
                                                        for pnode in self.__nodes[node].in_edges)))
                raise PipelineError("""Node %s receives inputs: %s.
                    It requires one of the following input sets: %s.
                    Its parents return the give the outputs: %s.""" %
                                   (node, str_list(node_inputs), str_node_input_types, str_parent_outputs))
            exec_order += node_exec

        return exec_order

    def run(self, timer=False, verbose=False):
        """Execute the constructed Pipeline


        Parameters
        ----------
        timer: boolean
            Keep timings of each node's executions
        verbose: boolean
            Show extended output for each node


        Raises
        ------
        PipelineError
            If a node's output signal types do not match the ones in the node's signal_types()
        PipelineError
            If a node outputs a multiple signal type that is not iterable
        """
        topo_order = self.__topological_order()
        if verbose:
            print '\nTopological Order:'
            for n in topo_order:
                print "\t", n
            sys.stdout.flush()

        exec_order = self.__execution_order(topo_order)
        if verbose:
            print '\nExecution Order:'
            for n in exec_order:
                print "\t", n
            sys.stdout.flush()

        node_outputs = dict((node, {}) for node in self.__nodes)
        for node_name, sig_func in exec_order:
            node = self.__nodes[node_name]

            node_input = dict((t, [] if SignalFunctionType.is_signal_list(t) else None)
                              for t in sig_func.signal_inputs)
            for t in node_input.keys():
                if SignalFunctionType.is_signal_list(t):
                    pass  # node_input["source_" + t] = []
                else:
                    pass  # node_input["source_" + t] = None
            node_multi_inputs = set(SignalFunctionType.get_multi_signal_name(t) for t in sig_func.signal_inputs
                                    if SignalFunctionType.is_signal_list(t))

            for source in node.in_edges:
                for signal_type, signal_val in node_outputs[source].iteritems():
                    # print sig_func.signal_inputs, signal_type, SignalFunctionType.get_multi_signal_name(signal_type)

                    if ((signal_type in sig_func.signal_inputs) or
                       (SignalFunctionType.make_multi_signal_name(signal_type) in sig_func.signal_inputs)):
                            if SignalFunctionType.is_signal_list(signal_type):
                                node_input[signal_type].extend(signal_val)
                                # node_input["source_" + signal_type].extend(source for x in range(len(signal_val)))
                            elif signal_type in node_multi_inputs:
                                signal_name = SignalFunctionType.make_multi_signal_name(signal_type)
                                node_input[signal_name].append(signal_val)
                                # node_input["source_" + signal_name].append(source)
                            else:
                                node_input[signal_type] = signal_val
                                # node_input["source_" + signal_type] = source

            header = "Calling " + node.name + " in mode " + sig_func.type_name
            print "\n", "-" * len(header)
            print header
            sys.stdout.flush()
            if(verbose):
                if len(sig_func.signal_inputs):
                    print "Input signals:"
                    for t, v in node_input.iteritems():
                        print t, ":\n", v
                sys.stdout.flush()

            tic = time.time()
            output_signals = node.node.run(node_name, node_input, sig_func.type_name)

            if(timer):
                toc = time.time()
                secs = (toc - tic) / 1000
                print node.name, "in mode", sig_func.type_name, "took", secs, "seconds"
                sys.stdout.flush()
            print "-" * len(header)

            if set(output_signals.keys()) != sig_func.signal_outputs:
                raise PipelineError("Incorrect output signals for %s running in mode %s should have signal types %s not %s."
                                    % (node.name, sig_func.type_name,
                                        str_list(sig_func.signal_outputs),
                                        str_list(output_signals.keys()),
                                       ))

            for t, v in output_signals.iteritems():
                if SignalFunctionType.is_signal_list(t) and not isinstance(v, Iterable):
                    raise PipelineError("Node %s outputs a multi-signal type %s which is not iterable."
                                        % (node.name, t))
                node_outputs[node.name][t] = v
