"""# Graph class module

The [`Graph`][ragraph.graph.Graph] class is the core of this package. It is the internal storage
format that facilitates all conversions. These class definitions are designed to
accommodate a wide range of graph formats.
"""
from collections import OrderedDict, defaultdict
from copy import deepcopy
from typing import (
    Any,
    Callable,
    Dict,
    Generator,
    Iterable,
    List,
    Optional,
    Set,
    Tuple,
    Union,
)
from uuid import UUID

from ragraph import utils
from ragraph.edge import Edge
from ragraph.generic import Annotations, Metadata
from ragraph.node import Node

try:
    import numpy as np

except ImportError:
    np = None  # type: ignore


class ConsistencyError(Exception):
    pass


class Graph(Metadata):
    """Graph of [`Node`][ragraph.node.Node] objects and [`Edge`][ragraph.edge.Edge] objects between
    them.

    Arguments:
        nodes: Iterable of graph nodes.
        edges: Iterable of graph edges.
        add_parents: Recursively add parent nodes of the provided nodes.
        add_children: Recursively add child nodes of the provided nodes.
        name: Instance name. Given a UUID if none provided.
        kind: Kind or main category of this instance.
        labels: Labels categorizing this instance.
        weights: Dictionary of weights attached to this instance.
        annotations: Miscellaneous properties of this instance.
        uuid: Fixed UUID if desired, generated when left set to None.
    """

    def __init__(
        self,
        nodes: Optional[Iterable[Node]] = None,
        edges: Optional[Iterable[Edge]] = None,
        add_parents: bool = False,
        add_children: bool = False,
        name: Optional[str] = None,
        kind: Optional[str] = None,
        labels: Optional[List[str]] = None,
        weights: Optional[Dict[str, Union[int, float]]] = None,
        annotations: Optional[Union[Annotations, Dict[str, Any]]] = None,
        uuid: Optional[Union[str, UUID]] = None,
    ):
        Metadata.__init__(
            self,
            name=name,
            kind=kind,
            labels=labels,
            weights=weights,
            annotations=annotations,
            uuid=uuid,
        )
        self._nodes: Dict[str, Node] = OrderedDict()
        self._node_uuid_dict: Optional[Dict[UUID, Node]] = None  # Lazy
        self._edges: Dict[str, Edge] = OrderedDict()
        self._edge_uuid_dict: Optional[Dict[UUID, Edge]] = None  # lazy
        self._directed_edges: defaultdict = defaultdict(lambda: defaultdict(list))
        self._reversed_edges: defaultdict = defaultdict(lambda: defaultdict(list))
        self.add_parents = add_parents
        self.add_children = add_children
        setattr(self, "nodes", nodes)
        setattr(self, "edges", edges)

    def __str__(self) -> str:
        nodes, edges = self.node_count, self.edge_count
        ns, es = "" if nodes == 1 else "s", "" if edges == 1 else "s"
        counts = f"{nodes} node{ns}, {edges} edge{es}"
        return f"{self.__class__.__name__}(name={self.name}, {counts})"

    def __repr__(self) -> str:
        kwargdict = dict(
            name=repr(self.name),
            kind=repr(self.kind),
            labels=self.labels,
            weights=self.weights,
            annotations=self.annotations,
            uuid=repr(self.uuid),
        )
        kwargs = ", ".join([str(k) + "=" + str(v) for k, v in kwargdict.items()])
        nodes, edges = self.node_count, self.edge_count
        ns, es = "" if nodes == 1 else "s", "" if edges == 1 else "s"
        counts = f"{nodes} node{ns}, {edges} edge{es}"
        return "<{}.{}({}), {} at {}>".format(
            self.__class__.__module__,
            self.__class__.__name__,
            kwargs,
            counts,
            hex(id(self)),
        )

    def __eq__(self, other) -> bool:
        return self.as_dict(use_uuid=False) == other.as_dict(use_uuid=False)

    def __getitem__(self, key: Union[str, UUID, Tuple[str, str]]):
        if isinstance(key, str):
            return self._nodes[key]

        if isinstance(key, (tuple, list)) and len(key) == 2:
            return self._directed_edges[key[0]][key[1]]

        if isinstance(key, UUID):
            if key in self.node_uuid_dict:
                return self.node_uuid_dict[key]
            if key in self.edge_uuid_dict:
                return self.edge_uuid_dict[key]
            raise KeyError(f"Cannot find any Node or Edge with UUID '{key}'.")

        raise TypeError(
            f"Key '{key}' should be a node name string or "
            'a ("source", "target") tuple of strings.'
        )

    def __contains__(self, key: Union[str, UUID, Tuple[str, str]]):
        try:
            return bool(self.__getitem__(key))
        except KeyError:
            return False

    def __copy__(self):
        cp = Metadata.__copy__(self)
        cp._node_uuid_dict = None
        cp._edge_uuid_dict = None
        return cp

    def __deepcopy__(self, memo):
        cp = Metadata.__deepcopy__(self, memo)
        cp._node_uuid_dict = None
        cp._edge_uuid_dict = None
        return cp

    @property
    def nodes(self) -> List[Node]:
        """Nodes as a list."""
        return self.node_list

    @nodes.setter
    def nodes(self, value: Optional[Iterable[Node]]):
        self._nodes = OrderedDict()
        if isinstance(value, dict):
            value = value.values()
        if value:
            for node in value:
                self.add_node(node)

    @property
    def node_count(self) -> int:
        """Number of nodes in the graph."""
        return len(self._nodes)

    @property
    def node_list(self) -> List[Node]:
        """Nodes as a list."""
        return list(self._nodes.values())

    @property
    def node_dict(self) -> Dict[str, Node]:
        """Node dictionary by node name."""
        return self._nodes

    @property
    def node_uuid_dict(self) -> Dict[UUID, Node]:
        """Node dictionary by node UUID."""
        if self._node_uuid_dict is None:
            self._node_uuid_dict = {n.uuid: n for n in self.node_gen}
        return self._node_uuid_dict

    @property
    def node_gen(self) -> Generator[Node, None, None]:
        """Yield nodes."""
        yield from self._nodes.values()

    @property
    def edges(self) -> List[Edge]:
        """Edges as a list."""
        return self.edge_list

    @edges.setter
    def edges(self, value: Optional[Iterable[Edge]]):
        self._edges = OrderedDict()
        self._directed_edges = defaultdict(lambda: defaultdict(list))
        self._reversed_edges = defaultdict(lambda: defaultdict(list))
        if isinstance(value, dict):
            for source in value:
                for target in value[source]:
                    for edge in value[source][target]:
                        self.add_edge(edge)
        elif value:
            for edge in value:
                self.add_edge(edge)

    @property
    def edge_list(self) -> List[Edge]:
        """Edges as a list."""
        return list(self._edges.values())

    @property
    def edge_dict(self) -> Dict[str, Edge]:
        """Edge dictionary by edge name."""
        return self._edges

    @property
    def edge_uuid_dict(self) -> Dict[UUID, Edge]:
        """Edge dictionary by node UUID."""
        if self._edge_uuid_dict is None:
            self._edge_uuid_dict = {e.uuid: e for e in self.edge_gen}
        return self._edge_uuid_dict

    @property
    def directed_edges(self) -> Dict[str, Dict[str, List[Edge]]]:
        """Nested edge dictionary from source name to target name to a list of edges."""
        return self._directed_edges

    @property
    def reversed_edges(self) -> Dict[str, Dict[str, List[Edge]]]:
        """Nested edge dictionary from target name to source name to a list of edges."""
        return self._reversed_edges

    @property
    def edge_gen(self) -> Generator[Edge, None, None]:
        """Yield edges."""
        for source in self._directed_edges:
            for target in self._directed_edges[source]:
                yield from self._directed_edges[source][target]

    @property
    def edge_count(self) -> int:
        """Number of edges in the graph."""
        return len(self._edges)

    def add_node(self, node: Node):
        """Add a new node to this graph.

        If the node instance already exists in the graph, it is ignored.

        Arguments:
            node: Node to add.
        """
        if not isinstance(node, Node):
            raise TypeError("%r is not an instance of Node but of %r." % (node, type(node)))

        # Append own node if not exists.
        if node.name in self._nodes:
            if node == self._nodes[node.name]:
                return
            raise ValueError(f"Another Node named '{node.name}' already exists in this Graph.")
        if node.uuid in self.node_uuid_dict:
            if node == self.node_uuid_dict[node.uuid]:
                return
            raise ValueError(f"Another Node with UUID '{node.uuid}' already exists in this Graph.")
        self._nodes[node.name] = node
        self.node_uuid_dict[node.uuid] = node

        # Append parent
        if self.add_parents and node.parent:
            self.add_node(node.parent)

        # Append children
        if self.add_children and node.children:
            for child in node.children:
                self.add_node(child)

    def del_node(self, node: Union[Node, str, UUID], inherit: bool = False):
        """Remove a node and related edges from the graph by name or Node instance.

        Arguments:
            node: Node to remove.
            inherit: Whether to inherit hierarchy for underlying nodes to the parent.
        """
        if isinstance(node, str):
            node = self._nodes[node]
        elif isinstance(node, UUID):
            node = self.node_uuid_dict[node]

        if inherit and node.parent and node.children:
            for child in node.children:  # Is bus does not carry over to new parent.
                child.is_bus = False
            node.parent.children = node.parent.children + node.children
        node.children = []
        node.parent = None
        for e in list(self.edges_from(node)):
            self.del_edge(e)
        for e in list(self.edges_to(node)):
            self.del_edge(e)
        self.node_uuid_dict.pop(node.uuid)
        self._nodes.pop(node.name)

    def add_edge(self, edge: Edge):
        """Add a new edge to the graph. Source and target need to exist in the graph.

        Arguments:
            edge: Edge to add.

        Raises:
            ValueError: if the source or target doesn't exist or the edge instance already does.
        """
        source = edge.source.name
        target = edge.target.name
        if source not in self.node_dict:
            raise ValueError("Source node %r does not exist in graph." % edge.source)
        if target not in self.node_dict:
            raise ValueError("Target node %r does not exist in graph." % edge.target)
        if edge.name in self._edges:
            if edge == self._edges[edge.name]:
                return
            raise ValueError(f"Edge name '{edge.name}' already exists in the graph.")
        if edge.uuid in self.edge_uuid_dict:
            if edge == self.edge_uuid_dict[edge.uuid]:
                return
            raise ValueError(f"Edge UUID '{edge.name}' already exists in the graph.")
        self._edges[edge.name] = edge
        self.edge_uuid_dict[edge.uuid] = edge
        self._directed_edges[source][target].append(edge)
        self._reversed_edges[target][source].append(edge)

    def del_edge(self, edge: Union[Edge, str, UUID]):
        """Remove an edge from the graph."""
        if isinstance(edge, str):
            edge = self._edges[edge]
        elif isinstance(edge, UUID):
            edge = self.edge_uuid_dict[edge]

        self.edge_uuid_dict.pop(edge.uuid)
        self._edges.pop(edge.name)

        source = edge.source.name
        target = edge.target.name

        self._directed_edges[source][target].remove(edge)
        if not self._directed_edges[source][target]:
            self._directed_edges[source].pop(target)

        self._reversed_edges[target][source].remove(edge)
        if not self._reversed_edges[source][target]:
            self._reversed_edges[source].pop(target)

    @property
    def node_kinds(self) -> List[str]:
        """List of unique node kinds in the graph."""
        return self.get_kinds(self.node_gen)

    @property
    def edge_kinds(self) -> List[str]:
        """List of unique edge kinds in the graph."""
        return self.get_kinds(self.edge_gen)

    def get_kinds(self, elements: Union[Iterable[Node], Iterable[Edge]]) -> List[str]:
        """Get an alphabetically sorted list of unique node/edge kinds in the graph."""
        return sorted(set(elem.kind for elem in elements), key=str)

    def get_nodes_by_kind(self, kind: str) -> List[Node]:
        """Get nodes/edges by kind."""
        return [node for node in self.node_gen if node.kind == kind]

    def get_edges_by_kind(self, kind: str) -> List[Edge]:
        """Get edges by kind."""
        return [edge for edge in self.edge_gen if edge.kind == kind]

    @property
    def node_weight_labels(self) -> List[str]:
        """List of unique node weight labels in the graph."""
        return self.get_weight_labels(self.node_gen)

    @property
    def edge_weight_labels(self) -> List[str]:
        """List of unique edge weight labels in the graph."""
        return self.get_weight_labels(self.edge_gen)

    def get_weight_labels(self, elements: Union[Iterable[Node], Iterable[Edge]]) -> List[str]:
        """Get an alphabetically sorted list of unique labels of node/edge weights."""
        labels: Set[str] = set()
        for elem in elements:
            labels.update(elem.weights.keys())
        return sorted(labels, key=str)

    @property
    def node_labels(self) -> List[str]:
        """Alphabetically sorted list of unique node labels in the graph."""
        labels = {label for node in self.node_gen for label in node.labels}
        return sorted(labels, key=str)

    @property
    def edge_labels(self) -> List[str]:
        """Alphabetically sorted list of unique edge labels in the graph."""
        labels = {label for edge in self.edge_gen for label in edge.labels}
        return sorted(labels, key=str)

    @property
    def roots(self) -> List[Node]:
        """List of roots in the graph."""
        return [node for node in self.node_gen if node.is_root]

    @property
    def leafs(self) -> List[Node]:
        """List of roots in the graph."""
        return [node for node in self.node_gen if node.is_leaf]

    @property
    def max_depth(self) -> int:
        """Maximum node depth in the graph."""
        return max([node.depth for node in self.leafs], default=0)

    def edges_from(self, source: Union[str, Node]) -> Generator[Edge, None, None]:
        """Yield edges originating from a given source node."""
        if isinstance(source, Node):
            source = source.name
        for target in self._directed_edges[source]:
            yield from self._directed_edges[source][target]

    def targets_of(self, source: Union[str, Node]) -> Generator[Node, None, None]:
        """Yield target nodes of a given source node."""
        if isinstance(source, Node):
            source = source.name
        yield from [
            self._nodes[target]
            for target in self._directed_edges[source]
            if self._directed_edges[source][target]
        ]

    def edges_to(self, target: Union[str, Node]) -> Generator[Edge, None, None]:
        """Yield edges towards a given target node."""
        if isinstance(target, Node):
            target = target.name
        for source in self._reversed_edges[target]:
            yield from self._reversed_edges[target][source]

    def sources_of(self, target: Union[str, Node]) -> Generator[Node, None, None]:
        """Yield source nodes of a given target node."""
        if isinstance(target, Node):
            target = target.name
        yield from [
            self._nodes[source]
            for source in self._reversed_edges[target]
            if self._reversed_edges[target][source]
        ]

    def edges_between(
        self,
        source: Union[str, Node],
        target: Union[str, Node],
        inherit: bool = False,
        loops: bool = True,
    ) -> Generator[Edge, None, None]:
        """Yield edges between source and target node.

        Arguments:
            source: Source node.
            target: Target node.
            inherit: Whether to include edges between descendants of given nodes.
            loops: Whether to include self-loop edges.
        """
        if source == target and not loops:
            return
        source_node = source if isinstance(source, Node) else self._nodes[source]
        target_node = target if isinstance(target, Node) else self._nodes[target]
        sources, targets = [source_node.name], [target_node.name]
        if inherit:
            sources.extend(n.name for n in source_node.descendant_gen)
            targets.extend(n.name for n in target_node.descendant_gen)

        for source in sources:
            for target in targets:
                if source == target and not loops:
                    continue
                yield from self._directed_edges[source][target]

    def edges_between_all(
        self,
        sources: Iterable[Union[str, Node]],
        targets: Iterable[Union[str, Node]],
        inherit: bool = False,
        loops: bool = True,
    ) -> Generator[Edge, None, None]:
        """Yield all edges between a set of source and a set of target nodes.

        Arguments:
            sources: Source nodes.
            targets: Target nodes.
            inherit: Whether to include edges between descendants of given nodes.
            loops: Whether to include self-loop edges.
        """
        for source in sources:
            for target in targets:
                yield from self.edges_between(source, target, inherit=inherit, loops=loops)

    def get_graph_slice(
        self,
        nodes: Optional[Iterable[Node]] = None,
        edges: Optional[Iterable[Edge]] = None,
        inherit: bool = False,
    ) -> "Graph":
        """Return the graph slice with only the given nodes and the edges between those.

        Arguments:
            nodes: Optional set of nodes to include in the new deep copy.
                Defaults to all.
            edges: Optional set of edges to include in the new deep copy.
                Defaults to all between selected nodes.
            inherit: Whether to include child nodes of the given nodes and edges
                between them (unless edges are explicitly specified).

        Note:
            This returns a deepcopy, so the copied nodes and edges do NOT share any
            changes! All parent and child nodes not in the list are excluded.
        """
        nodes = self.nodes if nodes is None else list(nodes)
        edges = self.edges_between_all(nodes, nodes, inherit) if edges is None else list(edges)

        # Get a deepcopy to prevent changing nodes in the current graph instance.
        graph = deepcopy(Graph(nodes=nodes, edges=edges, add_children=inherit))

        # Reinstate relationships only between given nodes.
        graph_nodes = graph.nodes
        for node in graph_nodes:
            if node.parent:
                node.parent = graph[node.parent.name] if node.parent in graph.nodes else None
            node.children = [c for c in node.children if c in graph.nodes]
            if node.is_bus and not node.parent:
                node.is_bus = False

        return graph

    def get_node_selection(
        self,
        node_kinds: Optional[List[str]] = None,
        edge_kinds: Optional[List[str]] = None,
        depth: int = 2,
        selection_mode: str = "dependent",
    ) -> List[Node]:
        """Select specific nodes from this graph in a structured order.

        Arguments:
            node_kinds: The kind of nodes to be selected.
            edge_kinds: The kind of edges to be selected.
            depth: The maximum depth of node to be selected.
            selection_mode: The selection mode. Either 'dependent' or 'independent'.

        Note:
            The selection mode argument determines how nodes of different kinds are
            selected. If the selection mode is set to 'dependent', the first node kind
            in the `node_kinds` list is considered to be the 'lead node kind'.
            Nodes of different kind than the lead node kind, are only selected if they
            have a dependency with at least one of the selected nodes of the lead node
            kind. If the selection mode is set to 'independent' this dependency
            condition is dropped.
        """
        nkinds = node_kinds or self.node_kinds
        ekinds = edge_kinds or self.edge_kinds
        return utils.select_nodes(self, nkinds, ekinds, depth, selection_mode)

    def get_edge_selection(
        self, nodes: Optional[List[Node]] = None, edge_kinds: Optional[List[str]] = None
    ) -> List[Edge]:
        """Select specific edges from this graph.

        Arguments:
            nodes: The list of nodes between which the edges must be selected.
            edge_kinds: The kinds of edges to be selected.

        Returns:
            List of selected edges.
        """

        nlist = nodes or self.get_node_selection(edge_kinds=edge_kinds)
        ekinds = edge_kinds or self.edge_kinds
        ekinds = sorted(set(ekinds))

        return [e for e in self.edges_between_all(sources=nlist, targets=nlist) if e.kind in ekinds]

    def get_node_and_edge_selection(
        self,
        node_kinds: Optional[List[str]] = None,
        edge_kinds: Optional[List[str]] = None,
        depth: int = 2,
        selection_mode: str = "dependent",
    ) -> Tuple[List[Node], List[Edge]]:
        """Select specific nodes and edges from this graph in a structured order.

        Arguments:
            node_kinds: The kind of nodes to be selected.
            edge_kinds: The kind of edges to be selected.
            depth: The maximum depth of node to be selected.
            selection_mode: The selection mode. Either 'dependent' or 'independent'.

        Note:
            The selection mode argument determines how nodes of different kinds are
            selected. If the selection mode is set to 'dependent', the first node kind
            in the `node_kinds` list is considered to be the 'lead node kind'.
            Nodes of different kind than the lead node kind, are only selected if they
            have a dependency with at least one of the selected nodes of the lead node
            kind. If the selection mode is set to 'independent' this dependency
            condition is dropped.
        """

        nodes = self.get_node_selection(
            node_kinds=node_kinds,
            edge_kinds=edge_kinds,
            depth=depth,
            selection_mode=selection_mode,
        )

        edges = self.get_edge_selection(nodes=nodes, edge_kinds=edge_kinds)

        return nodes, edges

    def get_hierarchy_dict(self, roots: Optional[List[Node]] = None, levels: Optional[int] = None):
        """Return a dictionary of the (partial) hierarchical node structure.

        Arguments:
            roots: Root nodes of the hierarchy to calculate.
            levels: Number of levels to include below the roots.
        """
        if roots is None:
            roots = self.roots

        if levels is None:
            return {root.name: self.get_hierarchy_dict(root.children) for root in roots}
        elif levels > 0:
            return {
                root.name: self.get_hierarchy_dict(root.children, levels=levels - 1)
                for root in roots
            }

        return {root.name: dict() for root in roots}

    def get_adjacency_matrix(
        self,
        nodes: Optional[Union[List[Node], List[str]]] = None,
        inherit: bool = False,
        loops: bool = False,
        only: Optional[List[str]] = None,
    ) -> Union["np.ndarray", List[List[float]]]:
        """Convert graph data into a numerical adjacency matrix.

        Arguments:
            nodes: Optional list of nodes for which to get the adjacency matrix.
            inherit: Whether to count weights between children of the given nodes.
            loops: Whether to calculate self-loops from a node to itself.
            only: Optional subset of edge weights to consider. See [`Edge`][ragraph.edge.Edge] for
                default edge weight implementation.

        Returns:
            Adjacency matrix as a 2D numpy array if numpy is present. Otherwise it will
            return a 2D nested list.
        """
        from ragraph.io.matrix import to_matrix

        return to_matrix(self, rows=nodes, cols=nodes, inherit=inherit, loops=loops, only=only)

    def get_mapping_matrix(
        self,
        rows: Optional[Union[List[Node], List[str]]] = None,
        cols: Optional[Union[List[Node], List[str]]] = None,
        inherit: bool = False,
        loops: bool = False,
        only: Optional[List[str]] = None,
    ) -> Union["np.ndarray", List[List[float]]]:
        """Convert graph data into a numerical mapping matrix.

        Arguments:
            rows: Nodes representing the matrix rows.
            cols: Nodes representing the matrix columns if different from the rows.
            inherit: Whether to count weights between children of the given nodes.
            loops: Whether to calculate self-loops from a node to itself.
            only: Optional subset of edge weights to consider. See [`Edge`][ragraph.edge.Edge] for
                default edge weight implementation.

        Returns:
            Adjacency matrix as a 2D numpy array if numpy is present. Otherwise it will
            return a 2D nested list.
        """
        from ragraph.io.matrix import to_matrix

        return to_matrix(self, rows=rows, cols=cols, inherit=inherit, loops=loops, only=only)

    def get_ascii_art(
        self,
        nodes: Optional[List[Node]] = None,
        edge: str = "X",
        diag: str = "\u25A0",
        show: bool = False,
    ) -> Optional[str]:
        """Get a unicode ASCII art representation of a binary adjacency matrix for a
        given set of nodes.

        Arguments:
            nodes: Nodes to include in the art.
            edge: Mark to use when there's an edge between nodes. (X by default)
            diag: Mark to use on the diagonal.
            show: Whether to directly print the ASCII art.

        Returns:
            ASCII art representation of this graph.
        """
        if nodes is None:
            nodes = self.leafs
        if not nodes:
            raise ValueError("Empty graph, cannot create ASCII art.")

        namelens = [len(n.name) for n in nodes]
        maxlen = max(namelens)
        dim = len(nodes)

        ddd = "\u2500\u2500\u2500"  # ---
        pad = maxlen * " "

        # Grid lines
        topline = pad + "\u250C" + (dim - 1) * (ddd + "\u252C") + ddd + "\u2510"
        sepline = pad + "\u251C" + (dim - 1) * (ddd + "\u253C") + ddd + "\u2524"
        endline = pad + "\u2514" + (dim - 1) * (ddd + "\u2534") + ddd + "\u2518"

        lines = [topline]

        for i, tgt in enumerate(nodes):
            line = (maxlen - namelens[i]) * " " + tgt.name + "\u2525"
            for j, src in enumerate(nodes):
                if i == j:
                    mark = diag
                elif self._directed_edges.get(src.name, dict()).get(tgt.name, False):
                    mark = edge
                else:
                    mark = " "
                line += " {} \u2502".format(mark)

            lines.append(line)
            lines.append(sepline)
        lines[-1] = endline
        txt = "\n".join(lines)
        if show:
            print(txt)
            return None
        else:
            return txt

    @property
    def json_dict(self) -> Dict[str, Any]:
        """JSON dictionary representation."""
        return self.as_dict(use_uuid=True)

    def as_dict(self, use_uuid: bool = False) -> Dict[str, Any]:
        """Return a copy as a (serializable) dictionary.

        Arguments:
            use_uuid: Whether to use UUIDs instead of names.

        Returns:
            nodes: Node names or UUIDs to Node dictionaries.
            kind: Kind as str.
            labels: Labels as list of str.
            weights: Weights as dict.
            annotations: Annotations as a dictionary.
            uuid: UUID as str if toggled.

        """
        if use_uuid:
            return dict(
                nodes={str(n.uuid): n.json_dict for n in self.nodes},
                edges={str(e.uuid): e.json_dict for e in self.edges},
                name=self.name,
                kind=self.kind,
                labels=self.labels,
                weights=self.weights,
                annotations=self.annotations.as_dict(),
                uuid=str(self.uuid),
            )
        else:
            return dict(
                nodes={n.name: n.as_dict(use_uuid=False) for n in self.nodes},
                edges={e.name: e.as_dict(use_uuid=False) for e in self.edges},
                name=self.name,
                kind=self.kind,
                labels=self.labels,
                weights=self.weights,
                annotations=self.annotations.as_dict(),
            )

    def check_consistency(self, raise_error: bool = True) -> bool:
        """Check the consistency of this graph.

        Arguments:
            raise_error: Whether to raise an error instead of returning a bool.

        Returns:
            Whether nodes aren't their own ancestor/descendant and if all their children
            and parents exist in the graph. Raises an error for inconsistencies if
            `raise_error` is set to `True`.
        """
        consistent = True

        try:
            for node in self.node_list:
                a = node
                while a.parent:
                    if a.parent == node:
                        raise ConsistencyError("Node {} is in its own ancestors.".format(node))
                    a = a.parent
                ds = node.children
                while ds:
                    if node in ds:
                        raise ConsistencyError("Node {} is in its own descendants.".format(node))
                    ds = [c for d in ds for c in d.children]
                if node.parent and node.parent not in self.node_list:
                    raise ConsistencyError(
                        "Node {}'s parent {} is missing in the graph.".format(node, node.parent)
                    )
                if node.parent and node not in node.parent.children:
                    raise ConsistencyError(
                        "Node {}'s does not occur in parent {}'s children.".format(
                            node, node.parent
                        )
                    )
                for child in node.children:
                    if child not in self.node_list:
                        raise ConsistencyError(
                            "Node {}'s child {} is missing in the graph.".format(node, child)
                        )
                    if child.parent != node:
                        raise ConsistencyError(
                            "Node {}'s child has a different parent {}.".format(node, child.parent)
                        )
                if node.is_bus and not node.parent:
                    raise ConsistencyError(
                        "Node {} is a bus node, but has no parent to be it for.".format(node)
                    )

            for edge in self.edge_list:
                if edge.source.name not in self.node_dict:
                    raise ConsistencyError(f"Edge source not in Graph: {repr(edge.source)}")
                if edge.target.name not in self.node_dict:
                    raise ConsistencyError(f"Edge target not in Graph: {repr(edge.source)}")

        except ConsistencyError as e:
            consistent = False
            if raise_error:
                raise e

        return consistent


