#
# SPDX-FileCopyrightText: Copyright (c) 1993-2024 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#

import copy
from typing import List

import numpy as np
import onnx
import onnx_graphsurgeon as gs
import pytest
from onnx_graphsurgeon.ir.function import Function
from onnx_graphsurgeon.ir.graph import Graph
from onnx_graphsurgeon.ir.node import Node
from onnx_graphsurgeon.ir.tensor import Constant, LazyValues, Tensor, Variable
from onnx_graphsurgeon.logger import G_LOGGER
from onnx_graphsurgeon.util import misc
from onnx_graphsurgeon.util.exception import OnnxGraphSurgeonException
from onnx_graphsurgeon.util.misc import SynchronizedList
from onnx_models import const_foldable, shape_cast_elision

G_LOGGER.severity = G_LOGGER.ULTRA_VERBOSE


@Graph.register()
def shape(self, inp):
    return self.layer(op="Shape", inputs=[inp], outputs=["shape_out"])[0]


@Graph.register()
def cast(self, inp, to):
    return self.layer(op="Cast", inputs=[inp], outputs=["cast_out"], attrs={"to": to})[
        0
    ]


@Graph.register()
def constant(self, values):
    return self.layer(
        op="Constant",
        inputs=[],
        outputs=["constant_out"],
        attrs={"value": Constant("values", values)},
    )[0]


@Graph.register()
def identity(self, inp):
    out = self.layer(op="Identity", inputs=[inp], outputs=["identity_out"])[0]
    out.dtype = inp.dtype
    return out


@Graph.register()
def relu(self, inp):
    out = self.layer(op="Relu", inputs=[inp], outputs=["relu_out"])[0]
    out.dtype = inp.dtype
    return out


@Graph.register()
def add(self, a, b, name=None):
    outputs = [Variable(name=name)] if name else ["add_out"]
    out = self.layer(op="Add", inputs=[a, b], outputs=outputs)[0]
    out.dtype = a.dtype or b.dtype
    return out


@Graph.register()
def mul(self, a, b, name=None):
    outputs = [Variable(name=name)] if name else ["mul_out"]
    out = self.layer(op="Mul", inputs=[a, b], outputs=outputs)[0]
    out.dtype = a.dtype or b.dtype
    return out


@Graph.register()
def less(self, a, b, name=None):
    outputs = [Variable(name=name)] if name else ["less_out"]
    out = self.layer(op="Less", inputs=[a, b], outputs=outputs)[0]
    out.dtype = bool
    return out


# A fake op that can be used to ensure things work even when there is an invalid
# node present in the model.
@Graph.register()
def fake(self, inp, name=None):
    outputs = [Variable(name=name)] if name else ["fake_out"]
    out = self.layer(op="Fake", inputs=[inp], outputs=outputs)[0]
    out.dtype = inp.dtype
    return out


@gs.Graph.register()
def gather(self, data, indices):
    return self.layer(op="Gather", inputs=[data, indices], outputs=["gather_out"])[0]


@gs.Graph.register()
def slice(self, data, starts=None, ends=None, axes=None, steps=None):
    inputs = []
    for inp in [data, starts, ends, axes, steps]:
        if inp is None:
            break
        inputs.append(inp)

    return self.layer(op="Slice", inputs=inputs, outputs=["slice_out"])[0]


@gs.Graph.register()
def nested(self, inp, graph):
    return self.layer(
        op="Nested", inputs=[inp], outputs=["nested_out"], attrs={"body": graph}
    )[0]


@gs.Graph.register()
def if_op(self, cond, then_graph, else_graph):
    return self.layer(
        op="If",
        inputs=[cond],
        outputs=["if_out"],
        attrs={"then_branch": then_graph, "else_branch": else_graph},
    )[0]


@gs.Graph.register()
def tile(self, inp, repeats):
    out = self.layer(op="Tile", inputs=[inp, repeats], outputs=["tile_out"])[0]
    out.dtype = inp.dtype
    return out


@gs.Graph.register()
def dequantize_linear(self, inp, scale, zero_point, axis=1):
    out = self.layer(
        op="DequantizeLinear",
        inputs=[inp, scale, zero_point],
        outputs=["dequantize_linear_out"],
        attrs={"axis": axis},
    )[0]
    out.dtype = np.float32
    return out


@gs.Graph.register()
def quantize_linear(self, inp, out_scale, out_zero_point, axis=1):
    out = self.layer(
        op="QuantizeLinear",
        inputs=[inp, out_scale, out_zero_point],
        outputs=["quantize_linear_out"],
        attrs={"axis": axis},
    )[0]
    out.dtype = np.int8
    return out


@gs.Graph.register()
def pad(self, data, pads, constant_value=None):
    constant_value = misc.default_value(constant_value, Variable.empty())
    out = self.layer(
        op="Pad", inputs=[data, pads, constant_value], outputs=["pad_out"]
    )[0]
    out.dtype = data.dtype
    return out


@gs.Graph.register()
def softmax(self, data, axis=None):
    attrs = {}
    if axis is not None:
        attrs["axis"] = axis
    out = self.layer(op="Softmax", inputs=[data], outputs=["softmax_out"], attrs=attrs)[
        0
    ]
    out.dtype = data.dtype
    return out


# Generates a graph where an outer node has no outputs except
# within the subgraph. ONNX-GS should recognize that the node
# is being used, and should not remove it during cleanup().
def make_nested_graph():
    inp = Variable("input")
    id_out = Variable("id_out")
    identity = Node(op="Identity", inputs=[inp], outputs=[id_out])

    # Subgraph outputs come from the parent node, but nodes in the subgraph
    # can use nodes from the outer graphs too.
    subgraph_inputs = [Variable("subgraph_inp")]
    subgraph_id_out = Variable("subgraph_id_out")
    subgraph_outputs = [Variable("subgraph_out")]

    subgraph_identity0 = Node(op="Identity", inputs=[id_out], outputs=[subgraph_id_out])
    subgraph_identity1 = Node(
        op="Identity", inputs=[subgraph_id_out], outputs=subgraph_outputs
    )

    subgraph = Graph(
        nodes=[subgraph_identity0, subgraph_identity1],
        inputs=subgraph_inputs,
        outputs=subgraph_outputs,
    )

    nested_out = Variable("nested_out")
    nested_node = Node(
        op="Nested", attrs={"body": subgraph}, inputs=[inp], outputs=[nested_out]
    )

    return Graph(nodes=[identity, nested_node], inputs=[inp], outputs=[nested_out])


@pytest.fixture
def nested_graph():
    yield make_nested_graph()


@pytest.fixture
def very_nested_graph():
    inner_subgraph_1 = Graph(name="inner_subgraph_1")
    inner_subgraph_2 = Graph(name="inner_subgraph_2")
    inner_subgraph_3 = Graph(name="inner_subgraph_3")
    outer_subgraph_1 = Graph(name="subgraph1")
    outer_subgraph_2 = Graph(
        name="subgraph2",
        nodes=[Node("Add", attrs={"x": inner_subgraph_1, "y": inner_subgraph_2})],
    )
    outer_subgraph_3 = Graph(
        name="subgraph3", nodes=[Node("Add", attrs={"x": inner_subgraph_3, "y": 3.14})]
    )
    node_1 = Node(
        op="Add",
        attrs={
            "x": outer_subgraph_1,
            "y": outer_subgraph_2,
            "z": 5,
            "w": outer_subgraph_3,
        },
    )
    node_2 = Node(op="Add", attrs={"x": outer_subgraph_3})
    return Graph(nodes=[node_1, node_2], name="main_graph")


class TestBasic(object):
    def test_generate_name(self):
        graph = Graph()
        generated_names = set()
        existing_names = {
            "name_{}".format(i) for i in range(50, 150)
        }  # names_50 to names_149
        num_names = 100
        for idx in range(num_names):
            generated_names.add(graph._generate_name("name", existing_names))
        assert len(generated_names) == num_names  # 100 unique generated_names
        assert (
            len(generated_names.intersection(existing_names)) == 0
        )  # no generated_names in existing_names
        expected_names = {"name_{}".format(i) for i in range(0, 50)}
        expected_names.update({"name_{}".format(i) for i in range(150, 200)})
        assert (
            generated_names == expected_names
        )  # expect 'names_0' to 'names_49', 'names_150' to 'names_199'

    def test_equal(self, nested_graph):
        assert nested_graph == nested_graph

    def test_equal_inputs_unequal(self):
        g0 = make_nested_graph()
        g1 = make_nested_graph()

        g0.inputs.append(Variable("test"))

        assert not (g0 == g1)

    def test_equal_outputs_unequal(self):
        g0 = make_nested_graph()
        g1 = make_nested_graph()

        g0.outputs.append(Variable("test"))

        assert not (g0 == g1)

    def test_equal_nested_unequal(self):
        g0 = make_nested_graph()
        g1 = make_nested_graph()

        # Changing the nested subgraph should make the graphs unequal
        g0.nodes[1].inputs[0].name = "subgraph_inp_modified"

        assert not (g0 == g1)

    def test_subgraphs_not_recursive(self, very_nested_graph):
        unrelated_graph = Graph(name="unrelated")
        subgraph_names = {subgraph.name for subgraph in very_nested_graph.subgraphs()}
        assert subgraph_names == {"subgraph1", "subgraph2", "subgraph3"}

    def test_subgraphs_recursive(self, very_nested_graph):
        unrelated_graph = Graph(name="unrelated")
        subgraph_names = {
            subgraph.name for subgraph in very_nested_graph.subgraphs(recursive=True)
        }
        assert subgraph_names == {
            "subgraph1",
            "subgraph2",
            "subgraph3",
            "inner_subgraph_1",
            "inner_subgraph_2",
            "inner_subgraph_3",
        }


