# noqa: PLC0302
r"""Deprecated class that handles interactions with the OmniGraphs - use og.Controller instead

  _____   ______  _____   _____   ______  _____         _______  ______  _____
 |  __ \ |  ____||  __ \ |  __ \ |  ____|/ ____|    /\ |__   __||  ____||  __ \
 | |  | || |__   | |__) || |__) || |__  | |        /  \   | |   | |__   | |  | |
 | |  | ||  __|  |  ___/ |  _  / |  __| | |       / /\ \  | |   |  __|  | |  | |
 | |__| || |____ | |     | | \ \ | |____| |____  / ____ \ | |   | |____ | |__| |
 |_____/ |______||_|     |_|  \_\|______|\_____|/_/    \_\|_|   |______||_____/

"""
import os
from contextlib import suppress
from typing import Any, Dict, List, Optional, Tuple, Union

import omni.graph.core as og
import omni.graph.tools as ogt
import omni.kit.commands
from carb import log_info, log_warn
from pxr import Sdf, Tf, Usd

from ..errors import OmniGraphError
from ..object_lookup import ObjectLookup
from ..typing import Attribute_t, Node_t
from ..utils import DBG_EVAL, dbg_eval
from .context_helper import ContextHelper
from .utils import (
    ATTRIBUTE_TYPE_HINTS,
    ATTRIBUTE_TYPE_OR_LIST,
    ATTRIBUTE_TYPE_TYPE_HINTS,
    ATTRIBUTE_VALUE_PAIR,
    ATTRIBUTE_VALUE_PAIRS,
    EXTENDED_ATTRIBUTE_TYPE_HINTS,
    NODE_TYPE_HINTS,
    NODE_TYPE_OR_LIST,
)

# Function argument types passed to the various graph interface operations
ConnectData_t = Tuple[Node_t, Attribute_t, Node_t, Attribute_t]
ConnectDatas_t = Union[ConnectData_t, List[ConnectData_t]]
CreateNodeData_t = Tuple[str, Node_t]
CreateNodeDatas_t = Union[CreateNodeData_t, List[CreateNodeData_t]]
CreatePrimData_t = Tuple[str, Dict[str, Tuple[str, Any]]]
CreatePrimDatas_t = Union[CreatePrimData_t, List[CreatePrimData_t]]
DeleteNodeData_t = str
DeleteNodeDatas_t = Union[DeleteNodeData_t, List[DeleteNodeData_t]]
DisconnectData_t = Tuple[Node_t, Attribute_t, Node_t, Attribute_t]
DisconnectDatas_t = Union[DisconnectData_t, List[DisconnectData_t]]
SetValueData_t = Tuple[Attribute_t, Any]
SetValueDatas_t = Union[SetValueData_t, List[SetValueData_t]]

# Decoded attribute bundle possibilities
BundledAttribute_t = Optional[Union[Usd.Prim, Usd.Property]]