class GraphView(Graph):
    """A view on a [`Graph`][ragraph.graph.Graph] object. It works exactly like the
    [`Graph`][ragraph.graph.Graph] object, except for that the nodes and edges are filtered
    according to the view function.

    Arguments:
        graph: Graph to provide a view on.
        view_func: View function that filters the [`Graph`][ragraph.graph.Graph]'s nodes and edges.
        view_kwargs: Optional arguments to pass to the view function.
    """

    def __init__(
        self,
        graph: Graph,
        view_func: Optional[
            Callable[
                [Any],
                Tuple[
                    Union[Iterable[Node], Dict[str, Node]],
                    Union[Iterable[Edge], Dict[str, Edge]],
                ],
            ]
        ] = None,
        view_kwargs: Optional[Dict[str, Any]] = None,
    ):
        Metadata.__init__(
            self,
            name=graph.name,
            kind=graph.kind,
            labels=graph.labels,
            weights=graph.weights,
            annotations=graph.annotations,
        )
        self._graph = graph
        self._view_func = view_func
        self._view_kwargs = view_kwargs

        self._vnodes: Optional[Dict[str, Node]] = None
        self._vnode_uuid_dict: Optional[Dict[UUID, Node]] = None
        self._vedges: Optional[Dict[str, Edge]] = None
        self._vedge_uuid_dict: Optional[Dict[UUID, Edge]] = None
        self._vdirected_edges: Optional[defaultdict] = None
        self._vreversed_edges: Optional[defaultdict] = None
        self.update()

    @property
    def graph(self) -> Graph:
        """Graph to provide a view on."""
        return self._graph

    @graph.setter
    def graph(self, value: Graph):
        self._graph = value
        self.reset()

    @property
    def view_func(
        self,
    ) -> Optional[
        Callable[
            [Any],
            Tuple[
                Union[Iterable[Node], Dict[str, Node]],
                Union[Iterable[Edge], Dict[str, Edge]],
            ],
        ]
    ]:
        """View function that filters the [`Graph`][ragraph.graph.Graph]'s nodes and edges."""
        return self._view_func

    @view_func.setter
    def view_func(
        self,
        value: Optional[
            Callable[
                [Any],
                Tuple[
                    Union[Iterable[Node], Dict[str, Node]],
                    Union[Iterable[Edge], Dict[str, Edge]],
                ],
            ]
        ],
    ):
        self._view_func = value
        self.reset()

    @property
    def view_kwargs(self) -> Dict[str, Any]:
        """Optional arguments to pass to the view function."""
        if self._view_kwargs is None:
            return dict()
        return self._view_kwargs

    @view_kwargs.setter
    def view_kwargs(self, value: Dict[str, Any]):
        self._view_kwargs = value
        self.reset()

    def update(self, **kwargs):
        """Apply the set view function with arguments."""
        # Just proxy if no view function is set.
        if self.view_func is None:
            return self.proxy()

        # Get view filter results.
        view_kwargs = kwargs or self.view_kwargs
        nodes, edges = self.view_func(self.graph, **view_kwargs)

        # Cast to dicts.
        ndict = nodes if isinstance(nodes, dict) else {n.name: n for n in nodes}
        edict = edges if isinstance(edges, dict) else {e.name: e for e in edges}

        # Populate directed/reversed edges.
        directed_edges = defaultdict(lambda: defaultdict(list))
        reversed_edges = defaultdict(lambda: defaultdict(list))
        for e in edict.values():
            directed_edges[e.source.name][e.target.name].append(e)
            reversed_edges[e.target.name][e.source.name].append(e)

        # Update private views.
        self._vnodes, self._vedges = ndict, edict
        self._vnode_uuid_dict = {n.uuid: n for n in ndict.values()}
        self._vedge_uuid_dict = {e.uuid: e for e in edict.values()}
        self._vdirected_edges, self._vreversed_edges = directed_edges, reversed_edges

        # Save view kwargs after view succeeded.
        self._view_kwargs = view_kwargs

    def proxy(self):
        """Undo any filtering and just set this view to proxy the Graph 1:1."""
        self._vnodes, self._vedges = self.graph._nodes, self.graph._edges
        self._vnode_uuid_dict = self.graph.node_uuid_dict
        self._vedge_uuid_dict = self.graph.edge_uuid_dict
        self._vdirected_edges = self.graph._directed_edges
        self._vreversed_edges = self.graph._reversed_edges

    def reset(self):
        """Reset the private properties after the original graph might have changed."""
        self._vnodes = None
        self._vnode_uuid_dict = None
        self._vedges = None
        self._vedge_uuid_dict = None
        self._vdirected_edges = None
        self._vreversed_edges = None

    @property  # type: ignore
    def _nodes(self) -> Optional[Dict[str, Node]]:  # type: ignore
        if self._vnodes is None:
            self.update()
        return self._vnodes

    @_nodes.setter
    def _nodes(self, value: Dict[str, Node]):
        self.graph._nodes = value
        self.reset()

    @property  # type: ignore
    def node_uuid_dict(self) -> Optional[Dict[UUID, Node]]:  # type: ignore
        if self._vnode_uuid_dict is None:
            self.update()
        return self._vnode_uuid_dict

    @property  # type: ignore
    def _edges(self) -> Optional[Dict[str, Edge]]:  # type: ignore
        if self._vedges is None:
            self.update()
        return self._vedges

    @_edges.setter
    def _edges(self, value: Dict[str, Edge]):
        self.graph._edges = value
        self.reset()

    @property  # type: ignore
    def edge_uuid_dict(self) -> Optional[Dict[UUID, Edge]]:  # type: ignore
        if self._vedge_uuid_dict is None:
            self.update()
        return self._vedge_uuid_dict

    @property  # type: ignore
    def _directed_edges(self) -> defaultdict:  # type: ignore
        if self._vdirected_edges is None:
            self.update()
        assert self._vdirected_edges is not None
        return self._vdirected_edges

    @_directed_edges.setter
    def _directed_edges(self, value: defaultdict):
        self.graph._directed_edges = value
        self.reset()

    @property  # type: ignore
    def _reversed_edges(self) -> defaultdict:  # type: ignore
        if self._vreversed_edges is None:
            self.update()
        assert self._vreversed_edges is not None
        return self._vreversed_edges

    @_reversed_edges.setter
    def _reversed_edges(self, value: defaultdict):
        self.graph._reversed_edges = value
        self.reset()

    def add_node(self, node: Node):
        self.graph.add_node(node)
        self.reset()

    def del_node(self, node: Union[Node, str, UUID], inherit: bool = False):
        self.graph.del_node(node, inherit=inherit)
        self.reset()

    def add_edge(self, edge: Edge):
        self.graph.add_edge(edge)
        self.reset()

    def del_edge(self, edge: Union[Edge, str, UUID]):
        self.graph.del_edge(edge)
        self.reset()