class TestRegister(object):
    def test_register(self):
        @Graph.register()
        def fake_add(self, a, b):
            return self.layer(op="Add", inputs=[a, b], outputs=["add_out"])

        graph = Graph()
        [output] = graph.fake_add("a", "b")
        assert "add_out" in output.name
        assert len(graph.nodes) == 1
        assert graph.nodes[-1].op == "Add"

    def test_register_opset(self):
        @Graph.register(opsets=[11])
        def fake_add(self, a, b):
            return self.layer(op="Add", inputs=[a, b], outputs=["add_out"])

        @Graph.register(opsets=[10])
        def fake_add(self, a, b):
            return self.layer(op="Add-10", inputs=[a, b], outputs=["add_out"])

        graph = Graph()
        [output] = graph.fake_add("a", "b")
        assert "add_out" in output.name
        assert len(graph.nodes) == 1
        assert graph.nodes[-1].op == "Add"

        graph_opset10 = Graph(opset=10)
        [output] = graph_opset10.fake_add("a", "b")
        assert "add_out" in output.name
        assert len(graph_opset10.nodes) == 1
        assert graph_opset10.nodes[-1].op == "Add-10"

    def test_register_name_conflict(self):
        @Graph.register()
        def fake_mul(self, a, b):
            return self.layer(
                op="Add", domain="domain1", inputs=[a, b], outputs=["mul_out"]
            )

        func = Function("fake_mul", domain="domain2")
        graph = Graph(functions=[func])
        graph.fake_mul("a", "b")
        assert len(graph.nodes) == 1
        assert graph.nodes[0].domain == "domain1"


class TestLayer(object):
    def test_layer_default_naming(self):
        node1 = Node(
            name="onnx_graphsurgeon_node_0", op="Identity"
        )  # injecting default name
        node2 = Node(
            name="onnx_graphsurgeon_node_1", op="Identity"
        )  # injecting default name again
        graph = Graph(nodes=[node1, node2])
        graph.layer(
            op="Identity"
        )  # new default name should be onnx_graphsurgeon_node_2
        assert graph.nodes[-1].name == "onnx_graphsurgeon_node_2"
        graph.layer(
            op="Identity"
        )  # new default name should be onnx_graphsurgeon_node_3
        assert graph.nodes[-1].name == "onnx_graphsurgeon_node_3"

    def test_layer_with_attrs(self):
        graph = Graph()
        outputs = graph.layer(op="Add", name="node", attrs={"fake_attr": 0})
        assert len(graph.nodes) == 1
        assert graph.nodes[-1].op == "Add"
        assert graph.nodes[-1].name == "node"
        assert graph.nodes[-1].attrs["fake_attr"] == 0

    def test_layer_with_tensors(self):
        x0 = Variable("x0")
        x1 = Variable("x1")
        y0 = Variable("y0")
        y1 = Variable("y1")
        graph = Graph()

        outputs = graph.layer(op="Fake", inputs=[x0, x1], outputs=[y0, y1])
        assert outputs == [y0, y1]
        assert len(graph.nodes) == 1
        assert graph.nodes[-1].inputs == [x0, x1]
        assert graph.nodes[-1].outputs == outputs

    def test_layer_with_strings(self):
        x0 = "x0"
        x1 = "x1"
        y0 = "y0"
        y1 = "y1"
        graph = Graph()

        outputs = graph.layer(op="Fake", inputs=[x0, x1], outputs=[y0, y1])
        assert len(graph.nodes) == 1
        assert [
            prefix in tensor.name
            for prefix, tensor in zip([x0, x1], graph.nodes[-1].inputs)
        ]
        assert [
            prefix in tensor.name
            for prefix, tensor in zip([y0, y1], graph.nodes[-1].outputs)
        ]
        assert graph.nodes[-1].outputs == outputs

    def test_layer_with_arrays(self):
        x0 = np.array([1])
        x1 = np.array([1])
        y0 = "y0"
        y1 = "y1"
        graph = Graph()

        outputs = graph.layer(op="Fake", inputs=[x0, x1], outputs=[y0, y1])
        assert [
            prefix in tensor.name
            for prefix, tensor in zip([y0, y1], graph.nodes[-1].outputs)
        ]
        assert len(graph.nodes) == 1
        assert graph.nodes[-1].inputs[0].values == x0
        assert graph.nodes[-1].inputs[1].values == x1
        assert graph.nodes[-1].outputs == outputs

    def test_layer_with_iterables(self):
        x0 = [1]
        x1 = (1,)
        y0 = "y0"
        y1 = "y1"
        graph = Graph()

        outputs = graph.layer(op="Fake", inputs=[x0, x1], outputs=[y0, y1])
        assert [
            prefix in tensor.name
            for prefix, tensor in zip([y0, y1], graph.nodes[-1].outputs)
        ]
        assert len(graph.nodes) == 1
        assert graph.nodes[-1].inputs[0].values == x0
        assert graph.nodes[-1].inputs[1].values == x1
        assert graph.nodes[-1].outputs == outputs


class TestFunctionCall(object):
    def make_graph_and_func(self):
        func_output = Variable("test_output", shape=[1, 2, 3], dtype=np.int32)
        func = Function(
            "TestFunction", inputs=[Variable("test_input")], outputs=[func_output]
        )
        graph = Graph(functions=[func])
        return graph, func_output

    def check_outputs_match(self, func_output, node_outputs):
        assert len(node_outputs) == 1
        output = node_outputs[0]

        assert output is not func_output
        assert output.name
        assert output.shape == func_output.shape
        assert output.dtype == func_output.dtype

    def test_function_default_outputs(self):
        graph, func_output = self.make_graph_and_func()

        # No outputs given, but they should be created.
        outputs = graph.TestFunction(inputs=["input"])
        self.check_outputs_match(func_output, outputs)

    def test_function_default_outputs_string_names(self):
        graph, func_output = self.make_graph_and_func()

        # Output name given, it should be preserved.
        outputs = graph.TestFunction(inputs=["input"], outputs=["output"])
        self.check_outputs_match(func_output, outputs)
        assert outputs[0].name.startswith("output")

    def test_function_default_outputs_existing_tensor(self):
        graph, _ = self.make_graph_and_func()

        # Output tensor provided, it should not be changed.
        existing_tensor = Variable("output", shape=[2, 3, 4], dtype=np.float32)
        outputs = graph.TestFunction(inputs=["input"], outputs=[existing_tensor])
        assert outputs[0] is existing_tensor


def tensors_linear_graph():
    inputs = [Variable(name="x")]
    intermediate0 = Variable(name="intermediate0")
    intermediate1 = Variable(name="intermediate1")
    intermediate2 = Variable(name="intermediate2")
    outputs = [Variable(name="y")]

    tensors = inputs + [intermediate0, intermediate1, intermediate2] + outputs
    tensors = {tensor.name: tensor for tensor in tensors}
    # Nodes are NOT in topo order.
    nodes = [
        Node(op="Add", name="Test0", inputs=inputs, outputs=[intermediate0]),
        Node(op="Add", name="Test1", inputs=[intermediate0], outputs=[intermediate1]),
        Node(op="Add", name="Test2", inputs=[intermediate1], outputs=[intermediate2]),
        Node(op="Add", name="Test3", inputs=[intermediate2], outputs=outputs),
    ]
    return Graph(nodes=nodes, inputs=inputs, outputs=outputs), nodes, tensors


class TestTensors(object):
    # Calling `tensors()` should not modify tensors in the graph.
    def test_tensors_does_not_modify_tensors(self):
        graph, _, _ = tensors_linear_graph()
        graph_tensors = graph.tensors()
        # Generate a new graph to compare against
        _, _, tensors = tensors_linear_graph()

        assert set(tensors.keys()) == set(graph_tensors.keys())

        for name, tensor in tensors.items():
            graph_tensor = graph_tensors[name]
            assert tensor == graph_tensor
            assert tensor.inputs == graph_tensor.inputs
            assert tensor.outputs == graph_tensor.outputs

    # Check that tensors includes tensors not attached to nodes
    def test_tensors_includes_non_node_tensors(self):
        X = Constant("X", values=np.ones(shape=(64, 64), dtype=np.float32))
        graph = Graph(inputs=[], outputs=[X])
        tensor_map = graph.tensors()
        assert "X" in tensor_map
        assert tensor_map["X"] == X

    def test_tensors_check_duplicates(self):
        inputs = [Variable(name="x")]
        outputs = [Variable(name="x")]  # Distinct tensors with the same name
        nodes = [
            Node(op="Add", name="Test", inputs=inputs, outputs=outputs),
        ]
        graph = Graph(nodes=nodes, inputs=inputs, outputs=outputs)

        with pytest.raises(OnnxGraphSurgeonException):
            graph.tensors(check_duplicates=True)

    def test_tensors_with_duplicates_check_disabled(self):
        inputs = [Variable(name="x")]
        outputs = [Variable(name="x")]  # Distinct tensors with the same name
        nodes = [
            Node(op="Add", name="Test", inputs=inputs, outputs=outputs),
        ]
        graph = Graph(nodes=nodes, inputs=inputs, outputs=outputs)

        # This should *not* throw
        graph.tensors(check_duplicates=False)


def toposort_linear_graph():
    inputs = [Variable(name="x")]
    intermediate0 = Variable(name="intermediate0")
    intermediate1 = Variable(name="intermediate1")
    intermediate2 = Variable(name="intermediate2")
    outputs = [Variable(name="y")]
    # Nodes are NOT in topo order.
    nodes = [
        Node(op="Add", name="Test0", inputs=inputs, outputs=[intermediate0]),
        Node(op="Add", name="Test2", inputs=[intermediate1], outputs=[intermediate2]),
        Node(op="Add", name="Test3", inputs=[intermediate2], outputs=outputs),
        Node(op="Add", name="Test1", inputs=[intermediate0], outputs=[intermediate1]),
    ]
    expected_node_order = [nodes[0], nodes[3], nodes[1], nodes[2]]
    return Graph(nodes=nodes, inputs=inputs, outputs=outputs), expected_node_order