# =====================================================================
@ogt.DeprecatedClass("og.OmniGraphHelper is deprecated after version 1.5.0. Use og.Controller instead.")
class OmniGraphHelper:
    """Class to provide a simple interface to OmniGraph manipulation functions

    Provides functions for creating nodes, making and breaking connections, and setting values.
    Graph manipulation functions are undoable, value changes are not.

    The main benefit this class provides over direct manipulation of the graph is that it accepts
    a variety of types for its operations. e.g. a connection could take an OmniGraph Node, a USD Prim,
    or a string indicating the path to the node.

    Attributes:
        graph: The OmniGraph on which operations will be performed.

    Raises:
        OmniGraphError: If there is not enough information to perform the requested operation
    """

    # Type of connection point
    PRIM_TYPE = 0
    BUNDLE_TYPE = 1
    REGULAR_TYPE = 2
    UNKNOWN = -1

    # ----------------------------------------------------------------------
    def __init__(self, omnigraph: Optional[og.Graph] = None):
        """Initialize the graph"""
        if omnigraph is None:
            self.graph = og.get_current_graph()
        else:
            self.graph = omnigraph

    # ----------------------------------------------------------------------
    def context(self):
        """Returns the evaluation context for the graph to which this helper is attached"""
        return self.graph.get_default_graph_context()

    # ----------------------------------------------------------------------
    async def evaluate(self):
        """Wait for the next Graph evaluation cycle - await this function to ensure it is finished before returning.
        If the graph evaluation time is not incremented after 10 application updates, RuntimeError is raised.
        """
        start_t = self.context().get_time_since_start()
        # Ensure that a graph evaluation has happened before returning
        for _ in range(10):
            await omni.kit.app.get_app().next_update_async()
            now_t = self.context().get_time_since_start()
            if now_t > start_t:
                return
        raise RuntimeError(f"Graph evaluation time {now_t} was not incremented from start time {start_t}")

    # ----------------------------------------------------------------------
    @staticmethod
    def safe_node_name(node_type_name: str, abbreviated: bool = False) -> str:
        """Returns a USD-safe node name derived from the node_type_name (stripping namespace)

        Args:
            node_type_name: Fully namespaced name of the node type
            abbreviated: If True then remove the namespace, else just make the separators into underscores

        Returns:
            A safe node name that roughly corresponds to the given node type name
        """
        if abbreviated:
            last_namespace = node_type_name.rfind(".")
            if last_namespace < 0:
                return node_type_name
            return node_type_name[last_namespace + 1 :]
        return node_type_name.replace(".", "_")

    # ----------------------------------------------------------------------
    def omnigraph_node(self, node_info: NODE_TYPE_OR_LIST) -> Union[og.Node, List[og.Node]]:
        """Returns the OmniGraph node(s) corresponding to the variable type parameter

        Args:
            node_info: Information for the node to find. If a list then iterate over the list

        Returns:
            Node(s) corresponding to the description(s) in the current graph, None where there is no match

        Raises:
            OmniGraphError if the node description wasn't one of the recognized types
        """
        return ObjectLookup.node(node_info)

    # ----------------------------------------------------------------------
    def omnigraph_attribute(
        self, node: NODE_TYPE_HINTS, attribute_info: ATTRIBUTE_TYPE_OR_LIST
    ) -> Union[og.Attribute, List[og.Attribute]]:
        """Returns the OmniGraph attribute(s) corresponding to the variable type parameter

        Args:
            node: Node to which the attribute belongs. Ignored if attribute_info is an og.Attribute
            attribute_info: Information on which attribute to look for. If a list then get all of them

        Returns:
            Attribute(s) matching the description(s) - None where there is no match

        Raises:
            OmniGraphError if the attribute description wasn't one of the recognized types, or if
            any of the attributes could not be found
        """
        return ObjectLookup.attribute(attribute_info, node)

    # ----------------------------------------------------------------------
    def node_as_prim(self, node: NODE_TYPE_HINTS) -> Usd.Prim:
        """Returns the prim corresponding to the variable type parameter"""
        if isinstance(node, Sdf.Path):
            return omni.usd.get_context().get_stage().GetPrimAtPath(node.GetPrimPath())
        if isinstance(node, og.Node):
            return omni.usd.get_context().get_stage().GetPrimAtPath(node.get_prim_path())
        if isinstance(node, Usd.Prim):
            return node
        with suppress(Exception):
            if isinstance(node, str):
                return omni.usd.get_context().get_stage().GetPrimAtPath(self.graph.get_node(node).get_prim_path())
        raise OmniGraphError(f"Failed to get prim on {node}")

    # ----------------------------------------------------------------------
    def get_prim_path(self, node: NODE_TYPE_HINTS):
        """Returns the prim path corresponding to the variable type parameter"""
        if isinstance(node, Sdf.Path):
            return node.GetPrimPath()
        if isinstance(node, og.Node):
            return node.get_prim_path()
        if isinstance(node, Usd.Prim):
            return node.GetPath()
        with suppress(Exception):
            if isinstance(node, str):
                return self.graph.get_node(node).get_prim_path()
        raise OmniGraphError(f"Failed to get prim path on {node}")

    # ----------------------------------------------------------------------
    # begin-create-attribute-function
    def create_attribute(
        self,
        node: NODE_TYPE_HINTS,
        attr_name: str,
        attr_type: ATTRIBUTE_TYPE_TYPE_HINTS,
        attr_port: Optional[og.AttributePortType] = og.AttributePortType.ATTRIBUTE_PORT_TYPE_INPUT,
        attr_default: Optional[Any] = None,
        attr_extended_type: Optional[
            EXTENDED_ATTRIBUTE_TYPE_HINTS
        ] = og.ExtendedAttributeType.EXTENDED_ATTR_TYPE_REGULAR,
    ) -> Optional[og.Attribute]:
        """Create a new dynamic attribute on the node

        Args:
            node: Node on which to create the attribute (path or og.Node)
            attr_name: Name of the new attribute, either with or without the port namespace
            attr_type: Type of the new attribute, as an OGN type string or og.Type
            attr_port: Port type of the new attribute, default is og.AttributePortType.ATTRIBUTE_PORT_TYPE_INPUT
            attr_default: The initial value to set on the attribute, default is None
            attr_extended_type: The extended type of the attribute, default is
                                og.ExtendedAttributeType.EXTENDED_ATTR_TYPE_REGULAR. If the extended type is
                                og.ExtendedAttributeType.EXTENDED_ATTR_TYPE_UNION then this parameter will be a
                                2-tuple with the second element being a list or comma-separated string of union types

        Returns:
            The newly created attribute, None if there was a problem creating it
        """
        # end-create-attribute-function
        ogt.dbg(f"Create attribute '{attr_name}' on node {node} of type {attr_type}")
        omg_node = self.omnigraph_node(node)
        omg_attr_type = ObjectLookup.attribute_type(attr_type)
        (success, result) = og.cmds.CreateAttr(
            node=omg_node,
            attr_name=attr_name,
            attr_type=omg_attr_type,
            attr_port=attr_port,
            attr_default=attr_default,
            attr_extended_type=attr_extended_type,
        )
        if not result or not success:
            return None
        namespace = og.get_port_type_namespace(attr_port)
        if not attr_name.startswith(namespace):
            if (
                omg_attr_type.get_type_name() == "bundle"
                and attr_port != og.AttributePortType.ATTRIBUTE_PORT_TYPE_INPUT
            ):
                separator = "_"
            else:
                separator = ":"
            attr_name = f"{namespace}{separator}{attr_name}"
        return omg_node.get_attribute(attr_name)

    # ----------------------------------------------------------------------
    # begin-remove-attribute-function
    def remove_attribute(self, attribute: ATTRIBUTE_TYPE_HINTS, node: Optional[NODE_TYPE_HINTS] = None) -> bool:
        """Removes an existing dynamic attribute from a node.

        Args:
            attribute: Name of the attribute to be removed
            node: If not None and the attribute is specified as a string then this is the node on which it lives

        Returns:
            True if the attribute was successfully removed, else False
        """
        # end-remove-attribute-function
        ogt.dbg(f"Remove attribute {attribute} using node {node}")
        omg_attribute = self.omnigraph_attribute(node, attribute)
        (success, result) = og.cmds.RemoveAttr(attribute=omg_attribute)
        return success and result

    # ----------------------------------------------------------------------
    def create_node(
        self, node_path: str, node_type: str, allow_exists: bool = False, version: Optional[int] = None
    ) -> og.Node:
        """Create an OmniGraph node of the given type and version at the given path.

        Args:
            node_path: SdfPath to the node in the stage
            node_type: Type of node to create
            check_exists: If true then succeed if a node with matching path, type, and version already exists
            version: Version of the node type to create. By default it creates the most recent.

        Raises:
            OmniGraphError: If the node already existed

        Returns:
            OmniGraph node added to the scene, or None if it could not be created
        """
        ogt.dbg(f"Create '{node_path}' of type '{node_type}', allow={allow_exists}, version={version}")
        if version is not None:
            raise OmniGraphError("Creating nodes with specific versions not supported")
        node = self.graph.get_node(node_path)
        if node is not None and node.is_valid():
            if allow_exists:
                current_node_type = node.get_type_name()
                if node_type == current_node_type:
                    return node
                error = f"already exists as type {current_node_type}"
            else:
                error = "already exists"
            raise OmniGraphError(f"Creation of {node_path} as type {node_type} failed - {error}")
        og.cmds.CreateNode(graph=self.graph, node_path=node_path, node_type=node_type, create_usd=True)
        return self.graph.get_node(node_path)

    # ----------------------------------------------------------------------
    def create_prim(self, prim_path: str, attribute_info: Dict[str, Tuple[Union[str, og.Type], Any]]):
        """Create a prim node containing a predefined set of attribute values and a ReadPrim OmniGraph node for it

        Args:
            prim_path: Location of the prim
            attribute_info: Dictionary of {NAME: (TYPE, VALUE)} for all prim attributes
                The TYPE is in OGN format, so "float[3][]", "bundle", etc.
                The VALUE should be in a format suitable for passing to pxr::UsdAttribute.Set()

        Returns:
            og.Node of the created node
        """
        stage = omni.usd.get_context().get_stage()
        omni.kit.commands.execute("CreatePrim", prim_path=prim_path, prim_type="BundleSource")
        prim = stage.GetPrimAtPath(prim_path)

        # Walk the list of attribute descriptions, creating them on the prim as they go
        for attribute_name, (attribute_type_name, attribute_value) in attribute_info.items():
            if isinstance(attribute_type_name, og.Type):
                attribute_type = attribute_type_name
                attribute_type_name = attribute_type.get_ogn_type_name()
            else:
                attribute_type = og.AttributeType.type_from_ogn_type_name(attribute_type_name)
            manager = ogt.get_attribute_manager_type(attribute_type_name)
            sdf_type_name = manager.sdf_type_name()
            if sdf_type_name is not None:
                sdf_type = getattr(Sdf.ValueTypeNames, sdf_type_name)
                usd_value = og.attribute_value_as_usd(attribute_type, attribute_value)
                prim.CreateAttribute(attribute_name, sdf_type).Set(usd_value)

        # Add the new Prim to OmniGraph
        return og.cmds.CreateNode(
            graph=self.graph, node_path=prim_path, node_type="omni.graph.core.Prim", create_usd=False
        )[1]

    # ----------------------------------------------------------------------
    def delete_node(self, node: NODE_TYPE_HINTS, allow_noexists: bool = False) -> bool:
        """Deletes an OmniGraph node at the given path.

        Args:
            node: node to be deleted
            allow_noexists: If true then succeed if no node with matching path exists

        Raises:
            OmniGraphError: If the node does not exist

        Returns:
            True if the node is gone
        """
        ogt.dbg(f"Delete '{node}', allow_noexists={allow_noexists}")
        try:
            omnigraph_node = self.omnigraph_node(node)
        except Exception:
            if allow_noexists:
                return True
            raise
        return og.cmds.DeleteNode(graph=self.graph, node_path=omnigraph_node.get_prim_path(), modify_usd=True)[0]

    # ----------------------------------------------------------------------
    def attach_to_prim(self, prim: Union[Usd.Prim, str]) -> og.Node:
        """Create a new compute node attached to an ordinary USD prim.

        Args:
            prim: Prim node or name of prim node to which the OmniGraph node should attach

        Returns:
            OmniGraph Prim type node associated with the passed in Prim
        """
        ogt.dbg(f"Attach to prim {prim}")
        if isinstance(prim, str):
            prim_path = prim
        else:
            prim_path = str(prim.GetPath())
        return self.create_node(prim_path, "omni.graph.core.Prim", allow_exists=True)

    # ----------------------------------------------------------------------
    @staticmethod
    def get_attr_type_name(attr):
        """
        Get the type name for the given attribute, taking in to account extended type

        Args:
            attr: Attribute whose type is to be determined

        Returns:
            The type name of the attribute
        """
        type_name = None
        with suppress(AttributeError):
            if attr.get_extended_type() in [
                og.ExtendedAttributeType.EXTENDED_ATTR_TYPE_ANY,
                og.ExtendedAttributeType.EXTENDED_ATTR_TYPE_UNION,
            ]:
                type_name = attr.get_resolved_type().get_type_name()

        if type_name is None:
            try:
                type_name = attr.get_type_name()
            except AttributeError:
                type_name = attr.get_type().get_type_name()
        return type_name

    # ----------------------------------------------------------------------
    @staticmethod
    def get_attr_type(attr: Union[og.Attribute, og.AttributeData]):
        """
        Get the type for the given attribute, taking in to account extended type

        Args:
            attr: Attribute whose type is to be determined

        Returns:
            The type of the attribute
        """
        return attr.get_resolved_type() if isinstance(attr, og.Attribute) else attr.get_type()

    # ----------------------------------------------------------------------
    def _decode_connection_point(
        self, node_info: NODE_TYPE_HINTS, attr_info: ATTRIBUTE_TYPE_HINTS
    ) -> Tuple[int, Optional[Usd.Prim], og.Node, Optional[og.Attribute]]:
        """Decode a connection point into identifying pieces

        Args:
            node_info: Identifying information for an OmniGraph node
            attr_info: Identifying information for an OmniGraph node's attribute

        Returns:
            (location_type, prim, node, attribute)
                location_type: what type of connection point this is - bundle, regular, or prim
                prim: Prim of the connection point, or None if it is not a prim type
                node: og.Node of the connection point, or None if it is a prim
                attribute: og.Attribute of the connection point, or None if it is a prim

        Raises:
            OmniGraphError if the configuration of the connection point is not consistent
        """
        attr_type = self.UNKNOWN
        prim = None
        node = node_info
        attr = None
        if attr_info is None:
            prim = self.node_as_prim(node_info)
            if not prim.IsValid():
                raise OmniGraphError("When attribute is None, node must be a valid Prim")
            attr_type = self.PRIM_TYPE
        else:
            node = self.omnigraph_node(node_info)
            if not node.is_valid():
                raise OmniGraphError("When attribute is specified, node must be a valid OmniGraph node")
            attr = self.omnigraph_attribute(node, attr_info)
            if not attr.is_valid():
                raise OmniGraphError("Attribute is not valid")
            if attr.get_type_name() == "bundle":
                attr_type = self.BUNDLE_TYPE
            else:
                attr_type = self.REGULAR_TYPE
        assert attr_type != self.UNKNOWN
        return (attr_type, prim, node, attr)

    # ----------------------------------------------------------------------
    def _decode_connection_type(
        self,
        src_node_info: NODE_TYPE_HINTS,
        src_attr_info: ATTRIBUTE_TYPE_HINTS,
        dst_node_info: NODE_TYPE_HINTS,
        dst_attr_info: ATTRIBUTE_TYPE_HINTS,
    ) -> Tuple[Union[Usd.Prim, og.Attribute], og.Attribute]:
        """
        Decode the node and source information into an Attribute->Attribute connection or a Prim->Bundle connection.

        Everything is set up to be fairly generic so that if we have more general connections in the future it can
        still be handled using the same framework.

        The source and destination comes from _decode_connection_point() with these legal cases:
            Prim -> bundleAttribute
                src_attr_info is None, src_node_info is Prim, dst_attr_info.type == bundle
            bundleAttribute -> bundleAttribute
                src_attr_info.type == dst_attr_info.type == bundle
            attribute -> attribute
                src_attr_info.type != bundle, dst_attr_info.type != bundle

        Args:
            src_node_info: Node on the source (input) end of the connection.
            src_attr_info: Attribute on the source (input) end of the connection - None means connect from a Prim.
            dst_node_info: Node on the destination (output) end of the connection.
            dst_attr_info: Attribute on the destination (output) end of the connection.

        Returns:
            (src_location, dst_location):
                If a Prim->Bundle connection:
                    src_location: Usd.Prim where the connection originates
                    dst_location: og.Attribute where the connection terminates
                If an Attribute->Attribute connection
                    src_location: og.Attribute where the connection originates
                    dst_location: og.Attribute where the connection terminates

        Raises:
            OmniGraphError: If the connection request doesn't fall into one of the allowed cases
        """
        src_location = None
        dst_location = None
        src_path = f"{self.get_prim_path(src_node_info)}.{src_attr_info}"
        dst_path = f"{self.get_prim_path(dst_node_info)}.{dst_attr_info}"

        try:
            (src_type, src_prim, src_node, src_attr) = self._decode_connection_point(src_node_info, src_attr_info)
            (dst_type, _, dst_node, dst_attr) = self._decode_connection_point(dst_node_info, dst_attr_info)
            if src_type == self.PRIM_TYPE:
                if dst_type == self.PRIM_TYPE:
                    raise OmniGraphError("Prim -> Prim connection not valid, use AddRelationship/RemoveRelationship")
                if dst_type == self.REGULAR_TYPE:
                    raise OmniGraphError("Prim -> Attribute connection not allowed")
                if not dst_node or not dst_node.is_valid() or not dst_attr:
                    raise OmniGraphError("Destination is invalid")
                # Prim -> Bundle connection
                if dst_type == self.BUNDLE_TYPE:
                    src_location = src_prim
                    dst_location = dst_attr
                    ogt.dbg(f"Prim -> Bundle {src_path} -> {dst_path}")
            elif src_type == self.BUNDLE_TYPE:
                if dst_type == self.REGULAR_TYPE:
                    raise OmniGraphError("Bundle -> Attribute connection not allowed")
                if not src_node or not src_node.is_valid() or not src_attr:
                    raise OmniGraphError("Source is invalid")
                if not dst_node or not dst_node.is_valid() or not dst_attr:
                    raise OmniGraphError("Destination is invalid")
                # Bundle -> Bundle connection
                if dst_type == self.BUNDLE_TYPE:
                    src_location = src_attr
                    dst_location = dst_attr
                    ogt.dbg(f"Bundle -> Bundle {src_path} -> {dst_path}")
                # Bundle -> Prim connection
                if dst_type == self.PRIM_TYPE:
                    raise OmniGraphError("Bundle -> Prim connection not allowed")
            else:
                if dst_type == self.PRIM_TYPE:
                    raise OmniGraphError("Attribute -> Prim connection not allowed")
                if dst_type == self.BUNDLE_TYPE:
                    raise OmniGraphError("Attribute -> Bundle connection not allowed")
                if not src_node or not src_node.is_valid() or not src_attr:
                    raise OmniGraphError("Source is invalid")
                if not dst_node or not dst_node.is_valid() or not dst_attr:
                    raise OmniGraphError("Destination is invalid")
                # Attribute -> Attribute connection
                src_location = src_attr
                dst_location = dst_attr
                ogt.dbg(f"Attribute -> Attribute {src_path} -> {dst_path}")
        except OmniGraphError as error:
            raise OmniGraphError(f"{src_path} -> {dst_path}") from error

        return (src_location, dst_location)

    # ----------------------------------------------------------------------
    def connect(
        self,
        src_node: NODE_TYPE_HINTS,
        src_attr: ATTRIBUTE_TYPE_HINTS,
        dst_node: NODE_TYPE_HINTS,
        dst_attr: ATTRIBUTE_TYPE_HINTS,
    ):
        """Create a connection between two attributes

        Args:
            src_node: Node on the source (input) end of the connection. Ignored if src_attr is an og.Attribute
            src_attr: Attribute on the source (input) end of the connection. (If None then it's a prim connection)
            dst_node: Node on the destination (output) end of the connection. Ignored if dst_attr is an og.Attribute
            dst_attr: Attribute on the destination (output) end of the connection.

        Raises:
            OmniGraphError: If nodes or attributes could not be found, or connection fails
        """
        ogt.dbg(f"Connect {src_node},{src_attr} -> {dst_node},{dst_attr}")
        (src_location, dst_location) = self._decode_connection_type(src_node, src_attr, dst_node, dst_attr)

        if src_location is None or dst_location is None:
            success = False
            error = "Connection locations not recognized"
        elif isinstance(src_location, Usd.Prim):
            ogt.dbg("   Connect Prim -> Bundle")
            (success, error) = og.cmds.ConnectPrim(
                attr=dst_location,
                prim_path=src_location.GetPath().pathString,
                is_bundle_connection=src_attr is not None,
            )
        else:
            ogt.dbg(f"   Connect Attr {src_location.get_name()} to {dst_location.get_name()}")
            (success, error) = og.cmds.ConnectAttrs(src_attr=src_location, dest_attr=dst_location, modify_usd=True)
        if not success:
            raise OmniGraphError(
                f"Failed to connect {self.get_prim_path(src_node)}.{src_attr}"
                f" -> {self.get_prim_path(dst_node)}.{dst_attr} ({error})"
            )

    # ----------------------------------------------------------------------
    def disconnect(
        self,
        src_node: NODE_TYPE_HINTS,
        src_attr: ATTRIBUTE_TYPE_HINTS,
        dst_node: NODE_TYPE_HINTS,
        dst_attr: ATTRIBUTE_TYPE_HINTS,
    ):
        """Break a connection between two attributes

        Args:
            src_node: Node on the source (input) end of the connection. Ignored if src_attr is an og.Attribute
            src_attr: Attribute on the source (input) end of the connection. If None then it is a prim connection.
            dst_node: Node on the destination (output) end of the connection. Ignored if dst_attr is an og.Attribute
            dst_attr: Attribute on the destination (output) end of the connection.

        Raises:
            OmniGraphError: If nodes or attributes could not be found, connection didn't exist, or disconnection fails
        """
        ogt.dbg(f"Disconnect {src_node},{src_attr} -> {dst_node},{dst_attr}")
        (src_location, dst_location) = self._decode_connection_type(src_node, src_attr, dst_node, dst_attr)

        if src_location is None or dst_location is None:
            success = False
            error = "Connection locations not recognized"
        elif isinstance(src_location, Usd.Prim):
            (success, error) = og.cmds.DisconnectPrim(
                attr=dst_location,
                prim_path=src_location.GetPath().pathString,
                is_bundle_connection=src_attr is not None,
            )
        else:
            ogt.dbg(f"   Disconnect Attr {src_location.get_name()} from {dst_location.get_name()}")
            (success, error) = og.cmds.DisconnectAttrs(src_attr=src_location, dest_attr=dst_location, modify_usd=True)
        if not success:
            raise OmniGraphError(
                f"Failed to disconnect {self.get_prim_path(src_node)}.{src_attr}"
                f" -> {self.get_prim_path(dst_node)}.{dst_attr} ({error})"
            )

    # ----------------------------------------------------------------------
    def disconnect_all(
        self,
        attr: ATTRIBUTE_TYPE_HINTS,
        node: Optional[NODE_TYPE_HINTS] = None,
    ):
        """Break all connections to and from an attribute

        Args:
            attr: Attribute on the source (input) end of the connection. If None then it is a prim connection.
            node: Node on the source (input) end of the connection. Ignored if attr is an og.Attribute

        Raises:
            OmniGraphError: If nodes or attributes could not be found, connection didn't exist, or disconnection fails
        """
        ogt.dbg(f"Disconnect all on {node},{attr}")
        attribute = self.omnigraph_attribute(node, attr)

        if attribute is None:
            success = False
            error = "Disconnection attribute not recognized"
        else:
            ogt.dbg(f"   Disconnect All from {attribute.get_name()}")
            (success, error) = og.cmds.DisconnectAllAttrs(attr=attribute, modify_usd=True)
        if not success:
            raise OmniGraphError(f"Failed to break connections on {self.get_prim_path(node)}.{attr} ({error})")

    # ----------------------------------------------------------------------
    def set_attribute_data_values(
        self, values_to_set: List[Tuple[og.AttributeData, Any]], graph: Optional[og.Graph] = None
    ):
        """Set a bunch of attribute data values

        Args:
            values_to_set: List of (AttributeData, Value) pairs which are the values to be set

        Raises:
            OmniGraphError: If values could not be set
        """
        if not values_to_set:
            return

        for (attribute_data, value) in values_to_set:
            try:
                (success, error) = og.cmds.SetAttrData(attribute_data=attribute_data, value=value, graph=graph)
                if not success:
                    raise TypeError(error)
            except TypeError as error:
                raise OmniGraphError(f"Could not set value on attribute data '{attribute_data.get_name()}'") from error

    # ----------------------------------------------------------------------
    def set_attribute_values(self, values_to_set: List[Tuple[og.Attribute, Any]], ignore_usd: bool = False):
        """Set a bunch of attribute values

        Dict values can be used to specify a type in additional to a value.  If the attribute is unresolved, it will be
        resolved to that type before setting the value.  For example:

        set_attribute_values([(attrib, {"type": "float2", "value": (1.0, 2.0)})])

        Args:
            values_to_set: List of (Attribute, Value) pairs which are the values to be set.
            ignore_usd: If False then report any problems updating the USD attributes

        Raises:
            OmniGraphError: If values could not be set
        """
        if not values_to_set:
            return

        for (attribute, value) in values_to_set:
            set_type = None
            if isinstance(value, dict):
                # special case - we may need to resolve the attribute to this particular type before setting
                set_type = value["type"]
                value = value["value"]
            try:
                (success, error) = og.cmds.SetAttr(attr=attribute, value=value, set_type=set_type)
                if not success:
                    raise TypeError(error)
            except TypeError as error:
                raise OmniGraphError(f"Could not set value on attribute '{attribute.get_name()}'") from error

            # ----------------------------------------------------------------------------------------------------
            # TODO: This is necessary to update USD at the moment. Updating flatcache and USD should be handled
            #       directly in SetAttrCommand. Once that has been done this can be deleted.
            # {
            stage = omni.usd.get_context().get_stage()
            prim = stage.GetPrimAtPath(attribute.get_node().get_prim_path())
            if prim.IsValid() and attribute.get_extended_type() == og.ExtendedAttributeType.EXTENDED_ATTR_TYPE_REGULAR:
                try:
                    # The Set method wants USD types for some attribute types so do the translation first
                    value = og.attribute_value_as_usd(OmniGraphHelper.get_attr_type(attribute), value)
                    prim.GetAttribute(attribute.get_name()).Set(value)
                except Tf.ErrorException as error:
                    if not ignore_usd:
                        log_warn(f"Could not sync USD on attribute {attribute.get_name()} - {error}")
                except TypeError as error:
                    # TODO: This occurs when the parameters to Set() don't match what USD expects. It could be fixed
                    #       by special-casing every known mismatch but this section should be going away so it won't
                    #       be done at this time. The current known failures are the quaternion types and arrays of the
                    #       tuple-arrays (e.g. "quatd[4]", "double[3][]", "float[2][]", ...)
                    if not ignore_usd:
                        log_info(f"Could not set value on attribute {attribute.get_name()} - {error}")
                except Exception as error:  # noqa: PLW0703
                    log_warn(f"Unknown problem setting values - {error}")
            # }
            # ----------------------------------------------------------------------------------------------------

    # ----------------------------------------------------------------------
    def set_values(self, node: NODE_TYPE_HINTS, values_to_set: ATTRIBUTE_VALUE_PAIRS, ignore_usd: bool = False):
        """Set a bunch of attribute values on a single node

        This is general purpose for handling all types of node and attribute descriptions. If you already have
        the og.Attributes whose values you wish to set then call set_attribute_values() instead.

        Args:
            node: Node on which the values are to be set
            values_to_set: List or single element of (Attribute, Value) pairs which are the values to be set
            ignore_usd: If False then report any problems updating the USD attributes

        Raises:
            OmniGraphError: If nodes or attributes could not be found, or values could not be set
        """
        if not values_to_set:
            log_warn("Tried to set values from an empty list")
            return

        omnigraph_node = self.omnigraph_node(node)

        def set_a_value(attribute_and_value: ATTRIBUTE_VALUE_PAIR):
            """Try to set a single value, raising an OmniGraphError exception if anything went wrong"""
            if len(attribute_and_value) != 2:
                raise OmniGraphError(f"Values to set should be (attribute, value) pairs, not '{attribute_and_value}'")
            attribute = self.omnigraph_attribute(omnigraph_node, attribute_and_value[0])
            self.set_attribute_values([(attribute, attribute_and_value[1])], ignore_usd=ignore_usd)

        if isinstance(values_to_set, list):
            if values_to_set and not isinstance(values_to_set[0], tuple) and not isinstance(values_to_set[0], list):
                log_warn(f"Call set_values() with a tuple or array of tuples as attribute values, not {values_to_set}")
                set_a_value(values_to_set)
            else:
                for attribute_and_value in values_to_set:
                    set_a_value(attribute_and_value)
        else:
            set_a_value(values_to_set)

    # ----------------------------------------------------------------------
    def get_attribute_values(self, attributes_to_get: List[og.Attribute]) -> List[Any]:
        """Get the values from a list of defined attributes

        Args:
            attributes_to_get: List of attributes whose values are to be retrieved

        Returns:
            List of values corresponding to the list of attributes passed in
        """
        results = []
        context_helper = ContextHelper()
        for attribute in attributes_to_get:
            try:
                results.append(context_helper.get_attr_value(attribute))
            except TypeError as error:
                raise OmniGraphError(f"Could not get value on attribute '{attribute.get_name()}'") from error
        return results

    # ----------------------------------------------------------------------
    def get_values(self, node: NODE_TYPE_HINTS, attributes_to_get: ATTRIBUTE_TYPE_OR_LIST) -> List[Any]:
        """Get the values from attributes on a node

        This is general purpose for handling all types of node and attribute descriptions. If you already have
        the og.Attributes whose values you wish to retrieve then call get_attribute_values() instead.

        Args:
            node: Description of node whose values are to be read
            attributes_to_get: One or a list of descriptions of attributes whose values are to be retrieved

        Raises:
            OmniGraphError: If nodes or attributes could not be found, or values could not be read

        Returns:
            One or a list of values corresponding to the attributes passed in
        """
        omnigraph_node = self.omnigraph_node(node)
        if not isinstance(attributes_to_get, List):
            return self.get_attribute_values([self.omnigraph_attribute(omnigraph_node, attributes_to_get)])[0]

        return self.get_attribute_values(
            [self.omnigraph_attribute(omnigraph_node, attribute) for attribute in attributes_to_get]
        )

    # ----------------------------------------------------------------------
    def _edit_delete_nodes(self, root_prim: str, nodes_to_delete: DeleteNodeData_t):
        """Delete the set of nodes passed, using the format required by edit_graph"""
        delete_list = nodes_to_delete if isinstance(nodes_to_delete, list) else [nodes_to_delete]
        for node_name in delete_list:
            dbg_eval(f"Deleting node {node_name}")
            node_path = os.path.join(root_prim, node_name).replace("\\", "/")
            self.delete_node(node_path, False)
            dbg_eval(f"... deleted {node_path}")

    # ----------------------------------------------------------------------
    def _edit_create_nodes(self, root_prim: str, nodes_to_create: CreateNodeData_t):
        """Create the set of nodes passed, using the format required by edit_graph
        Returns a tuple of the map from node path to the created node, and the list of constructed nodes"""
        node_path_map = {}
        nodes_constructed = []
        create_list = nodes_to_create if isinstance(nodes_to_create, list) else [nodes_to_create]
        for node_type, node_name in create_list:
            dbg_eval(f"Creating node {node_name} of type {node_type}")
            node_path = os.path.join(root_prim, node_name).replace("\\", "/")
            node_path_map[node_name] = self.create_node(node_path, node_type, False)
            nodes_constructed.append(node_path_map[node_name])
            dbg_eval(f"... created {node_path_map[node_name]}")
        return (node_path_map, nodes_constructed)

    # ----------------------------------------------------------------------
    def _edit_create_prims(
        self,
        root_prim: str,
        node_path_map: Dict[str, og.Node],
        nodes_constructed: List[og.Node],
        prims_to_create: CreatePrimData_t,
    ):
        """Create the set of prims passed, using the format required by edit_graph"""
        create_list = prims_to_create if isinstance(prims_to_create, list) else [prims_to_create]
        for prim_path, attribute_info in create_list:
            dbg_eval(f"Creating prim at {prim_path}")
            node_path = os.path.join(root_prim, prim_path).replace("\\", "/")
            node_path_map[prim_path] = self.create_prim(node_path, attribute_info)
            nodes_constructed.append(node_path_map[prim_path])
            dbg_eval(f"... created {node_path_map[prim_path]}")
        return (node_path_map, nodes_constructed)

    # ----------------------------------------------------------------------
    def _edit_connect(self, node_path_map: Dict[str, og.Node], connections_to_make: ConnectData_t):
        """Make a set of attribute connections, using the format required by edit_graph"""
        connection_list = connections_to_make if isinstance(connections_to_make, list) else [connections_to_make]
        try:
            for (src_node_name, src_attr_name, dst_node_name, dst_attr_name) in connection_list:
                dbg_eval(f"Connecting {src_node_name}.{src_attr_name} -> {dst_node_name}.{dst_attr_name}")
                try:
                    src_node = node_path_map[src_node_name]
                except KeyError:
                    src_node = src_node_name
                try:
                    dst_node = node_path_map[dst_node_name]
                except KeyError:
                    dst_node = dst_node_name
                dbg_eval(f"...nodes resolved to {src_node} and {dst_node}")
                self.connect(src_node, src_attr_name, dst_node, dst_attr_name)
                dbg_eval("...connection succeeded")
        except ValueError as error:
            raise OmniGraphError(
                f"Connect requires src_node, src_attr, dst_node, dst_attr - got {connection_list}"
            ) from error

    # ----------------------------------------------------------------------
    def _edit_disconnect(self, node_path_map: Dict[str, og.Node], connections_to_break: DisconnectData_t):
        """Make a set of attribute disconnections, using the format required by edit_graph"""
        connection_list = connections_to_break if isinstance(connections_to_break, list) else [connections_to_break]
        try:
            for (src_node_name, src_attr_name, dst_node_name, dst_attr_name) in connection_list:
                dbg_eval(f"Disconnecting {src_node_name}.{src_attr_name} -> {dst_node_name}.{dst_attr_name}")
                try:
                    src_node = node_path_map[src_node_name]
                except KeyError:
                    src_node = src_node_name
                try:
                    dst_node = node_path_map[dst_node_name]
                except KeyError:
                    dst_node = dst_node_name
                dbg_eval(f"...nodes resolved to {src_node} and {dst_node}")
                self.disconnect(src_node, src_attr_name, dst_node, dst_attr_name)
                dbg_eval("...disconnection succeeded")
        except ValueError as error:
            raise OmniGraphError(
                f"Connect requires src_node, src_attr, dst_node, dst_attr - got {connection_list}"
            ) from error

    # ----------------------------------------------------------------------
    def _edit_set_values(self, node_path_map: Dict[str, og.Node], values_to_set: SetValueData_t):
        """Set a bunch of attribute values, using the format required by edit_graph"""
        value_descriptions = values_to_set if isinstance(values_to_set, list) else [values_to_set]
        # value_list data can either include a node or not. If the node is not included then
        # the list members must be og.Attributes, not just names.
        try:
            for value_list in value_descriptions:
                if not isinstance(value_list, tuple) and not isinstance(value_list, list):
                    raise ValueError
                if len(value_list) == 3:
                    node_name, attr_name, value = value_list
                    _ = DBG_EVAL and dbg_eval(f"Setting value '{value} on {node_name}.{attr_name}")
                    try:
                        node = node_path_map[node_name]
                    except KeyError:
                        node = node_name
                    self.set_values(node, [(attr_name, value)])
                else:
                    attr_name, value = value_list
                    if not isinstance(attr_name, og.Attribute):
                        raise OmniGraphError(f"Must set values with og.Attribute, got '{attr_name}'")
                    _ = DBG_EVAL and dbg_eval(f"Setting value '{value} on {attr_name}")
                    self.set_attribute_values([(attr_name, value)])
                _ = DBG_EVAL and dbg_eval("...setting value succeeded")
        except ValueError as ex:
            raise OmniGraphError(f"Setting value requires ({{node_name, }}attr_name, value) - got {value_list}") from ex

    # ----------------------------------------------------------------------
    def edit(self, graph_description: Dict[str, Any]) -> List[og.Node]:
        """Modify an OmniGraph relative to the top level of the scene ("/")
        Convenience function for accessing edit_graph without a root prim when it's not relevant
        """
        return self.edit_graph("/", graph_description)

    # ----------------------------------------------------------------------
    def edit_graph(self, root_prim: str, graph_description: Dict[str, Any]) -> List[og.Node]:
        """Create an OmniGraph node graph from a dictionary description of the contents.

        Here's a simple call that first deletes an existing node "oldnode", then creates two nodes of type
        "omni.graph.tutorials.SimpleData", connects their "a_int" attributes, disconnects their "a_float" attributes
        and sets the input "a_int" of the source node to the value 5. It also creates two unused USD Prim nodes, one
        with a float attribute named "attrFloat" with value 2.0, and the other with a boolean attribute named "attrBool"
        with the value true.

        .. code-block:: python

            helper = OmniGraphHelper()
            (src_node, dst_node) = helper.edit_graph("/", {
                "deletions" [
                    "oldnode"
                ],
                "nodes": [
                    ("omni.graph.tutorials.SimpleData", "src"),
                    ("omni.graph.tutorials.SimpleData", "dst")
                    ],
                "prims": [
                    ("Prim1", {"attrFloat": ("float", 2.0)),
                    ("Prim2", {"attrBool": ("bool", true)),
                    ],
                "connections": [
                    ("src", "outputs:a_int", "dst", "inputs:a_int")
                    ],
                "disconnections": [
                    ("src", "outputs:a_float", "dst", "inputs:a_float")
                    ],
                "values": [
                    ("src", "inputs:a_int", 5)
                    ]
                }
            )

        Args:
            root_prim: Top level prim for the graph nodes (e.g. "/")
            graph_description: Dictionary of graph construction definitions. A strict set of keys is accepted. Each
            of the values in the dictionary can be either a single value or a list of values of the proscribed type.
            - "deletions": [NODE_NAME]

                Deletes a node at the given path

            - "nodes": [(NODE_TYPE, NODE_NAME)]

              Constructs a node of type "NODE_TYPE" and name "NODE_NAME".

            - "prims": [(PRIM_PATH, {ATTR_NAME: (ATTR_TYPE, ATTR_VALUE)})]

              Constructs a prim with path "PRIM_PATH containing a set of attributes with specified types and values.

            - "connections": [(SRC_NODE_NAME, SRC_ATTR_NAME, DST_NODE_NAME, DST_ATTR_NAME)]

              Makes a connection between the given source and destination attributes

            - "disconnections": [(SRC_NODE_NAME, SRC_ATTR_NAME, DST_NODE_NAME, DST_ATTR_NAME)]

              Breaks a connection between the given source and destination attributes

            - "values": [(NODE_NAME, ATTR_NAME, VALUE)]

                Sets the value of the given list of attributes.

            Note that when constructing nodes the "NODE_NAME" may be in use, so a map is constructed when
            the nodes are created between the requested node name and the actual node created, which will
            contain the prim path reference.

            Operations happen in that order (all deletions, all nodes, all connections, all values) to minimize
            the possibility of errors. As a shortform any of the keys can accept a single tuple as well as a
            list of tuples.

        Returns:
            The list of og.Nodes created for all constructed nodes

        Raises:
            OmniGraphError if any of the graph creation instructions could not be fulfilled
            The graph will be left in the partially constructed state it reached at the time of the error
        """
        # The graph could be in an illegal state when halfway through editing operations. This will disable it
        # until all operations are completed. It's up to the caller to ensure that the state is legal after all
        # operations are completed.
        graph_was_disabled = self.graph.is_disabled()
        self.graph.set_disabled(True)

        try:
            # Syntax check first
            for instruction, data in graph_description.items():
                if instruction not in ["nodes", "connections", "disconnections", "values", "deletions", "prims"]:
                    raise OmniGraphError(f"Unknown graph construction operation - {instruction}: {data}")

            # Delete first because we may be creating nodes with the same names
            with suppress(KeyError):
                self._edit_delete_nodes(root_prim, graph_description["deletions"])

            # Create nodes next since connect and set may need them
            node_path_map = {}
            nodes_constructed = []
            with suppress(KeyError):
                (node_path_map, nodes_constructed) = self._edit_create_nodes(root_prim, graph_description["nodes"])

            # Prims next as they may be used in connections
            with suppress(KeyError):
                self._edit_create_prims(root_prim, node_path_map, nodes_constructed, graph_description["prims"])

            # Connections next as setting values may override their data
            with suppress(KeyError):
                self._edit_connect(node_path_map, graph_description["connections"])

            # Disconnections may have been created by the connections list so they're next, though that's unlikely
            with suppress(KeyError):
                self._edit_disconnect(node_path_map, graph_description["disconnections"])

            # Now set the values
            with suppress(KeyError):
                self._edit_set_values(node_path_map, graph_description["values"])
        finally:
            # Really important that this always gets reset
            self.graph.set_disabled(graph_was_disabled)

        return nodes_constructed