# Graph structure:
#  x
#  |
#  Test0
#  |
# intermediate0
#   |           \
# Test1         Test2
#   |              |
#  intermediate1   intermediate2
#              \    /
#              Test3
#                |
#                y
def toposort_diamond_graph():
    inputs = [Variable(name="x")]
    intermediate0 = Variable(name="intermediate0")
    intermediate1 = Variable(name="intermediate1")
    intermediate2 = Variable(name="intermediate2")
    outputs = [Variable(name="y")]
    # Nodes are NOT in topo order:
    nodes = [
        Node(op="Add", name="Test0", inputs=inputs, outputs=[intermediate0]),
        # If the end of the diamond shape occurs before the branches, it can trigger a path
        # where a false cycle is detected - this test guards against that.
        Node(
            op="Add",
            name="Test3",
            inputs=[intermediate1, intermediate2],
            outputs=outputs,
        ),
        Node(op="Add", name="Test1", inputs=[intermediate0], outputs=[intermediate1]),
        Node(op="Add", name="Test2", inputs=[intermediate0], outputs=[intermediate2]),
    ]
    expected_node_order = [nodes[0], nodes[2], nodes[3], nodes[1]]
    return Graph(nodes=nodes, inputs=inputs, outputs=outputs), expected_node_order


# Graph structure:
# x
# |
# Test0 -> out0 (graph output)
# |
# out0
# |
# Test1 -> out1 (graph output)
# |
# out1
# |
# Test2 -> out2 (graph_output)
def toposort_multi_tier_output_graph():
    inputs = [Variable(name="x")]
    outputs = [Variable(name="out0"), Variable(name="out1"), Variable(name="out2")]
    out0, out1, out2 = outputs
    nodes = [
        Node(op="Add", name="Test2", inputs=[out1], outputs=[out2]),
        Node(op="Add", name="Test0", inputs=inputs, outputs=[out0]),
        Node(op="Add", name="Test1", inputs=[out0], outputs=[out1]),
    ]
    expected_node_order = [nodes[1], nodes[2], nodes[0]]
    return Graph(nodes=nodes, inputs=inputs, outputs=outputs), expected_node_order


# Graph structure:
# x2  x1
# |   |
# Test0
# |
# int0  x0
# |    /
# Test1
# |
# int1  x3
# |    /
# Test2 -> out (graph_output)
def toposort_multi_tier_input_graph():
    inputs = [
        Variable(name="x0"),
        Variable(name="x1"),
        Variable(name="x2"),
        Variable(name="x3"),
    ]
    int0, int1 = [Variable(name="intermediate0"), Variable(name="intermediate1")]
    outputs = [Variable(name="out")]
    x0, x1, x2, x3 = inputs
    nodes = [
        Node(op="Add", name="Test2", inputs=[int1, x3], outputs=outputs),
        Node(op="Add", name="Test0", inputs=[x2, x1], outputs=[int0]),
        Node(op="Add", name="Test1", inputs=[int0, x0], outputs=[int1]),
    ]
    expected_node_order = [nodes[1], nodes[2], nodes[0]]
    return Graph(nodes=nodes, inputs=inputs, outputs=outputs), expected_node_order


# Graph structure:
# x0
# |
# Add
# |
# x1
# |
# Relu
# |
# x2
#
# If:
#   Then:
#          x1  x2
#           |  |
#           Add
#            |
#           res
#   Else:
#          x1  x2
#           |  |
#           Add
#            |
#           res
#  |
# out
#
# In this graph, the subgraph of If implicitly depends on x1/x2 from the outer graph, so the parent If
# node must come after the outer Add/ReLU nodes.
# If we fail to consider such implicit inputs, the If will remain the first node.
def toposort_implicit_subgraph_inputs_graph():
    def make_var(name):
        return Variable(name, shape=(1, 1), dtype=np.float32)

    # Main graph
    inputs = [make_var("x0")]
    const = Constant(name="const", values=np.array([[1.5]], dtype=np.float32))
    cond = Constant(name="cond", values=np.array([True]))
    x1, x2 = [make_var("x1"), make_var("x2")]
    outputs = [make_var("out")]

    # Subgraphs for If
    subgraph_outputs = [make_var("res")]
    subgraph_nodes = [
        Node(op="Add", name="SubgraphTest0", inputs=[x1, x2], outputs=subgraph_outputs)
    ]
    subgraph = Graph(nodes=subgraph_nodes, outputs=subgraph_outputs)

    nodes = [
        Node(
            op="If",
            name="Test2",
            inputs=[cond],
            outputs=outputs,
            attrs={"then_branch": subgraph, "else_branch": subgraph},
        ),
        Node(op="Relu", name="Test1", inputs=[x1], outputs=[x2]),
        Node(op="Add", name="Test0", inputs=inputs + [const], outputs=[x1]),
    ]
    expected_node_order = [nodes[2], nodes[1], nodes[0]]
    return Graph(nodes=nodes, inputs=inputs, outputs=outputs), expected_node_order


TOPOSORT_TEST_CASES = [
    toposort_linear_graph,
    toposort_diamond_graph,
    toposort_multi_tier_output_graph,
    toposort_multi_tier_input_graph,
    toposort_implicit_subgraph_inputs_graph,
]


class TestToposort(object):
    @staticmethod
    def make_single_node_function(name: str, dependencies: List[str]) -> Function:
        """
        Create a function which uses all the functions given in 'dependencies'.
        """
        func = Function(name, inputs=[Variable("input")], outputs=[Variable("output")])
        if not dependencies:
            return func
        intermediate = func.inputs[0]
        for i, dep in enumerate(dependencies):
            new_intermediate = Variable(f"inter_{i}")
            func.nodes.append(
                Node(
                    dep,
                    domain=Function.DEFAULT_DOMAIN,
                    inputs=[intermediate],
                    outputs=[new_intermediate],
                )
            )
            intermediate = new_intermediate
        func.outputs = [intermediate]
        return func

    @pytest.mark.parametrize("toposort_test_case", TOPOSORT_TEST_CASES)
    def test_topologically_sort(self, toposort_test_case):
        graph, expected_node_order = toposort_test_case()
        assert graph.nodes != expected_node_order
        graph.toposort()
        assert graph.nodes == expected_node_order

    @pytest.mark.parametrize("toposort_test_case", TOPOSORT_TEST_CASES)
    def test_toposort_nested(self, toposort_test_case):
        subgraph, expected_node_order = toposort_test_case()
        assert subgraph.nodes != expected_node_order

        # Wrap the graph within a subgraph
        inp = Variable("input")
        id_out = Variable("id_out")
        identity = Node(op="Identity", inputs=[inp], outputs=[id_out])

        # Make the subgraph take an input from the outer graph node
        # If toposort tries to take the node id, it'll fault.
        subgraph.nodes[0].inputs.append(id_out)

        out = Variable("output")
        nested = Node(
            op="Nested", inputs=[id_out], outputs=[out], attrs={"subgraph": subgraph}
        )

        graph = Graph(nodes=[identity, nested], inputs=[inp], outputs=[out])
        graph.toposort(recurse_subgraphs=True)

        assert subgraph.nodes == expected_node_order

    def test_function(self):
        graph, expected_node_order = toposort_multi_tier_input_graph()
        func = Function(
            "Test", nodes=graph.nodes, inputs=graph.inputs, outputs=graph.outputs
        )
        func.toposort()
        assert func.nodes == expected_node_order

    def test_function_order(self):
        # Check that toposort re-orders functions in topological order.
        func1 = self.make_single_node_function("func1", [])
        func2 = self.make_single_node_function("func2", ["func1"])
        func3 = self.make_single_node_function("func3", ["func2"])
        func4 = self.make_single_node_function("func4", ["func3", "func2"])
        funcs = [func3, func2, func4, func1]
        graph = Graph(functions=funcs)
        graph.toposort()
        assert graph.functions == [func1, func2, func3, func4]

    def test_function_circular_dep_simple(self):
        func = self.make_single_node_function("func", ["func"])
        graph = Graph(functions=[func])
        try:
            graph.toposort()
            assert False, "Should have raised"
        except OnnxGraphSurgeonException:
            pass

    def test_function_circular_dep_complicated(self):
        # Circular dependency [func2 -> func3 -> func4 -> func2]
        func1 = self.make_single_node_function("func1", ["func2", "func5"])
        func2 = self.make_single_node_function("func2", ["func3"])
        func3 = self.make_single_node_function("func3", ["func4", "func5"])
        func4 = self.make_single_node_function("func4", ["func2", "func5"])
        func5 = self.make_single_node_function("func5", [])
        funcs = [func3, func2, func4, func1, func5]
        graph = Graph(functions=funcs)
        try:
            graph.toposort()
            assert False, "Should have raised"
        except OnnxGraphSurgeonException:
            pass


def build_basic_graph():
    inputs = [Variable(name="x")]
    outputs = [Variable(name="y")]
    nodes = [
        Node(op="Add", name="Test", inputs=inputs, outputs=outputs),
    ]
    return Graph(nodes=nodes, inputs=inputs, outputs=outputs, ir_version=10)


def build_two_layer_graph():
    inputs = [Variable(name="x")]
    intermediate_tensor = Variable(name="intermediate")
    outputs = [Variable(name="y")]
    nodes = [
        Node(op="Add", name="Test0", inputs=inputs, outputs=[intermediate_tensor]),
        Node(op="Add", name="Test1", inputs=[intermediate_tensor], outputs=outputs),
    ]
    return Graph(nodes=nodes, inputs=inputs, outputs=outputs, ir_version=10)


def build_two_layer_graph_multiple_io():
    inputs = [Variable(name="x0"), Variable(name="x1")]
    intermediate_tensor = Variable(name="intermediate")
    outputs = [Variable(name="y0"), Variable(name="y1")]
    nodes = [
        Node(op="Add", name="Test0", inputs=inputs, outputs=[intermediate_tensor]),
        Node(op="Add", name="Test1", inputs=[intermediate_tensor], outputs=outputs),
    ]
    return Graph(nodes=nodes, inputs=inputs, outputs=outputs, ir_version=10)


def build_function_with_unused_node():
    func = Function("Test")

    A = Variable("A", dtype=np.float32, shape=(1, 1))
    B = Variable("B", dtype=np.float32, shape=(1, 1))
    X = Variable("X", dtype=np.float32, shape=(1, 1))
    Y = Variable("Y", dtype=np.float32, shape=(1, 1))

    func.inputs = [A, X]
    func.outputs = [B]
    func.nodes = [
        Node(op="Sin", inputs=[X], outputs=[Y]),  # this node is unused
        Node(op="Cos", inputs=[A], outputs=[B]),
    ]
    return func


CLEANUP_TEST_CASES = [
    build_basic_graph(),
    build_two_layer_graph(),
    build_two_layer_graph_multiple_io(),
]


class TestCleanup(object):
    @pytest.mark.parametrize("graph", CLEANUP_TEST_CASES)
    def test_get_used_node_ids(self, graph):
        graph_used_nodes = copy.copy(graph.nodes)
        graph_used_tensors = copy.copy(list(graph.tensors().values()))

        unused_tensor = Variable(name="Unused")
        unused_node = Node(
            op="Unused", inputs=[graph.inputs[0]], outputs=[unused_tensor]
        )
        graph.nodes.append(unused_node)

        with graph.node_ids():
            used_node_ids, used_tensors = graph._get_used_node_ids()
            assert len(used_node_ids) == len(graph.nodes) - 1
            assert all([node.id in used_node_ids for node in graph_used_nodes])
            assert unused_node.id not in used_node_ids
            assert unused_tensor not in used_tensors
            assert all(
                [used_tensor in used_tensors for used_tensor in graph_used_tensors]
            )

    def test_multi_tier(self):
        graph, _ = toposort_multi_tier_output_graph()
        tensor = graph.outputs.pop()
        unused_node = tensor.inputs[0]
        graph.cleanup()  # Should remove just the Test2 node as out1 is still an output.
        assert unused_node not in graph.nodes
        assert len(graph.nodes) == 2
        assert len(graph.outputs) == 2

        tensor_map = graph.tensors()
        assert tensor.name not in tensor_map

    def test_remove_unused_node_outputs(self):
        graph, _ = toposort_linear_graph()
        graph.toposort()
        graph_output = graph.outputs[0]

        dummy = Variable("dummy")
        # Add unused tensor to a node in the middle of the graph.
        # Since it does not contribute to graph outputs, it should be removed.
        graph.nodes[1].outputs.append(dummy)

        graph.cleanup(remove_unused_node_outputs=True)
        assert dummy not in graph.nodes[1].outputs
        assert graph.outputs[0] == graph_output  # Graoh outputs will never be removed

    def test_graph_input_producers(self):
        graph, _ = toposort_linear_graph()
        tensor_map = graph.tensors()
        assert "x" in tensor_map

        graph.inputs = [tensor_map["intermediate0"]]

        graph.cleanup()
        cleaned_tensor_map = graph.tensors()
        assert "x" not in cleaned_tensor_map

    @pytest.mark.parametrize("remove_unused_graph_inputs", [True, False])
    def test_independent_path(self, remove_unused_graph_inputs):
        graph, _ = toposort_linear_graph()
        # Build out a path totally unrelated to rest of the graph
        indep0 = Variable(name="indep0")
        indep1 = Variable(name="indep1")
        node = Node(op="IndepTest", inputs=[indep0], outputs=[indep1])
        graph.nodes.append(node)
        graph.inputs.append(indep0)
        graph.cleanup(remove_unused_graph_inputs=remove_unused_graph_inputs)
        assert indep0 not in graph.inputs or not remove_unused_graph_inputs
        assert node not in graph.nodes or not remove_unused_graph_inputs

        tensor_map = graph.tensors()
        assert indep0.name not in tensor_map or not remove_unused_graph_inputs
        assert indep1.name not in tensor_map or not remove_unused_graph_inputs

    def test_nested_graph(self, nested_graph):
        nested_node = nested_graph.nodes[1]
        nested_inp = nested_node.inputs[0]
        nested_out = nested_node.outputs[0]
        subgraph = nested_node.attrs["body"]

        assert "id_out" in nested_graph.tensors()
        nested_graph.cleanup(recurse_subgraphs=True)
        # Clean up should not remove a tensor whose only output node is a subgraph.
        assert "id_out" in nested_graph.tensors()

        # Clean up should not modify the nested nodes inputs or outputs
        assert nested_node.inputs == [nested_inp]
        assert nested_node.outputs == [nested_out]

        # Next we'll clean up the subgraph by recursing from the top-level
        assert subgraph.nodes
        subgraph.outputs.clear()
        nested_graph.cleanup(recurse_subgraphs=True)
        assert not subgraph.nodes

    def test_node_used_only_in_nested_graph(self):
        X = Variable("X", dtype=np.float32, shape=(1,))
        Y = Variable("Y", dtype=np.float32, shape=(1,))
        graph = Graph(inputs=[X, Y])

        X_p = graph.identity(
            X
        )  # X_p is only used by the subgraph, not in the outer graph.

        subgraph_inp = Variable("subgraph_input", dtype=np.float32, shape=(1,))
        subgraph = Graph(inputs=[subgraph_inp])
        subgraph.outputs = [subgraph.add(subgraph_inp, X_p)]

        graph.outputs = [graph.nested(Y, subgraph)]

        graph.cleanup(remove_unused_graph_inputs=True)

        assert graph.nodes[0].op == "Identity"
        assert graph.nodes[0].inputs == [X]

    def test_input_is_output(self):
        graph = Graph()

        A = Variable("A", dtype=np.float32, shape=(1, 1))
        B = Variable("B", dtype=np.float32, shape=(1, 1))

        C = graph.add(A, B)

        graph.inputs = [A, B]
        graph.outputs = [C, B, A]  # Out of order w/ respect to Add node inputs

        # Graph should remain unchanged after cleanup, including I/O tensors.
        graph.cleanup()

        assert graph.inputs == [A, B]
        assert graph.outputs == [C, B, A]
        assert len(graph.nodes) == 1
        assert graph.nodes[0].inputs == [A, B]
        assert graph.nodes[0].outputs == [C]

    def test_function(self):
        func = build_function_with_unused_node()

        func.cleanup()
        assert {i.name for i in func.inputs} == {"A", "X"}
        assert {o.name for o in func.outputs} == {"B"}
        assert len(func.nodes) == 1

    def test_graph_cleans_up_function(self):
        graph = Graph()
        func = build_function_with_unused_node()
        graph.functions.append(func)

        # Cleaning up the graph should by default also cleanup the function.
        graph.cleanup()
        assert {i.name for i in func.inputs} == {"A", "X"}
        assert {o.name for o in func.outputs} == {"B"}
        assert len(func.nodes) == 1


class TestCopy(object):
    def test_basic(self):
        graph = Graph(
            nodes=[Node(op="Test")],
            inputs=[Variable("test")],
            outputs=[Variable("test")],
            name="test-name",
            doc_string="test-docstring",
            import_domains=["fake-import-domain"],
            opset=-1,
        )
        new_graph = graph.copy()

        assert new_graph == graph
        assert new_graph.nodes == graph.nodes
        assert new_graph.inputs == graph.inputs
        assert new_graph.outputs == graph.outputs
        assert new_graph.name == graph.name
        assert new_graph.doc_string == graph.doc_string
        assert new_graph.import_domains == graph.import_domains
        assert new_graph.opset == graph.opset

    def test_copy(self):
        def make_graph():
            graph, _ = toposort_multi_tier_output_graph()
            graph.outputs.pop()
            # Deep copy should work with empty tensors
            graph.nodes[0].inputs.append(Variable.empty())
            graph.nodes[0].outputs.append(Variable.empty())
            return graph

        graph = make_graph()
        new_graph = graph.copy()
        assert graph == new_graph

        # Running cleanup on the first graph should not affect the copy
        graph.cleanup()
        assert graph != new_graph
        assert new_graph == make_graph()

    def test_copy_with_subgraph(self, nested_graph):
        new_graph = nested_graph.copy()
        assert new_graph == nested_graph

        new_subgraph = new_graph.nodes[1].attrs["body"]

        id_out = new_subgraph.nodes[0].inputs[0]
        assert id_out.name == "id_out"
        assert len(id_out.inputs) == 1
        assert id_out.inputs[0].op == "Identity"
        assert id_out.inputs[0].inputs[0].name == "input"

        new_subgraph.nodes[0].outputs.clear()
        new_subgraph.nodes[1].inputs.clear()

        subgraph = nested_graph.nodes[1].attrs["body"]
        assert subgraph.nodes[0].outputs
        assert subgraph.nodes[1].inputs

        new_graph.outputs.clear()
        new_graph.cleanup()

        assert nested_graph.outputs
        assert len(nested_graph.nodes) == 2
        assert len(subgraph.nodes) == 2

    # If the subgraph has a tensor with the same name as the outer graph,
    # the subgraph copy should include a copy of the subgraph tensor, not the outer
    # graph tensor.
    def test_copy_with_subgraph_dup_tensors(self):
        inp = Variable("input", dtype=np.float32, shape=(4, 5))
        graph = Graph(inputs=[inp])

        # We'll use shape to distinguish inner/outer tensor
        subgraph_inp = Variable("input", dtype=np.float32, shape=(1, 2))
        subgraph = Graph(inputs=[subgraph_inp])

        graph.outputs = [graph.nested(inp, subgraph)]

        graph_copy = graph.copy()
        assert graph_copy.nodes[0].attrs["body"].inputs[0].shape == (1, 2)

    def test_copy_with_subgraph_dup_const_tensors(self):
        inp = Constant("input", values=np.ones(dtype=np.float32, shape=(4, 5)))
        graph = Graph()

        # We'll use shape to distinguish inner/outer tensor
        subgraph_inp = Constant("input", values=np.ones(dtype=np.float32, shape=(1, 2)))
        subgraph = Graph()
        subgraph.outputs = [subgraph.identity(subgraph_inp)]

        graph.outputs = [graph.nested(inp, subgraph)]

        graph_copy = graph.copy()
        assert graph_copy.nodes[0].attrs["body"].nodes[0].inputs[0].shape == (1, 2)

    def test_function(self):
        func = Function(
            "Test",
            domain="onnx-gs.test",
            nodes=[Node(op="Add")],
            inputs=[Variable("input")],
            outputs=[Variable("output")],
            doc_string="docstring",
            opset=15,
            import_domains=["test"],
            attrs={"attr1": None, "attr2": np.array([1, 2, 3])},
        )
        func_copy = func.copy()
        assert func.name == func_copy.name
        assert func.domain == func_copy.domain
        assert func.nodes == func_copy.nodes
        assert func.inputs == func_copy.inputs
        assert func.outputs == func_copy.outputs
        assert func.doc_string == func_copy.doc_string
        assert func.opset == func_copy.opset
        assert func.import_domains == func_copy.import_domains
        assert func.attrs["attr1"] == func_copy.attrs["attr1"]
        assert np.all(func.attrs["attr2"] == func_copy.attrs["attr2"])

        assert func.nodes is not func_copy.nodes
        assert func.inputs is not func_copy.inputs
        assert func.outputs is not func_copy.outputs
        assert func.attrs is not func_copy.attrs

        assert func.nodes[0] is not func_copy.nodes[0]
        assert func.inputs[0] is not func_copy.inputs[0]
        assert func.outputs[0] is not func_copy.outputs[0]
        assert func.attrs["attr2"] is not func_copy.attrs["attr2"]


@pytest.fixture
def simple_foldable():
    # Graph:
    # c = (a + b)
    # output = input + c
    # Should fold to:
    # output = input + c
    weights = np.ones(shape=(1, 3), dtype=np.float32)

    graph = Graph(ir_version=10)
    inp = Variable("input", shape=(1, 3), dtype=np.float32)
    c = graph.add(weights, weights, name="c")
    out = graph.add(inp, c, name="out")

    graph.inputs = [inp]
    graph.outputs = [out]
    yield graph


@pytest.fixture
def one_hop_foldable():
    # Graph:
    # c = (a + b)
    # e = (c + d)
    # output = input + e
    # Should fold to:
    # output = input + e
    weights = np.ones(shape=(1, 3), dtype=np.float32)

    graph = Graph(ir_version=10)
    inp = Variable("input", shape=(1, 3), dtype=np.float32)
    c = graph.add(weights, weights, name="c")
    e = graph.add(c, weights, name="e")
    out = graph.add(inp, e)

    graph.inputs = [inp]
    graph.outputs = [out]
    yield graph


@pytest.fixture
def foldable_with_invalid_node():
    # Graph
    # c = (a + b)
    # e = fake(d)
    # f = (e + c)
    # out = inp + f
    #
    # c should be folded even though e is the output of an
    # invalid node.
    weights = np.ones(shape=(1, 3), dtype=np.float32)

    graph = Graph(ir_version=10)
    inp = Variable("input", shape=(1, 3), dtype=np.float32)
    c = graph.add(weights, weights, name="c")
    e = graph.fake(weights, name="e")
    f = graph.add(e, c, name="f")
    out = graph.add(inp, f, name="output")

    graph.inputs = [inp]
    graph.outputs = [out]
    yield graph


@pytest.fixture
def foldable_with_local_functions():
    dtype = np.float32
    counter = 0

    def const():
        nonlocal counter
        counter += 1
        return Constant(f"constant_{counter}", np.ones(1, dtype=np.float32))

    func_inner = Function("FuncInner", ir_version=10)
    func_outer = Function("FuncOuter", ir_version=10)
    funcs = [func_inner, func_outer]

    # func_inner(x) = x + 1
    func_inner.inputs = [Variable("input", dtype=dtype)]
    func_inner.outputs = [Variable("output", dtype=dtype)]
    func_inner.nodes = [
        Node(
            "Add",
            inputs=[func_inner.inputs[0], const()],
            outputs=[func_inner.outputs[0]],
        )
    ]

    # func_outer(x) = func_inner(1) * x
    func_outer.inputs = [Variable("input", dtype=dtype)]
    func_outer.functions = [func_inner]
    func_outer_intermediate = func_outer.FuncInner(inputs=[const()])[0]
    func_outer.outputs = [func_outer.mul(func_outer.inputs[0], func_outer_intermediate)]

    # a = func_inner(input)
    # b = func_outer(input)
    # c = 1 + 1
    # d = a + b
    # e = c + d
    # output = e + c
    graph = Graph(
        inputs=[Variable("graph_input", dtype=dtype)], functions=funcs, ir_version=10
    )
    var0 = graph.FuncInner(inputs=[const()])[0]
    var1 = graph.FuncOuter(inputs=[const()])[0]
    var2 = graph.add(const(), const())
    var3 = graph.add(var0, var1)
    var4 = graph.add(var2, var3)
    graph.outputs = [graph.add(var2, var4)]
    graph.outputs[0].dtype = dtype

    yield graph


class TestFoldConstants(object):
    @pytest.mark.parametrize("partitioning", [None, "basic", "recursive"])
    def test_basic(self, simple_foldable, partitioning):
        inp = simple_foldable.inputs[0]

        simple_foldable.fold_constants(partitioning=partitioning).cleanup(
            remove_unused_graph_inputs=True
        )

        # Extra node should be removed
        assert len(simple_foldable.nodes) == 1
        assert simple_foldable.nodes[0].inputs[0] == inp
        assert simple_foldable.nodes[0].inputs[1].name == "c"

        # Value should be computed correctly
        assert np.all(
            simple_foldable.nodes[0].inputs[1].values
            == np.ones(shape=(1, 3), dtype=np.float32) * 2
        )

    def test_one_hop(self, one_hop_foldable):
        inp = one_hop_foldable.inputs[0]

        one_hop_foldable.fold_constants().cleanup()

        # Extra nodes should be removed
        assert len(one_hop_foldable.nodes) == 1
        assert one_hop_foldable.nodes[0].inputs[0] == inp
        assert one_hop_foldable.nodes[0].inputs[1].name == "e"

        # Value should be computed correctly
        assert np.all(
            one_hop_foldable.nodes[0].inputs[1].values
            == np.ones(shape=(1, 3), dtype=np.float32) * 3
        )

    def test_with_invalid_nodes(self, foldable_with_invalid_node):
        foldable_with_invalid_node.fold_constants(partitioning="recursive").cleanup()

        tensor_map = foldable_with_invalid_node.tensors()

        assert len(foldable_with_invalid_node.nodes) == 3
        assert foldable_with_invalid_node.nodes[0].op == "Fake"
        assert foldable_with_invalid_node.nodes[1].op == "Add"
        assert foldable_with_invalid_node.nodes[2].op == "Add"
        assert np.all(
            tensor_map["c"].values == (np.ones(shape=(1, 3), dtype=np.float32) * 2)
        )

    def test_with_invalid_nodes_no_recursive(self, foldable_with_invalid_node):
        # No folding should take place without recursive partitioning
        original = foldable_with_invalid_node.copy()
        assert foldable_with_invalid_node.fold_constants() == original

    def test_no_foldable_constants(self):
        inp0 = Variable("input0", shape=(1, 3), dtype=np.float32)
        inp1 = Variable("input1", shape=(1, 3), dtype=np.float32)
        out = Variable("output", shape=(1, 3), dtype=np.float32)

        nodes = [Node("Add", inputs=[inp0, inp1], outputs=[out])]

        graph = Graph(nodes=nodes, inputs=[inp0, inp1], outputs=[out], ir_version=10)

        graph.fold_constants().cleanup()

        assert len(graph.nodes) == 1
        assert graph.nodes[0].inputs == [inp0, inp1]

    def test_const_node(self):
        graph = Graph(ir_version=10)
        values = np.ones((1, 3, 3), dtype=np.int64)
        graph.outputs = [graph.constant(values=values)]

        assert isinstance(graph.outputs[0], Variable)

        graph.fold_constants().cleanup()

        assert isinstance(graph.outputs[0], Constant)
        assert np.all(graph.outputs[0].values == values)
        assert not graph.nodes

    def test_shape_of_constant_tensor(self):
        graph = Graph(ir_version=10)
        values = np.ones((1, 3, 3), dtype=np.int64)
        const = Constant("const", values=values)
        graph.outputs = [graph.shape(const)]

        graph.fold_constants().cleanup()

        assert not graph.nodes
        assert isinstance(graph.outputs[0], Constant)
        assert np.all(graph.outputs[0].values == (1, 3, 3))

    def test_shape_of_constant_node(self):
        graph = Graph(ir_version=10)
        values = np.ones((1, 3, 3), dtype=np.int64)
        const = graph.constant(values=values)
        graph.outputs = [graph.shape(const)]

        graph.fold_constants().cleanup()

        assert not graph.nodes
        assert isinstance(graph.outputs[0], Constant)
        assert np.all(graph.outputs[0].values == (1, 3, 3))

    # Cannot fold shape nodes if they have dynamically shaped inputs.
    def test_shape_of_variable_tensor_dynamic_shape(self):
        var = Variable("var", dtype=np.float32, shape=("", -1, 0, 4))
        graph = Graph(inputs=[var], ir_version=10)
        graph.outputs = [graph.shape(var)]

        graph.fold_constants().cleanup()

        assert len(graph.nodes) == 1
        assert graph.nodes[0].op == "Shape"
        assert isinstance(graph.outputs[0], Variable)

    def test_shape_of_variable_tensor_static_shape(self):
        var = Variable("var", dtype=np.float32, shape=(1, 3, 4))
        graph = Graph(inputs=[var], ir_version=10)
        graph.inputs = [var]
        graph.outputs = [graph.shape(var)]

        graph.fold_constants().cleanup()

        assert not graph.nodes
        assert isinstance(graph.outputs[0], Constant)
        assert np.all(graph.outputs[0].values == (1, 3, 4))

    def test_shape_of_variable_tensor_multiple_shapes(self):
        graph = Graph(ir_version=10)
        var = Variable("var", dtype=np.float32, shape=(1, 3, 4))
        var2 = Variable("var2", dtype=np.float32, shape=tuple())  # Scalar
        graph.inputs = [var, var2]
        graph.outputs = [graph.shape(var), graph.identity(var), graph.shape(var2)]

        graph.fold_constants().cleanup()

        assert len(graph.nodes) == 1
        assert graph.nodes[0].op == "Identity"
        assert isinstance(graph.outputs[0], Constant)
        assert np.all(graph.outputs[0].values == (1, 3, 4))
        assert isinstance(graph.outputs[2], Constant)
        assert np.all(graph.outputs[2].values == tuple())

    def test_shape_of_variable_tensor_static_shape_no_fold(self):
        graph = Graph(ir_version=10)
        var = Variable("var", dtype=np.float32, shape=(1, 3, 4))
        graph.inputs = [var]
        graph.outputs = [graph.shape(var)]

        graph.fold_constants(fold_shapes=False).cleanup()

        assert len(graph.nodes) == 1
        assert graph.nodes[0].op == "Shape"
        assert isinstance(graph.outputs[0], Variable)

    # Constant folding should not cause constant tensors in the model to be loaded.
    def test_no_load_constants(self):
        graph = gs.import_onnx(const_foldable().load())

        new_graph = graph.fold_constants()

        def check_no_const_loaded(graph):
            num_lazy_constants = 0
            for tensor in graph.tensors().values():
                if isinstance(tensor, Constant) and isinstance(
                    tensor._values, LazyValues
                ):
                    num_lazy_constants += 1
            assert (
                num_lazy_constants == 3
            )  # Graph starts with 3 constants - none should be loaded.

        check_no_const_loaded(graph)
        check_no_const_loaded(new_graph)

    @pytest.mark.parametrize(
        "shape, indices",
        [
            (("batch", 3, "height", "width"), 1),  # Scalar indices case
            (None, 1),  # Shape not inferered case
            (("batch", 3, "height", "width"), [1]),
            (("batch", 3, "height", 224), [1, 3]),
            (("batch", 3, 224, 224), [1, 2, 3]),
        ],
    )
    def test_shape_gather(self, shape, indices):
        indices = np.array(indices)

        inp = Variable("input", dtype=np.float32, shape=shape)
        graph = Graph(inputs=[inp], ir_version=10)

        inp_shape = graph.shape(inp)
        shape_part = graph.gather(inp_shape, indices=indices)
        graph.outputs = [
            graph.add(shape_part, shape_part),
            graph.gather(inp_shape, indices=[0]),
            graph.gather(inp_shape, indices=np.array(0)),
        ]

        graph.fold_constants()

        if shape is not None:
            assert isinstance(graph.outputs[0], Constant)
            expected_shape = np.array(shape)[indices].astype(np.int64) * 2
            assert np.all(graph.outputs[0].values == expected_shape)
        else:
            assert isinstance(graph.outputs[0], Variable)

        assert isinstance(graph.outputs[1], Variable)
        assert isinstance(graph.outputs[2], Variable)

    @pytest.mark.parametrize(
        "shape, starts, ends, axes, steps, expected",
        [
            (
                ("batch", 3, "height", "width"),
                1,
                2,
                0,
                1,
                [3],
            ),  # Scalar starts/ends case
            (("batch", 3, "height", "width"), [1], [2], [0], [1], [3]),
            (
                ("batch", 3, 5, "width"),
                [1],
                [-1],
                [0],
                [1],
                [3, 5],
            ),  # Negative ends case
            (
                ("batch", 3, 5, 7),
                [1],
                [2000],
                [0],
                [1],
                [3, 5, 7],
            ),  # Past end, ends case
            (("batch", 3, 5, 7), [-2], [4], [0], [1], [5, 7]),  # Negative starts case
            (("batch", 3, 5, 7), [-2], [4], [1], [1], None),  # Non-zero axes case
            (("batch", 3, 5, "width"), [-2], [4], [1], [1], None),  # Dynamic case
            (("batch", 3, 5, 7), [1], [4], [0], [2], [3, 7]),  # Non-one steps case
            (("batch", 3, 5, 7), [4], [0], [0], [-1], [7, 5, 3]),  # Negative steps case
        ],
    )
    def test_shape_slice(self, shape, starts, ends, axes, steps, expected):
        inp = Variable("input", dtype=np.float32, shape=shape)
        graph = Graph(inputs=[inp], ir_version=10)

        inp_shape = graph.shape(inp)
        graph.outputs = [
            graph.slice(
                inp_shape,
                np.array(starts),
                np.array(ends),
                axes=np.array(axes),
                steps=np.array(steps),
            )
        ]

        graph.fold_constants()

        if expected:
            assert isinstance(graph.outputs[0], Constant)
            assert np.all(graph.outputs[0].values == expected)
        else:
            assert isinstance(graph.outputs[0], Variable)

    # In the single input case, we should derive starts/ends/axes/steps from the attributes.
    def test_shape_slice_single_input(self):
        inp = Variable("input", dtype=np.int64, shape=(5, 6, 3, 2))
        graph = Graph(inputs=[inp], ir_version=10)

        inp_shape = graph.shape(inp)
        graph.outputs = [graph.slice(inp_shape)]

        slice_node = graph.outputs[0].inputs[0]

        slice_node.attrs = {
            "axes": [0],
            "starts": [1],
            "ends": [3],
            "steps": [2],
        }

        graph.fold_constants()

        assert isinstance(graph.outputs[0], Constant)
        assert np.all(graph.outputs[0].values == inp.shape[1:3:2])

    def test_with_variable_conditional(self):
        cond = gs.Variable("cond", dtype=bool, shape=(1,))

        X = gs.Variable("X", dtype=np.float32, shape=(1,))
        Y = gs.Constant("Y", values=np.ones((1,), dtype=np.float32))
        graph = Graph(inputs=[X, cond], ir_version=10)

        then_graph = Graph(name="Then", ir_version=10)
        then_graph.outputs = [then_graph.add(Y, Y)]

        else_graph = Graph(name="Else", ir_version=10)
        else_graph.outputs = [else_graph.add(X, else_graph.add(Y, Y))]

        graph.outputs = [graph.if_op(cond, then_graph, else_graph)]

        graph.fold_constants()
        graph.cleanup()

        assert len(then_graph.nodes) == 0
        assert np.all(then_graph.outputs[0].values == (Y.values * 2))

        assert len(else_graph.nodes) == 1
        assert isinstance(else_graph.nodes[0].inputs[1], Constant)
        assert np.all(else_graph.nodes[0].inputs[1].values == (Y.values * 2))

    @pytest.mark.parametrize("cond_value", [True, False])
    @pytest.mark.parametrize("flatten", [True, False])
    def test_flatten_static_conditional(self, flatten, cond_value):
        cond = gs.Constant("cond", values=np.array([cond_value], dtype=bool))

        X = gs.Variable("X", dtype=np.float32, shape=(1,))
        Y = gs.Variable("Y", dtype=np.float32, shape=(1,))
        graph = Graph(inputs=[X, cond], ir_version=10)

        then_graph = Graph(name="Then", ir_version=10)
        then_graph.outputs = [then_graph.relu(then_graph.add(Y, Y))]

        else_graph = Graph(name="Else", ir_version=10)
        else_graph.outputs = [else_graph.add(X, else_graph.add(Y, Y))]

        if_out = graph.if_op(cond, then_graph, else_graph)
        graph.outputs = [if_out]

        graph.fold_constants(flatten_subgraphs=flatten)
        graph.cleanup()

        if flatten:
            assert len(graph.nodes) == 2
            assert graph.nodes[0].op == "Add"
            assert graph.nodes[1].op == "Relu" if cond_value else "Add"

            subgraph = then_graph if cond_value else else_graph
            # Make sure subgraph intermediate tensors are renamed
            assert graph.nodes[0].outputs[0].name == "add_out_0_subg_0_{:}".format(
                subgraph.name
            )
            assert graph.outputs[0].inputs[0] == subgraph.nodes[-1]
            assert subgraph.nodes[-1] == graph.nodes[-1]
        else:
            assert len(graph.nodes) == 1
            assert len(graph.nodes) == 1
            assert graph.nodes[0].op == "If"
            assert graph.outputs[0].inputs[0] == graph.nodes[-1]
        assert graph.outputs == [if_out]

    def test_const_inp_but_non_foldable_nested_graph(self):
        cond = gs.Constant("cond", values=np.array(True))
        X = gs.Variable("X", dtype=np.float32, shape=(1,))

        graph = Graph(inputs=[X], ir_version=10)

        then_graph = Graph(name="Then", ir_version=10)
        then_graph.outputs = [then_graph.add(X, X)]

        else_graph = Graph(name="Else", ir_version=10)
        else_graph.outputs = [else_graph.add(X, else_graph.add(X, X))]

        # Even though if_op looks foldable because it has all constant inputs,
        # it's not, since its subgraphs depend on variables in the outer scope.
        graph.outputs = [graph.if_op(cond, then_graph, else_graph)]

        # This should not raise because the `If` node should be excluded from
        # constant folding.
        graph.fold_constants(error_ok=False, flatten_subgraphs=False).cleanup()

        assert graph.nodes[0].op == "If"
        assert len(then_graph.nodes) == 1
        assert len(else_graph.nodes) == 2

    def test_cast_elision(self):
        graph = gs.import_onnx(shape_cast_elision().load())
        graph.fold_constants().cleanup()
        assert not any(node.op == "Cast" for node in graph.nodes)

    def test_cast_elision_int64(self):
        X = gs.Variable("X", dtype=np.int64, shape=(1,))
        graph = Graph(inputs=[X], ir_version=10)
        casted_x = graph.cast(X, to=onnx.TensorProto.DataType.FLOAT)
        add_out = graph.add(casted_x, casted_x)
        graph.outputs = [graph.cast(add_out, to=onnx.TensorProto.DataType.INT64)]

        graph.fold_constants().cleanup()
        assert graph.nodes[0].op == "Add"

    # Make sure we're lowering constant nodes before running cast elision
    def test_cast_elision_with_constant_node(self):
        inp = gs.Variable("inp", dtype=np.int64, shape=(1,))
        graph = Graph(inputs=[inp], ir_version=10)

        casted_inp = graph.cast(inp, to=onnx.TensorProto.DataType.FLOAT)
        add_out = graph.add(casted_inp, graph.constant(np.array([2], dtype=np.float32)))

        casted_out = graph.cast(add_out, to=onnx.TensorProto.DataType.INT64)
        casted_out.dtype = np.int64

        graph.outputs = [casted_out]

        graph.fold_constants().cleanup()
        assert [node.op for node in graph.nodes] == ["Add"]

        add_const_inp = graph.nodes[0].inputs[1]
        assert isinstance(add_const_inp, Constant)
        assert (
            add_const_inp.dtype == np.int64
        )  # Should have been casted to match dtype of other inputs.

    # For a graph like:
    #
    #     inp
    #      |
    #    Cast
    #      |
    #    Add
    #     |
    #    Cast
    #     |
    #    out
    #
    # 1. We cannot remove the initial `Cast` if it is used outside the `Add` node
    # 2. We cannot perform cast elision at all if the original output of the `Add` node is
    #    used outside the subsequent `Cast` node.
    #
    @pytest.mark.parametrize("use_as_graph_output", [True, False], ids=["graph", ""])
    @pytest.mark.parametrize("use_in_other_node", [True, False], ids=["node", ""])
    # Whether to apply the effects of the first two parameters to the input `Cast` node or to the `Add` node.
    @pytest.mark.parametrize(
        "apply_to_input_cast", [True, False], ids=["input", "output"]
    )
    def test_cast_elision_multi_use_cast(
        self, use_as_graph_output, use_in_other_node, apply_to_input_cast
    ):
        X = gs.Variable("X", dtype=np.int32, shape=(1,))
        graph = Graph(inputs=[X], ir_version=10)
        casted_x = graph.cast(X, to=onnx.TensorProto.DataType.FLOAT)
        add_out = graph.add(casted_x, casted_x)
        uncasted_x = graph.cast(add_out, to=onnx.TensorProto.DataType.INT32)

        graph.outputs = [uncasted_x]

        mutli_use_tensor = casted_x if apply_to_input_cast else add_out
        if use_in_other_node:
            graph.outputs.append(graph.identity(mutli_use_tensor))

        if use_as_graph_output:
            graph.outputs.append(mutli_use_tensor)

        print(graph)
        graph.fold_constants().cleanup()
        ops = [node.op for node in graph.nodes]
        if use_as_graph_output or use_in_other_node:
            if apply_to_input_cast:
                assert graph.nodes[1].inputs[0] == X
                assert graph.nodes[1].outputs[0] == uncasted_x
                assert ops == ["Cast", "Add"] + (
                    ["Identity"] if use_in_other_node else []
                )
            else:
                assert ops == ["Cast", "Add", "Cast"] + (
                    ["Identity"] if use_in_other_node else []
                )
        else:
            assert ops == ["Add"]

    @pytest.mark.parametrize(
        # If layer1_num_bytes is larger than layer0_num_bytes, then it must be a multiple.
        "size_threshold, layer0_num_bytes, layer0_should_fold, layer1_num_bytes, layer1_should_fold",
        [
            # No size threshold - everything should fold.
            (
                None,
                2,
                True,
                4,
                True,
            ),
            # Monotonically increasing but under size threshold - everything should fold.
            (
                8,
                2,
                True,
                4,
                True,
            ),
            # Increasing then decreasing, but under size threshold - everything should fold.
            (
                8,
                2,
                True,
                1,
                True,
            ),
            # All tensors over size threshold - nothing should fold.
            (
                1,
                2,
                False,
                4,
                False,
            ),
            # Second tensor over size threshold - only first tensor should fold.
            (
                3,
                2,
                True,
                4,
                False,
            ),
            # First tensor over size threshold - second tensor should still fold.
            (
                3,
                4,
                False,
                2,
                True,
            ),
        ],
    )
    @pytest.mark.parametrize("push_into_subgraph", [True, False], ids=["subgraph", ""])
    def test_folding_size_threshold(
        self,
        size_threshold,
        layer0_num_bytes,
        layer0_should_fold,
        layer1_num_bytes,
        layer1_should_fold,
        push_into_subgraph,
    ):
        graph = Graph(ir_version=10)

        shape = (1,)

        layer0_repeats = layer0_num_bytes // misc.volume(shape)
        layer0 = graph.tile(np.ones(shape, dtype=np.int8), repeats=[layer0_repeats])
        layer0.inputs[0].name = "Layer0"

        if layer1_num_bytes > layer0_num_bytes:
            layer1_repeats = layer1_num_bytes // layer0_num_bytes
            layer1 = graph.tile(layer0, repeats=[layer1_repeats])
        else:
            layer1 = graph.slice(layer0, starts=[0], ends=[layer1_num_bytes])
        layer1.inputs[0].name = "Layer1"

        graph.outputs = [layer1]

        # Make sure size_threshold option is propagated into subgraphs.
        if push_into_subgraph:
            cond = gs.Variable("cond", dtype=bool, shape=tuple())
            outer_graph = Graph(inputs=[cond])
            outer_graph.if_op(cond, then_graph=graph, else_graph=graph)

            outer_graph.fold_constants(size_threshold=size_threshold)
        else:
            graph.fold_constants(size_threshold=size_threshold)

        # When a tensor is folded, it is disconnected from its producer nodes
        assert len(graph.nodes[0].outputs) == (0 if layer0_should_fold else 1)
        assert len(graph.nodes[1].outputs) == (0 if layer1_should_fold else 1)

    @pytest.mark.parametrize("op", ["Q", "DQ"])
    @pytest.mark.parametrize("add_intermediate_layer", [True, False])
    def test_no_fold_qdq(self, op, add_intermediate_layer):
        dtype = np.float32 if op == "Q" else np.int8
        inp = gs.Constant("input", np.ones(shape=(1, 3, 5, 5), dtype=dtype))
        graph = Graph(inputs=[inp], opset=13, ir_version=10)

        if add_intermediate_layer:
            inp = graph.identity(inp)

        qdq_func = graph.quantize_linear if op == "Q" else graph.dequantize_linear
        graph.outputs = [
            qdq_func(inp, 1.2, np.array(0, dtype=np.int8))
        ]  # Arbitrary scale and zero-point

        graph.fold_constants().cleanup()
        assert len(graph.nodes) == 1
        assert (
            graph.nodes[0].op == "QuantizeLinear" if op == "Q" else "DequantizeLinear"
        )

    @pytest.mark.parametrize(
        "should_exclude_node_func,expected_node_names",
        [
            (
                lambda node: True,
                [
                    "onnx_graphsurgeon_node_1",
                    "onnx_graphsurgeon_node_3",
                    "onnx_graphsurgeon_node_5",
                    "onnx_graphsurgeon_node_7",
                ],
            ),
            (
                lambda node: node.name == "onnx_graphsurgeon_node_5",
                ["onnx_graphsurgeon_node_5", "onnx_graphsurgeon_node_7"],
            ),
            (
                lambda node: node.op == "Add",
                [
                    "onnx_graphsurgeon_node_1",
                    "onnx_graphsurgeon_node_3",
                    "onnx_graphsurgeon_node_5",
                    "onnx_graphsurgeon_node_7",
                ],
            ),
            (
                lambda node: node.op == "Relu",
                [
                    "onnx_graphsurgeon_node_3",
                    "onnx_graphsurgeon_node_5",
                    "onnx_graphsurgeon_node_7",
                ],
            ),
        ],
    )
    def test_custom_should_exclude_node(
        self, should_exclude_node_func, expected_node_names
    ):
        inp = gs.Constant("input", np.ones(shape=(1, 3, 5, 5), dtype=np.float32))
        graph = Graph(inputs=[inp], ir_version=10)

        add_0 = graph.add(inp, inp)  # onnx_graphsurgeon_node_1 -> add_out_0
        relu_0 = graph.relu(add_0)  # onnx_graphsurgeon_node_3 -> relu_out_2
        add_1 = graph.add(relu_0, relu_0)  # onnx_graphsurgeon_node_5 -> add_out_4
        relu_1 = graph.relu(add_1)  # onnx_graphsurgeon_node_7 -> relu_out_6

        graph.outputs = [relu_1]

        graph.fold_constants(should_exclude_node=should_exclude_node_func).cleanup()
        assert [node.name for node in graph.nodes] == expected_node_names

    def test_omitted_optional_inputs_ignored(self):
        # An omitted optional input will show up as a `Variable` with no name.
        # This should *not* prevent us from folding nodes where all other inputs are constants.
        data = gs.Constant("data", np.ones(shape=(3, 5, 5), dtype=np.float32))
        pads = gs.Constant("pads", np.zeros(shape=(6,), dtype=np.int64))
        graph = Graph(ir_version=10)

        pad_0 = graph.pad(data, pads, constant_value=None)
        graph.outputs = [pad_0]

        assert pad_0.inputs[0].inputs[2] == Variable.empty()
        assert len(graph.nodes) == 1

        graph.fold_constants().cleanup()

        assert len(graph.nodes) == 0
        assert isinstance(graph.outputs[0], gs.Constant)

    def test_function(self, simple_foldable):
        func = Function(
            "Test",
            nodes=simple_foldable.nodes,
            inputs=simple_foldable.inputs,
            outputs=simple_foldable.outputs,
            ir_version=10,
        )
        assert len(func.nodes) == 2
        func.fold_constants().cleanup()
        assert len(func.nodes) == 1

    def test_function_with_attributes(self):
        # Nodes that reference function attributes shouldn't be folded.
        input = Variable("input", dtype=np.float32)
        func = Function(
            "Test", inputs=[input], attrs={"softmax_axis": -1}, ir_version=10
        )
        x = func.softmax(
            np.float32([[1, 2, 3]]), axis=Node.AttributeRef("softmax_axis", int)
        )
        y = func.softmax(np.float32([[4, 5, 6]]), axis=-1)
        z = func.add(x, y)
        func.outputs += [func.add(input, z)]

        # Only one node should get folded.
        assert len(func.nodes) == 4
        func.fold_constants().cleanup()
        assert len(func.nodes) == 3

    def test_functions_inside_functions(self, foldable_with_local_functions):
        graph = foldable_with_local_functions
        graph.toposort()
        graph.fold_constants(error_ok=False)
        graph.cleanup()

        assert len(graph.inputs) == 1
        assert len(graph.outputs) == 1
        assert isinstance(graph.outputs[0], Constant)
        assert graph.outputs[0].values == 8

    def test_function_with_unused_input(self, foldable_with_local_functions):
        # Constant folding should still work correctly when a function has unused inputs.
        graph = foldable_with_local_functions

        func_outer = graph.functions[1]
        func_outer.nodes[1].inputs[0] = gs.Constant(
            "Constant_99", values=np.array([3], dtype=np.float32)
        )

        graph.toposort().fold_constants(error_ok=False).cleanup()

        assert len(graph.inputs) == 1
        assert len(graph.outputs) == 1
        assert isinstance(graph.outputs[0], Constant)
        assert graph.outputs[0].values == 12

    def test_inner_function_with_unused_input(self, foldable_with_local_functions):
        graph = foldable_with_local_functions

        func_inner = graph.functions[0]
        func_inner.nodes[0].inputs[0] = gs.Constant(
            "Constant_99", values=np.array([3], dtype=np.float32)
        )

        graph.toposort().fold_constants(error_ok=False).cleanup()

        assert len(graph.inputs) == 1
        assert len(graph.outputs) == 1
        assert isinstance(graph.outputs[0], Constant)
        assert graph.outputs[0].values == 12

    def test_function_with_subgraph(self, foldable_with_local_functions):
        graph = foldable_with_local_functions
        dtype = graph.outputs[0].dtype

        func = Function("func_with_subgraph", ir_version=10)
        func.inputs = [Variable("input")]

        then_graph = Graph(name="Then", ir_version=10)
        then_graph.outputs = [then_graph.identity(func.inputs[0])]

        else_graph = Graph(name="Else", ir_version=10)
        else_graph.functions = graph.functions
        else_graph.outputs = else_graph.FuncInner(
            inputs=[func.inputs[0]], outputs=["else_out"]
        )

        cond = func.less(func.inputs[0], Constant("Zero", np.zeros(1, dtype=dtype)))
        func.outputs = [func.if_op(cond, then_graph, else_graph)]

        graph.functions.append(func)
        graph.outputs = graph.func_with_subgraph(
            inputs=[graph.outputs[0]], outputs=["new_output"]
        )
        graph.toposort().fold_constants(error_ok=False).cleanup()
        assert len(graph.inputs) == 1
        assert len(graph.outputs) == 1
        assert isinstance(graph.outputs[0], Constant)
        assert graph.outputs[0].values == 9

    def test_function_with_complicated_attrs(self):
        # Types of attributes we should test:
        # 1) Has no default value
        # 2) Has default value which is used
        # 3) Has default value which is overridden
        # 4) Confusing attribute name / reference name mappings
        dtype = np.float32
        opset = 18
        func = Function(
            "complicated_func",
            inputs=[Variable("input", dtype=dtype)],
            opset=opset,
            ir_version=10,
        )
        variables = [Variable(f"var{i}", dtype=dtype) for i in range(5)]

        func.nodes.append(
            Node(
                "ConstantOfShape",
                attrs={"value": Node.AttributeRef("ConstantOfShape_value", Tensor)},
                inputs=[func.inputs[0]],
                outputs=[variables[0]],  # shape [2, 3, 4]
            )
        )
        func.nodes.append(
            Node(
                "ReduceSum",
                attrs={"keepdims": Node.AttributeRef("keepdims", int)},
                inputs=[
                    variables[0],
                    Constant("ReduceSum_axis", np.array([1], dtype=int)),
                ],
                outputs=[variables[1]],  # shape [2, 1, 4] when keepDims=True
            )
        )
        func.nodes.append(
            Node(
                "Flatten",
                attrs={"axis": Node.AttributeRef("Flatten_axis", np.int32)},
                inputs=[variables[1]],
                outputs=[variables[2]],  # shape [2, 4] when axis=1
            )
        )
        func.nodes.append(
            Node(
                "Concat",
                attrs={"axis": Node.AttributeRef("axis", np.int64)},
                inputs=[
                    variables[2],
                    Constant("to_concat", values=np.ones((2, 1), dtype=dtype)),
                ],
                outputs=[variables[3]],  # shape [2, 5] when axis=-1
            )
        )
        func.nodes.append(
            Node(
                "Concat",
                attrs={"axis": Node.AttributeRef("Concat_axis", int)},
                inputs=[
                    variables[3],
                    Constant("to_concat_2", values=2 * np.ones((2, 1), dtype=dtype)),
                ],
                outputs=[variables[4]],  # shape [2, 6] when axis=-1
            )
        )
        func.outputs = [variables[4]]

        func.attrs = {
            "ConstantOfShape_value": None,  # no default value
            "axis": None,  # no default value
            "Flatten_axis": 0,
            "keepdims": True,
            "Concat_axis": -1,
        }
        graph = Graph(opset=opset, functions=[func], ir_version=10)
        input = Constant("shape", values=np.array([2, 3, 4], dtype=int))
        output = graph.complicated_func(
            inputs=[input],
            outputs=["output"],
            attrs={
                "ConstantOfShape_value": Constant(
                    "three", values=np.array([3], dtype=dtype)
                ),
                "axis": -1,
                "Flatten_axis": 2,  # overrides default value
            },
        )[0]
        output.dtype = dtype
        graph.inputs = [input]
        graph.outputs = [output]
        graph.fold_constants(error_ok=False).cleanup()

        assert len(graph.inputs) == 1
        assert len(graph.outputs) == 1
        assert isinstance(graph.outputs[0], Constant)
        assert np.all(
            graph.outputs[0].values
            == np.array([[9, 9, 9, 9, 1, 2], [9, 9, 9, 9, 1, 2]])
        )


class TestIO(object):
    def test_io_cannot_be_sync_list_on_init(self):
        inp = Variable("input0", shape=(1, 3), dtype=np.float32)
        out = Variable("input1", shape=(1, 3), dtype=np.float32)

        node = Node("Add", inputs=[inp], outputs=[out])
        assert isinstance(node.inputs, SynchronizedList)
        assert isinstance(node.outputs, SynchronizedList)

        graph = Graph(nodes=[node], inputs=node.inputs, outputs=node.outputs)
        assert not isinstance(graph.inputs, SynchronizedList)
        assert not isinstance(graph.outputs, SynchronizedList)

    def test_io_cannot_be_sync_list_on_assign(self):
        inp = Variable("input0", shape=(1, 3), dtype=np.float32)
        out = Variable("input1", shape=(1, 3), dtype=np.float32)

        node = Node("Add", inputs=[inp], outputs=[out])
        assert isinstance(node.inputs, SynchronizedList)
        assert isinstance(node.outputs, SynchronizedList)

        graph = Graph(nodes=[node], inputs=[], outputs=[])
        graph.inputs = node.inputs
        graph.outputs = node.outputs

        assert not isinstance(graph.inputs, SynchronizedList)
        assert not isinstance(graph.outputs, SynchronizedList)


class TestFunctionList(object):
    def test_shared_function_list(self):
        subgraph = Graph()
        node = Node("Test", attrs={"test_attr": subgraph})
        main_graph = Graph(nodes=[node])

        new_func = Function("func")
        main_graph.functions.append(new_func)
        assert len(main_graph.functions) == 1
        assert len(subgraph.functions) == 1
        assert subgraph.functions[0] == new_func

    def test_set_function_list(self):
        subgraph = Graph()
        node = Node("Test", attrs={"test_attr": subgraph})
        main_graph = Graph(nodes=[node])

        new_func = Function("func")
        main_graph.functions = [new_func]
        assert len(main_graph.functions) == 1
        assert len(subgraph.functions) == 1
        assert subgraph.functions[0] == new_func

    def test_merge_funcs(self):
        func1 = Function("func1", domain="domain1")
        func2 = Function("func1", domain="domain2")
        func3 = Function("func2", domain="domain1")
        func4 = Function("func2", domain="domain2")
        all_funcs = [func1, func2, func3, func4]

        subgraph1 = Graph(functions=[func1, func2])

        node1 = Node("Test", attrs={"test_attr": subgraph1})
        subgraph2 = Graph(functions=[func2, func3], nodes=[node1])

        node2 = Node("Test", attrs={"test_attr": subgraph2})
        main_graph = Graph(functions=[func3, func4], nodes=[node2])

        assert len(main_graph.functions) == len(all_funcs)
        assert len(subgraph1.functions) == len(all_funcs)
        assert len(subgraph2.functions) == len(all_funcs)

        new_func = Function("func3")
        main_graph.functions.append(new_func)
        assert new_func in subgraph1.functions
        assert new_func in subgraph2.functions
