"""This module implements the Circuit class."""
from __future__ import annotations

import copy
import logging
import pickle
import warnings
from typing import Any
from typing import Callable
from typing import cast
from typing import Collection
from typing import Dict
from typing import Iterable
from typing import Iterator
from typing import Optional
from typing import overload
from typing import Sequence
from typing import TYPE_CHECKING

import dill
import numpy as np
import numpy.typing as npt

from bqskit.ir.gate import Gate
from bqskit.ir.gates.circuitgate import CircuitGate
from bqskit.ir.gates.constant.unitary import ConstantUnitaryGate
from bqskit.ir.gates.measure import MeasurementPlaceholder
from bqskit.ir.interval import CycleInterval
from bqskit.ir.iterator import CircuitIterator
from bqskit.ir.lang import get_language
from bqskit.ir.location import CircuitLocation
from bqskit.ir.location import CircuitLocationLike
from bqskit.ir.operation import Operation
from bqskit.ir.opt.cost.generator import CostFunctionGenerator
from bqskit.ir.opt.instantiater import Instantiater
from bqskit.ir.opt.instantiaters import instantiater_order
from bqskit.ir.opt.minimizers.ceres import CeresMinimizer
from bqskit.ir.opt.multistartgen import MultiStartGenerator
from bqskit.ir.point import CircuitPoint
from bqskit.ir.point import CircuitPointLike
from bqskit.ir.region import CircuitRegion
from bqskit.ir.region import CircuitRegionLike
from bqskit.qis.graph import CouplingGraph
from bqskit.qis.state.state import StateLike
from bqskit.qis.state.state import StateVector
from bqskit.qis.state.statemap import StateVectorMap
from bqskit.qis.state.system import StateSystem
from bqskit.qis.unitary.differentiable import DifferentiableUnitary
from bqskit.qis.unitary.unitary import RealVector
from bqskit.qis.unitary.unitarybuilder import UnitaryBuilder
from bqskit.qis.unitary.unitarymatrix import UnitaryLike
from bqskit.qis.unitary.unitarymatrix import UnitaryMatrix
from bqskit.utils.random import seed_random_sources
from bqskit.utils.typing import is_bool
from bqskit.utils.typing import is_integer
from bqskit.utils.typing import is_iterable
from bqskit.utils.typing import is_sequence_of_int
from bqskit.utils.typing import is_valid_radixes

if TYPE_CHECKING:
    from bqskit.ir.opt.cost.function import CostFunction
    from bqskit.compiler.basepass import BasePass
    from bqskit.compiler.gateset import GateSet

_logger = logging.getLogger(__name__)


class Circuit(DifferentiableUnitary, StateVectorMap, Collection[Operation]):
    """
    A Circuit is a quantum program composed of operation objects.

    The operations are organized in 2-dimensions, and are indexed by
    a CircuitPoint. The first index corresponds to an operation's cycle.
    This describes when an operation will be executed. The second index
    is the qudit index and describes where the operation will execute.
    An operation can be multi-qudit meaning its location - list of qudit
    indices describing which qudits the operation affects - contains
    multiple indices. All operations exist in only one cycle at a time,
    but if an operation is multi-qudit, then it is pointed to by multiple
    qudit indices.

    Invariants:
        1. A circuit method will never complete with an idle cycle.
           An idle cycle is one that contains no gates.

        2. No one logical operation will ever be pointed to from more
           than one cycle.

        3. Iterating through the entire circuit always produces
           operations in simulation order. This means that if those
           operations were applied to a quantum state in the same
           order, then the result is the same as simulating the circuit.

    Notes:
        While a guarantee is made that the circuit never has any idle
        cycles, this means that cycles can be deleted or inserted
        during a method call. Therefore, cycle indices may need to be
        updated in between calls to circuit methods. There are several
        "batch" variants of methods that can handle this for you.
    """

    def __init__(self, num_qudits: int, radixes: Sequence[int] = []) -> None:
        """
        Build an empty circuit with the specified number of qudits.

        By default, all qudits are qubits, but this can be changed
        with radixes.

        Args:
            num_qudits (int): The number of qudits in this circuit.

            radixes (Sequence[int]): A sequence with its length equal
                to `num_qudits`. Each element specifies the base of a
                qudit. Defaults to qubits.

        Raises:
            ValueError: if `num_qudits` is nonpositive.

        Examples:
            >>> circ = Circuit(4)  # Creates four-qubit empty circuit.

            >>> circ = Circuit(2, [2, 3])  # Creates one qubit and one qutrit.

            >>> circ = Circuit(2)
            >>> from bqskit.ir.gates import HGate, CXGate, HGate
            >>> circ.append_gate(HGate(), 0)
            >>> circ.append_gate(CXGate(), (0, 1))
            >>> circ.append_gate(HGate(), 1)
            >>> circ.get_unitary()
            array([[ 0.5+0.j,  0.5+0.j,  0.5+0.j,  0.5+0.j],
                   [ 0.5+0.j, -0.5+0.j,  0.5+0.j, -0.5+0.j],
                   [ 0.5+0.j,  0.5+0.j, -0.5+0.j, -0.5+0.j],
                   [-0.5+0.j,  0.5+0.j,  0.5+0.j, -0.5+0.j]])
            >>> circ.get_statevector([1, 0, 0, 0])
            array([ 0.5+0.j,  0.5+0.j,  0.5+0.j, -0.5+0.j])
        """

        if not is_integer(num_qudits):
            raise TypeError(
                f'Expected integer num_qudits, got {type(num_qudits)}.',
            )

        if num_qudits <= 0:
            raise ValueError(
                f'Expected positive integer for num_qudits, got {num_qudits}.',
            )

        self._num_qudits = int(num_qudits)
        self._radixes = tuple(
            radixes if len(radixes) > 0 else [2] * self.num_qudits,
        )

        if not is_valid_radixes(self.radixes):
            raise TypeError('Invalid qudit radixes.')

        if len(self.radixes) != self.num_qudits:
            raise ValueError(
                'Expected length of radixes to be equal to num_qudits:'
                ' %d != %d' % (len(self.radixes), self.num_qudits),
            )

        self._circuit: list[list[Operation | None]] = []
        self._gate_info: dict[Gate, int] = {}
        self._graph_info: dict[tuple[int, int], int] = {}

        _NodePtrs = Dict[int, Optional[CircuitPoint]]
        self._front: _NodePtrs = {i: None for i in range(self.num_qudits)}
        self._rear: _NodePtrs = {i: None for i in range(self.num_qudits)}
        self._dag: dict[CircuitPoint, tuple[_NodePtrs, _NodePtrs]] = {}

    # region Circuit Properties

    @property
    def num_params(self) -> int:
        """The total number of parameters in the circuit."""
        num_params_acm = 0
        for gate, count in self._gate_info.items():
            num_params_acm += gate.num_params * count
        return num_params_acm

    @property
    def num_operations(self) -> int:
        """The total number of operations in the circuit."""
        num_gates_acm = 0
        for _, count in self._gate_info.items():
            num_gates_acm += count
        return num_gates_acm

    @property
    def num_cycles(self) -> int:
        """The number of cycles in the circuit."""
        return len(self._circuit)

    @property
    def params(self) -> npt.NDArray[np.float64]:
        """The stored parameters for the circuit."""
        return np.array(sum((list(op.params) for op in self), []))

    @property
    def depth(self) -> int:
        """The length of the critical path in the circuit."""
        qudit_depths = np.zeros(self.num_qudits, dtype=int)
        for op in self:
            new_depth = max(qudit_depths[list(op.location)]) + 1
            qudit_depths[list(op.location)] = new_depth
        return int(max(qudit_depths))

    @property
    def multi_qudit_depth(self) -> int:
        """The length of the critical path excluding single-qudit gates."""
        qudit_depths = np.zeros(self.num_qudits, dtype=int)
        for op in self:
            if op.num_qudits == 1:
                continue
            new_depth = max(qudit_depths[list(op.location)]) + 1
            qudit_depths[list(op.location)] = new_depth
        return int(max(qudit_depths))

    @property
    def parallelism(self) -> float:
        """The amount of parallelism in the circuit."""
        depth = self.depth

        if depth == 0:
            return 0

        weighted_num_operations = np.sum([
            gate.num_qudits * count
            for gate, count in self._gate_info.items()
        ])

        return float(weighted_num_operations / depth)

    @property
    def coupling_graph(self) -> CouplingGraph:
        """
        The qudit connectivity in the circuit.

        Returns:
            (CouplingGraph): The coupling graph required by the circuit.

        Notes:
            - Logical multi-qudit gates require participating qudits to
                have all-to-all connectivity.
        """
        return CouplingGraph(self._graph_info.keys(), self.num_qudits)

    @property
    def gate_set(self) -> GateSet:
        """The set of gates in the circuit."""
        from bqskit.compiler.gateset import GateSet
        return GateSet(set(self._gate_info.keys()))

    @property
    def gate_set_no_blocks(self) -> GateSet:
        """The set of gates in the circuit, recurses into circuit gates."""
        from bqskit.compiler.gateset import GateSet
        gates: set[Gate] = set()
        for g, _ in self._gate_info.items():
            if isinstance(g, CircuitGate):
                for other_g in g._circuit.gate_set_no_blocks:
                    gates.add(other_g)
            else:
                gates.add(g)
        return GateSet(gates)

    @property
    def gate_counts(self) -> dict[Gate, int]:
        """The count of each type of gate in the circuit."""
        return {gate: self.count(gate) for gate in self.gate_set}

    @property
    def active_qudits(self) -> list[int]:
        """The qudits involved in at least one operation."""
        active_qudits = set()
        for qudit, point in self._front.items():
            if point is not None:
                active_qudits.add(qudit)
        return list(sorted(active_qudits))

    @property
    def is_empty(self) -> bool:
        """If there are no operations in the Circuit."""
        return self.num_operations == 0

    def is_differentiable(self) -> bool:
        """Check if all gates are differentiable."""
        return all(
            isinstance(gate, DifferentiableUnitary)
            for gate in self.gate_set
        )

    # endregion

    # region Qudit Methods

    def append_qudit(self, radix: int = 2) -> None:
        """
        Append a qudit to the circuit.

        Args:
            radix (int): The radix of the qudit. (Default: qubit)

        Raises:
            ValueError: If `radix` is < 2
        """

        if not is_integer(radix):
            raise TypeError('Expected integer for radix, got: %s', type(radix))

        if radix < 2:
            raise ValueError('Expected radix to be >= 2, got %d' % radix)

        self._num_qudits += 1
        self._radixes = self.radixes + (radix,)

        for cycle in self._circuit:
            cycle.append(None)

        self._rear[self.num_qudits - 1] = None
        self._front[self.num_qudits - 1] = None

    def extend_qudits(self, radixes: Iterable[int]) -> None:
        """
        Append many qudits to the circuit.

        Args:
            radixes (Iterable[int]): The radix for each qudit to append.

        Raises:
            ValueError: If any radix in `radixes` is < 2.
        """

        for radix in radixes:
            self.append_qudit(radix)

    def insert_qudit(self, qudit_index: int, radix: int = 2) -> None:
        """
        Insert a qudit into the circuit.

        Args:
            qudit_index (int): The index where to insert the qudit.

            radix (int): The radix of the qudit. (Default: qubit)

        Raises:
            ValueError: If `radix` is < 2.
        """

        if not is_integer(qudit_index):
            raise TypeError(
                f'Expected integer for qudit_index, got: {type(qudit_index)}',
            )

        if not is_integer(radix):
            raise TypeError(f'Expected integer for radix, got: {type(radix)}')

        if radix < 2:
            raise ValueError(f'Expected radix to be >= 2, got {radix}')

        if qudit_index >= self.num_qudits:
            return self.append_qudit(radix)

        if qudit_index <= -self.num_qudits:
            qudit_index = 0
        elif qudit_index < 0:
            qudit_index = self.num_qudits + qudit_index

        # Update circuit properties
        self._num_qudits += 1
        radix_list = list(self.radixes)
        radix_list.insert(qudit_index, radix)
        self._radixes = tuple(radix_list)

        # Insert qudit
        shift_index = lambda q: q if q < qudit_index else q + 1
        inc_point = lambda p: CircuitPoint(p.cycle, p.qudit + 1)
        shift_point = lambda p: p if p.qudit < qudit_index else inc_point(p)
        shift_point_or_none = lambda p: None if p is None else shift_point(p)
        shift_edge = lambda e: (shift_index(e[0]), shift_index(e[1]))

        for cycle in self._circuit:
            cycle.insert(qudit_index, None)

            # Renumber gates with now-invalid locations
            qudits_to_skip: list[int] = []
            for i, op in enumerate(cycle[qudit_index:]):
                if op is None or i + qudit_index in qudits_to_skip:
                    continue
                shifted_location = [shift_index(i) for i in op.location]
                op._location = CircuitLocation(shifted_location)
                qudits_to_skip.extend(op.location)

        # Shift _front, _rear, _graph_info, _dag
        self._front = {
            shift_index(q): shift_point_or_none(p)
            for q, p in self._front.items()
        }
        self._front[qudit_index] = None
        self._rear = {
            shift_index(q): shift_point_or_none(p)
            for q, p in self._rear.items()
        }
        self._rear[qudit_index] = None
        self._graph_info = {
            shift_edge(e): i
            for e, i in self._graph_info.items()
        }
        self._dag = {
            shift_point(p): (
                # Prev
                {
                    shift_index(q): shift_point_or_none(p2)
                    for q, p2 in ptr_map[0].items()
                },
                # Next
                {
                    shift_index(q): shift_point_or_none(p2)
                    for q, p2 in ptr_map[1].items()
                },
            )
            for p, ptr_map in self._dag.items()
        }

    def pop_qudit(self, qudit_index: int) -> None:
        """
        Pop a qudit from the circuit and all gates attached to it.

        Args:
            qudit_index (int): The index of the qudit to pop.

        Raises:
            IndexError: If `qudit_index` is out of range.

            ValueError: If circuit only has one qudit.
        """

        if not is_integer(qudit_index):
            raise TypeError(
                f'Expected integer for qudit_index, got: {type(qudit_index)}',
            )

        if not self.is_qudit_in_range(qudit_index):
            raise IndexError(f'Qudit index ({qudit_index}) is out-of-range.')

        if self.num_qudits == 1:
            raise ValueError('Cannot pop only qudit in circuit.')

        if qudit_index < 0:
            qudit_index = self.num_qudits + qudit_index

        # Remove gates attached to popped qudit
        points = []
        for cycle_index, cycle in enumerate(self._circuit):
            if cycle[qudit_index] is not None:
                points.append((cycle_index, qudit_index))

        if len(points) > 0:
            self.batch_pop(points)

        # Update circuit properties
        self._num_qudits -= 1
        radix_list = list(self.radixes)
        radix_list.pop(qudit_index)
        self._radixes = tuple(radix_list)

        # Remove qudit
        shift_index = lambda q: q if q < qudit_index else q - 1
        dec_point = lambda p: CircuitPoint(p.cycle, p.qudit - 1)
        shift_point = lambda p: p if p.qudit < qudit_index else dec_point(p)
        shift_point_or_none = lambda p: None if p is None else shift_point(p)
        shift_edge = lambda e: (shift_index(e[0]), shift_index(e[1]))

        for cycle_index, cycle in enumerate(self._circuit):
            cycle.pop(qudit_index)

        # Renumber gates with now-invalid locations
        for cycle_index, cycle in enumerate(self._circuit):
            qudits_to_skip: list[int] = []
            for i, op in enumerate(cycle[qudit_index:]):
                if op is None or i + qudit_index in qudits_to_skip:
                    continue
                shifted_location = [shift_index(i) for i in op.location]
                op._location = CircuitLocation(shifted_location)
                qudits_to_skip.extend(op.location)

        # Shift _front, _rear, _graph_info, _dag
        self._front = {
            shift_index(q): shift_point_or_none(p)
            for q, p in self._front.items()
            if q != qudit_index
        }
        self._rear = {
            shift_index(q): shift_point_or_none(p)
            for q, p in self._rear.items()
            if q != qudit_index
        }
        self._graph_info = {
            shift_edge(e): i
            for e, i in self._graph_info.items()
        }
        self._dag = {
            shift_point(p): (
                # Prev
                {
                    shift_index(q): shift_point_or_none(p2)
                    for q, p2 in ptr_map[0].items()
                },
                # Next
                {
                    shift_index(q): shift_point_or_none(p2)
                    for q, p2 in ptr_map[1].items()
                },
            )
            for p, ptr_map in self._dag.items()
        }

    def is_qudit_in_range(self, qudit_index: int) -> bool:
        """Return true if `qudit_index` is in-range for the circuit."""

        if not is_integer(qudit_index):
            raise TypeError(
                f'Expected integer for qudit_index, got: {type(qudit_index)}',
            )

        return (
            qudit_index < self.num_qudits
            and qudit_index >= -self.num_qudits
        )

    def is_qudit_idle(self, qudit_index: int) -> bool:
        """Return true if the qudit is not involved in any operations."""

        if not is_integer(qudit_index):
            raise TypeError(
                f'Expected integer for qudit_index, got: {type(qudit_index)}',
            )

        return self._front[qudit_index] is None

    def renumber_qudits(self, qudit_permutation: Sequence[int]) -> None:
        """
        Permute the qudits in the circuit.

        Args:
            qudit_permutation (Sequence[int]): A map from qudit indices
                to qudit indices.

        Raises:
            IndexError: If any of the indices are out of range.

            ValueError: If the `qudit_permutation` is not the same size
                as the circuit.

            ValueError: If the `qudit_permutation` is not a valid permutation.
        """
        if not is_sequence_of_int(qudit_permutation):
            raise TypeError(
                'Expected sequence of integers'
                ', got %s' % type(qudit_permutation),
            )

        if len(qudit_permutation) != self.num_qudits:
            raise ValueError(
                'Expected qudit_permutation length equal to circuit num_qudits:'
                '%d, got %d' % (self.num_qudits, len(qudit_permutation)),
            )

        if len(qudit_permutation) != len(set(qudit_permutation)):
            raise ValueError('Invalid permutation.')

        perm = [int(q) for q in qudit_permutation]

        perm_point = lambda p: CircuitPoint(p.cycle, perm[p.qudit])
        perm_point_or_none = lambda p: perm_point(p) if p is not None else p

        self._graph_info = {
            (perm[e[0]], perm[e[1]]): i
            for e, i in self._graph_info.items()
        }
        self._front = {
            perm[i]: perm_point_or_none(p)
            for i, p in self._front.items()
        }
        self._rear = {
            perm[i]: perm_point_or_none(p)
            for i, p in self._rear.items()
        }
        self._dag = {
            perm_point(p): (
                # Prev
                {
                    perm[q]: perm_point_or_none(p2)
                    for q, p2 in ptr_map[0].items()
                },
                # Next
                {
                    perm[q]: perm_point_or_none(p2)
                    for q, p2 in ptr_map[1].items()
                },
            )
            for p, ptr_map in self._dag.items()
        }

        for i in range(self.num_cycles):
            self._circuit[i] = [
                self._circuit[i][perm.index(q)]
                for q in range(self.num_qudits)
            ]

            qudits_to_skip: list[int] = []
            for j in range(self.num_qudits):
                if j in qudits_to_skip:
                    continue

                op = self._circuit[i][j]
                if op is not None:
                    loc = CircuitLocation([perm[q] for q in op.location])
                    op._location = loc
                    qudits_to_skip.extend(loc)

    # endregion

    # region Cycle Methods

    def _append_cycle(self) -> None:
        """Appends an idle cycle to the end of the circuit."""
        self._circuit.append([None] * self.num_qudits)

    def _insert_cycle(self, cycle_index: int) -> None:
        """Inserts an idle cycle in the circuit."""
        self._circuit.insert(cycle_index, [None] * self.num_qudits)
        inc_point = lambda p: CircuitPoint(p.cycle + 1, p.qudit)
        shift_point = lambda p: p if p.cycle < cycle_index else inc_point(p)
        shift_point_or_none = lambda p: None if p is None else shift_point(p)
        self._front = {
            q: shift_point_or_none(p)
            for q, p in self._front.items()
        }
        self._rear = {
            q: shift_point_or_none(p)
            for q, p in self._rear.items()
        }
        self._dag = {
            shift_point(p): (
                # Prev
                {
                    q: shift_point_or_none(p2)
                    for q, p2 in prevs.items()
                },
                # Next
                {
                    q: shift_point_or_none(p2)
                    for q, p2 in nexts.items()
                },
            )
            for p, (prevs, nexts) in self._dag.items()
        }

    def pop_cycle(self, cycle_index: int) -> None:
        """
        Pop a cycle from the circuit and all operations in it.

        Args:
            cycle_index (int): The index of the cycle to pop.

        Raises:
            IndexError: If `cycle_index` is out of range.
        """

        if not is_integer(cycle_index):
            raise TypeError(
                f'Expected integer for cycle_index, got: {type(cycle_index)}',
            )

        if not self.is_cycle_in_range(cycle_index):
            raise IndexError(f'Cycle index ({cycle_index}) is out-of-range.')

        ops = {op for op in self._circuit[cycle_index] if op is not None}
        points = [(cycle_index, op.location[0]) for op in ops]
        if len(points) != 0:
            for point in points:
                self.pop(point)
            return

        self._circuit.pop(cycle_index)

        if cycle_index == -1 or cycle_index == self.num_cycles:
            return

        dec_point = lambda p: CircuitPoint(p.cycle - 1, p.qudit)
        shift_point = lambda p: p if p.cycle < cycle_index else dec_point(p)
        shift_point_or_none = lambda p: None if p is None else shift_point(p)
        self._front = {
            q: shift_point_or_none(p)
            for q, p in self._front.items()
        }
        self._rear = {
            q: shift_point_or_none(p)
            for q, p in self._rear.items()
        }
        self._dag = {
            shift_point(p): (
                # Prev
                {
                    q: shift_point_or_none(p2)
                    for q, p2 in ptr_map[0].items()
                },
                # Next
                {
                    q: shift_point_or_none(p2)
                    for q, p2 in ptr_map[1].items()
                },
            )
            for p, ptr_map in self._dag.items()
        }

    def _is_cycle_idle(self, cycle_index: int) -> bool:
        """Return true if the cycle is idle, that is it contains no gates."""
        return all(op is None for op in self._circuit[cycle_index])

    def is_cycle_in_range(self, cycle_index: int) -> bool:
        """Return true if cycle is a valid in-range index in the circuit."""

        if not is_integer(cycle_index):
            raise TypeError(
                f'Expected integer for cycle_index, got: {type(cycle_index)}',
            )

        return (
            cycle_index < self.num_cycles
            and cycle_index >= -self.num_cycles
        )

    def is_cycle_unoccupied(
        self,
        cycle_index: int,
        location: CircuitLocationLike,
    ) -> bool:
        """
        Check if a cycle is unoccupied for all qudits in `location`.

        Args:
            cycle_index (int): The cycle to check.

            location (CircuitLocationLike): The set of qudits to check.

        Returns:
            bool: True if the `cycle_index` at `location` is unoccupied.

        Raises:
            IndexError: If `cycle_index` is out of range.

        Examples:
            >>> from bqskit.ir.gates import HGate, XGate, ZGate
            >>> circuit = Circuit(2)
            >>> circuit.append_gate(HGate(), [0])
            >>> circuit.append_gate(XGate(), [0])
            >>> circuit.append_gate(ZGate(), [1])
            >>> circuit.is_cycle_unoccupied(0, [0])
            False
            >>> circuit.is_cycle_unoccupied(1, [1])
            True
        """
        if not is_integer(cycle_index):
            raise TypeError(
                'Expected integer for cycle_index, got: %s', type(cycle_index),
            )

        if not CircuitLocation.is_location(location):
            raise TypeError('Invalid location.')

        if not self.is_cycle_in_range(cycle_index):
            raise IndexError('Out-of-range cycle index: %d.' % cycle_index)

        location = CircuitLocation(location)

        if max(location) > self.num_qudits:
            raise ValueError('Location has an out-of-range qudit index.')

        for qudit_index in location:
            if self._circuit[cycle_index][qudit_index] is not None:
                return False

        return True

    def find_available_cycle(self, location: CircuitLocationLike) -> int:
        """
        Finds the first available cycle for qudits in `location`.

        An available cycle for `location` is one where it and all
        cycles after it are unoccupied for `location`.

        Args:
            localtion (CircuitLocationLike): Find a cycle for this location.

        Returns:
            int: The first available cycle.

        Raises:
            ValueError: If no available cycle exists.

        Examples:
            >>> from bqskit.ir.gates import HGate, XGate, ZGate
            >>> circuit = Circuit(2)
            >>> circuit.append_gate(HGate(), [0])
            >>> circuit.find_available_cycle([1])
            0
            >>> circuit.append_gate(XGate(), [0])
            >>> circuit.append_gate(ZGate(), [1])
            >>> circuit.find_available_cycle([1])
            1
        """

        location = CircuitLocation(location)

        if max(location) > self.num_qudits:
            raise ValueError('Location has an out-of-range qudit index.')

        # No available cycle
        if self.num_cycles == 0:
            return 0

        cycle = 0
        for q in location:
            rear = self._rear[q]
            if rear is not None:
                cycle = max(cycle, rear.cycle + 1)
        return cycle

    def _find_available_or_append_cycle(
        self,
        location: CircuitLocationLike,
    ) -> int:
        """Find the first available cycle, if none exists append one."""

        available_cycle = self.find_available_cycle(location)

        # If no available cycle
        if available_cycle == self.num_cycles:
            self._append_cycle()
            return self.num_cycles - 1

        return available_cycle

    # endregion

    # region Point Methods

    def is_point_in_range(self, point: CircuitPointLike) -> bool:
        """Return true if `point` is a valid in-range index in the circuit."""
        if not CircuitPoint.is_point(point):
            raise TypeError(f'Expected CircuitPoint, got: {type(point)}.')

        return (
            self.is_cycle_in_range(point[0])
            and self.is_qudit_in_range(point[1])
        )

    def is_point_idle(self, point: CircuitPointLike) -> bool:
        """Return true if an operation does not exist at `point`."""
        if not CircuitPoint.is_point(point):
            raise TypeError(f'Expected CircuitPoint, got {type(point)}.')

        return self._circuit[point[0]][point[1]] is None

    def normalize_point(self, point: CircuitPointLike) -> CircuitPoint:
        """Flip negative indices to positive indices."""
        if not self.is_point_in_range(point):
            raise IndexError('Out-of-range point.')

        cycle = point[0] if point[0] >= 0 else self.num_cycles + point[0]
        qudit = point[1] if point[1] >= 0 else self.num_qudits + point[1]
        return CircuitPoint(cycle, qudit)

    # endregion

    # region DAG Methods

    @property
    def front(self) -> set[CircuitPoint]:
        """The positions of operations with no dependencies."""
        front_set = set()
        for point in self._front.values():
            if point is not None and len(self.prev(point)) == 0:
                front_set.add(point)
        return front_set

    @property
    def rear(self) -> set[CircuitPoint]:
        """The positions of operations with nothing after them."""
        rear_set = set()
        for point in self._rear.values():
            if point is not None and len(self.next(point)) == 0:
                rear_set.add(point)
        return rear_set

    def last_on(self, qudit: int) -> CircuitPoint | None:
        """Report the point for the last operation on `qudit` if it exists."""
        return self._rear[qudit]

    def first_on(self, qudit: int) -> CircuitPoint | None:
        """Report the point for the first operation on `qudit` if it exists."""
        return self._front[qudit]

    def next(
        self,
        point: CircuitPointLike | CircuitRegionLike,
    ) -> set[CircuitPoint]:
        """Return the points of operations dependent on the one at `point`."""
        if CircuitRegion.is_region(point):
            points = []
            for cyc_op in self.operations_with_cycles(qudits_or_region=point):
                points.append((cyc_op[0], cyc_op[1].location[0]))

            next_points = set()
            for p in points:
                for next in self.next(p):
                    if next not in points:
                        next_points.add(next)

            return next_points

        return {
            p
            for p in self._dag[point][1].values()  # type: ignore
            if p is not None
        }

    def prev(
        self,
        point: CircuitPointLike | CircuitRegionLike,
    ) -> set[CircuitPoint]:
        """Return the points of operations the one at `point` depends on."""
        if CircuitRegion.is_region(point):
            points = []
            for cyc_op in self.operations_with_cycles(qudits_or_region=point):
                points.append((cyc_op[0], cyc_op[1].location[0]))

            prev_points = set()
            for p in points:
                for prev in self.prev(p):
                    if prev not in points:
                        prev_points.add(prev)

            return prev_points

        return {
            p
            for p in self._dag[point][0].values()  # type: ignore
            if p is not None
        }

    # endregion

    # region Operation/Gate/Circuit Methods

    def check_valid_operation(self, op: Operation) -> None:
        """Check that `op` can be applied to the circuit."""
        if not isinstance(op, Operation):
            raise TypeError('Expected Operation got %s.' % type(op))

        if not all([qudit < self.num_qudits for qudit in op.location]):
            raise ValueError('Operation location mismatch with Circuit.')

        for op_radix, circ_radix_idx in zip(op.radixes, op.location):
            if op_radix != self.radixes[circ_radix_idx]:
                raise ValueError('Operation radix mismatch with Circuit.')

    def get_operation(self, point: CircuitPointLike) -> Operation:
        """
        Retrieve the operation at the `point`.

        Args:
            point (CircuitPointLike): The circuit point position of the
                operation.

        Raises:
            IndexError: If no operation exists at the point specified.

        Returns:
            Operation: The operation at `point`.

        Examples:
            >>> from bqskit.ir.gates import HGate, CNOTGate
            >>> circuit = Circuit(2)
            >>> circuit.append_gate(HGate(), [0])
            >>> circuit.append_gate(CNOTGate(), [0, 1])
            >>> circuit.get_operation((1, 0))
            CNOTGate@(0, 1)
        """
        if not self.is_point_in_range(point):
            raise IndexError('Out-of-range or invalid point.')

        op = self._circuit[point[0]][point[1]]

        if op is None:
            raise IndexError(
                'No operation exists at the specified point: %s.' % str(point),
            )

        return op

    def point(
        self,
        op: Operation | Gate,
        start: CircuitPointLike = (0, 0),
        end: CircuitPointLike | None = None,
    ) -> CircuitPoint:
        """
        Return point of the first occurrence of `op`.

        Args:
            op (Operation | Gate): The operation or gate to find.

            start (CircuitPointLike): Circuit point to start searching
                from. Inclusive. (Default: Beginning of the circuit.)

            end (CircuitPointLike | None): Cycle index to stop searching at.
                Inclusive. (Default: End of the circuit.)

        Returns:
            CircuitPoint: The first point that contains `op`.

        Raises:
            ValueError: If `op` is not found.

            ValueError: If `op` could not have been placed on the circuit
                due to either an invalid location or gate radix mismatch.

        Examples:
            >>> from bqskit.ir.gates import HGate, XGate
            >>> circuit = Circuit(1)
            >>> opH = Operation(HGate(), [0])
            >>> circuit.append(opH)
            >>> circuit.point(opH)
            (0, 0)
            >>> opX = Operation(XGate(), [0])
            >>> circuit.append(opX)
            >>> circuit.point(opX)
            (1, 0)
        """

        if not isinstance(op, (Operation, Gate)):
            raise TypeError(f'Expected gate or operation, got {type(op)}.')

        end = end if end is not None else (-1, -1)
        start = self.normalize_point(start)
        end = self.normalize_point(end)

        if isinstance(op, Operation):
            self.check_valid_operation(op)
            if op.gate not in self._gate_info:
                raise ValueError('No such operation exists in the circuit.')

        elif isinstance(op, Gate):
            if op not in self._gate_info:
                raise ValueError('No such operation exists in the circuit.')

        if not self.is_point_in_range(start):
            raise IndexError('Out-of-range or invalid start point.')

        if not self.is_point_in_range(end):
            raise IndexError('Out-of-range or invalid end point.')

        if isinstance(op, Operation):
            qudit_index = op.location[0]
            for i, cycle in enumerate(self._circuit[start[0]:end[0] + 1]):
                if cycle[qudit_index] is not None and cycle[qudit_index] == op:
                    return CircuitPoint(start[0] + i, qudit_index)
        else:
            for i, cycle in enumerate(self._circuit[start[0]:end[0] + 1]):
                for q, _op in enumerate(cycle[start[1]:end[1] + 1]):
                    if _op is not None and _op.gate == op:
                        return CircuitPoint(start[0] + i, start[1] + q)

        raise ValueError('No such operation exists in the circuit.')

    def _append(self, op: Operation, cycle_index: int) -> None:
        """Append the operation to the circuit at the specified cycle."""
        point = CircuitPoint(cycle_index, op.location[0])

        prevs: dict[int, CircuitPoint | None] = {i: None for i in op.location}
        for qudit_index in op.location:
            # Add op to the circuit structure
            self._circuit[cycle_index][qudit_index] = op

            # Update necessary gates already in _dag to point to this one
            rear = self._rear[qudit_index]
            if rear is not None:
                prevs[qudit_index] = rear
                self._dag[rear][1][qudit_index] = point

            # Update rear pointers
            self._rear[qudit_index] = point

            # Update front pointers
            if self._front[qudit_index] is None:
                self._front[qudit_index] = point

        # Add new op to the dag
        self._dag[point] = (prevs, {i: None for i in op.location})

        # Update _graph_info
        for pair in op.location.pairs:
            if pair not in self._graph_info:
                self._graph_info[pair] = 0
            self._graph_info[pair] += 1

        # Update _gate_info
        if op.gate not in self._gate_info:
            self._gate_info[op.gate] = 0
        self._gate_info[op.gate] += 1

    def append(self, op: Operation) -> int:
        """
        Append `op` to the end of the circuit and return its cycle index.

        Args:
            op (Operation): The operation to append.

        Returns:
            int: The cycle index of the appended operation.

        Raises:
            ValueError: If `op` cannot be placed on the circuit due to
                either an invalid location or gate radix mismatch.

        Notes:
            Due to the circuit being represented as a matrix,
            `circuit.append(op)` does not imply `op` is last in simulation
            order but it implies `op` is in the last cycle of circuit.

        Examples:
            >>> from bqskit.ir.gates import HGate
            >>> circ = Circuit(1)
            >>> op = Operation(HGate(), [0])
            >>> circ.append(op) # Appends a Hadamard gate to qudit 0.
        """
        self.check_valid_operation(op)
        cycle_index = self._find_available_or_append_cycle(op.location)
        self._append(op, cycle_index)
        return cycle_index

    def append_gate(
        self,
        gate: Gate,
        location: CircuitLocationLike,
        params: RealVector = [],
    ) -> int:
        """
        Append the gate object to the circuit on the qudits in location.

        Args:
            gate (Gate): The gate to append.

            location (CircuitLocationLike): Apply the gate to these qudits.

            params (RealVector): The gate's parameters.
                (Default: all zeros)

        Returns:
            int: The cycle index of the appended gate.

        Examples:
            >>> from bqskit.ir.gates import HGate
            >>> circ = Circuit(1)
            >>> # Append a Hadamard gate to qudit 0.
            >>> circ.append_gate(HGate(), 0)

        See Also:
            :func:`append`
        """
        return self.append(Operation(gate, location, params))

    def append_circuit(
        self,
        circuit: Circuit,
        location: CircuitLocationLike,
        as_circuit_gate: bool = False,
        move: bool = False,
    ) -> int:
        """
        Append `circuit` at the qudit location specified.

        Args:
            circuit (Circuit): The circuit to append.

            location (CircuitLocationLike): Apply the circuit to these qudits.

            as_circuit_gate (bool): If true, append `circuit` as a unit
                block (CircuitGate) rather than each operation in `circuit`
                individually. (Default: False)

            move (bool): Move circuit into circuit gate rather than copy.
                (Default: False)

        Returns:
            int: The starting cycle index of the appended circuit. If the
                appended circuit is empty, then this will be -1.

        Raises:
            ValueError: If `circuit` is not the same size as `location`.

        See Also:
            :func:`append`
        """
        if not isinstance(circuit, Circuit):
            raise TypeError('Expected circuit, got %s.' % type(circuit))

        if not CircuitLocation.is_location(location):
            raise TypeError('Invalid location.')

        location = CircuitLocation(location)

        if not is_bool(as_circuit_gate):
            raise TypeError(f'Expected bool, got: {type(as_circuit_gate)}.')

        if circuit.num_qudits != len(location):
            raise ValueError('Circuit and location size mismatch.')

        if as_circuit_gate:
            op = Operation(CircuitGate(circuit, move), location, circuit.params)
            return self.append(op)

        cycle_index = -1

        for op in circuit:
            mapped_location = [location[q] for q in op.location]
            ci = self.append(Operation(op.gate, mapped_location, op.params))
            if cycle_index is None:
                cycle_index = ci

        return cycle_index

    def extend(self, ops: Iterable[Operation]) -> None:
        """
        Append all operations in `ops` to the circuit.

        Args:
            ops (Operation): The operations to append.

        Examples:
            >>> from bqskit.ir.gates import HGate, XGate
            >>> circ = Circuit(1)
            >>> opH = Operation(HGate(), [0])
            >>> opX = Operation(XGate(), [0])
            >>> circ.extend([opH, opX])
            >>> circ.point(opH)
            (0, 0)
            >>> circ.point(opX)
            (1, 0)

        Notes:
            See `append` for more info.
        """
        for op in ops:
            self.append(op)

    def insert(self, cycle_index: int, op: Operation) -> None:
        """
        Insert `op` in the circuit at the specified cycle.

        After this, if cycle was in range, you can expect:
        `all([self[cycle_index, idx] == op for idx in op.location])`

        Unless cycle_index was out-of-bounds, in which case, it will
        be appended ASAP rather than inserted.

        Args:
            cycle_index (int): The cycle to insert the operation.

            op (Operation): The operation to insert.

        Raises:
            ValueError: If `op` cannot be placed on the circuit due to
                either an invalid location or gate radix mismatch.

        Examples:
            >>> from bqskit.ir.gates import HGate, XGate
            >>> circ = Circuit(1)
            >>> opX = Operation(XGate(), [0])
            >>> opH = Operation(HGate(), [0])
            >>> circ.append(opX)
            >>> circ.insert(0, opH)
            >>> circ.point(opH).cycle
            0

        Notes:
            Clamps cycle to be in range.
        """
        self.check_valid_operation(op)

        if self.num_cycles == 0:
            self.append(op)
            return

        if not self.is_cycle_in_range(cycle_index):
            if cycle_index < -self.num_cycles:
                cycle_index = 0
            else:
                self.append(op)
                return

        if cycle_index < 0:
            cycle_index = self.num_cycles + cycle_index

        if not self.is_cycle_unoccupied(cycle_index, op.location):
            self._insert_cycle(cycle_index)

        point = CircuitPoint(cycle_index, op.location[0])

        prevs: dict[int, CircuitPoint | None] = {i: None for i in op.location}
        nexts: dict[int, CircuitPoint | None] = {i: None for i in op.location}
        for qudit_index in op.location:

            # Update front pointers if necessary
            if self._front[qudit_index] is None:
                self._front[qudit_index] = point

            # Update rear pointers if necessary
            if self._rear[qudit_index] is None:
                self._rear[qudit_index] = point

            # Search to left of cycle_index on qudit_index
            for c in reversed(range(cycle_index)):
                prev_op = self._circuit[c][qudit_index]

                # Find first op on qudit_index
                if prev_op is not None:
                    prev_point = CircuitPoint(c, prev_op.location[0])

                    # Update its next to be this
                    self._dag[prev_point][1][qudit_index] = point

                    # Add it to this prev
                    prevs[qudit_index] = prev_point

                    # Stop searching to left
                    break

            # Search to right of cycle_index on qudit_index
            for c in range(cycle_index, self.num_cycles):
                next_op = self._circuit[c][qudit_index]

                # Find first op on qudit_index
                if next_op is not None:
                    next_point = CircuitPoint(c, next_op.location[0])

                    # Update its prev to be this
                    self._dag[next_point][0][qudit_index] = point

                    # Add it to this next
                    nexts[qudit_index] = next_point

                    # Stop searching to right
                    break

        # Update _front if this is new front
        for qudit_index, _prev_point in prevs.items():
            if _prev_point is None:
                self._front[qudit_index] = point

        # Update _rear if this is new rear
        for qudit_index, _next_point in nexts.items():
            if _next_point is None:
                self._rear[qudit_index] = point

        # Add this to _dag
        self._dag[point] = (prevs, nexts)

        # Add op to the circuit structure
        for qudit_index in op.location:
            self._circuit[cycle_index][qudit_index] = op

        # Update _graph_info
        for pair in op.location.pairs:
            if pair not in self._graph_info:
                self._graph_info[pair] = 0
            self._graph_info[pair] += 1

        # Update _gate_info
        if op.gate not in self._gate_info:
            self._gate_info[op.gate] = 0
        self._gate_info[op.gate] += 1

    def insert_gate(
        self,
        cycle_index: int,
        gate: Gate,
        location: CircuitLocationLike,
        params: RealVector = [],
    ) -> None:
        """
        Insert the gate object in the circuit on the qudits in location.

        After this, if cycle was in range, you can expect:
        `all([self[cycle_index, idx].gate == gate for idx in location])`

        Args:
            cycle_index (int): The cycle to insert the gate.

            gate (Gate): The gate to insert.

            location (CircuitLocationLike): Apply the gate to this set of
                qudits.

            params (RealVector): The gate's parameters.

        Raises:
            IndexError: If the specified cycle doesn't exist.

            ValueError: If `gate` cannot be placed on the circuit due to
                either an invalid location or gate radix mismatch.

        See Also:
            :func:`insert`
        """
        self.insert(cycle_index, Operation(gate, location, params))

    def insert_circuit(
        self,
        cycle_index: int,
        circuit: Circuit,
        location: CircuitLocationLike,
        as_circuit_gate: bool = False,
        move: bool = False,
    ) -> None:
        """
        Insert `circuit` at the cycle and location specified.

        Args:
            cycle_index (int): The cycle to insert the circuit.

            circuit (Circuit): The circuit to insert.

            location (CircuitLocationLike): Apply the circuit to these
                qudits.

            as_circuit_gate (bool): If true, append `circuit` as a unit
                block (CircuitGate) rather than each operation in `circuit`
                individually. (Default: False)

            move (bool): Move circuit into circuit gate rather than copy.
                (Default: False)

        Raises:
            ValueError: If `circuit` is not the same size as `location`.

        See Also:
            :func:`insert`
        """

        if not is_integer(cycle_index):
            raise TypeError(
                f'Expected integer cycle index, got: {cycle_index}.',
            )

        if not is_bool(as_circuit_gate):
            raise TypeError(f'Expected bool, got: {type(as_circuit_gate)}.')

        if not CircuitLocation.is_location(location):
            raise TypeError('Invalid location.')

        location = CircuitLocation(location)

        if circuit.num_qudits != len(location):
            raise ValueError('Circuit and location size mismatch.')

        if as_circuit_gate:
            op = Operation(CircuitGate(circuit, move), location, circuit.params)
            self.insert(cycle_index, op)
            return

        for op in reversed(circuit):
            mapped_location = [location[q] for q in op.location]
            self.insert(
                cycle_index,
                Operation(op.gate, mapped_location, op.params),
            )

    def remove(self, op: Operation | Gate) -> None:
        """
        Removes the first occurrence of `op` in the circuit.

        Args:
            op (Operation | Gate): The Operation or Gate to remove.

        Raises:
            ValueError: If the `op` doesn't exist in the circuit.

            ValueError: If `op` could not have been placed on the circuit
                due to either an invalid location or gate radix mismatch.

        Examples:
            >>> from bqskit.ir.gates import HGate
            >>> circ = Circuit(1)
            >>> op = Operation(HGate(), [0])
            >>> circ.append(op)
            >>> circ.num_operations
            1
            >>> circ.remove(op)
            >>> circ.num_operations
            0

        See Also:
            :func:`remove_all`
        """
        self.pop(self.point(op))

    def remove_all(self, op: Operation | Gate) -> None:
        """
        Removes the all occurrences of `op` in the circuit.

        Args:
            op (Operation | Gate): The Operation or Gate to remove.

        Raises:
            ValueError: If the `op` doesn't exist in the circuit.

            ValueError: If `op` could not have been placed on the circuit
                due to either an invalid location or gate radix mismatch.

        See Also:
            :func:`remove`
        """
        while True:
            try:
                self.pop(self.point(op))
            except (ValueError, IndexError):
                break

    def count(self, op: Operation | Gate) -> int:
        """
        Count the number of times `op` occurs in the circuit.

        Args:
            op (Operation | Gate): The operation or gate to count.

        Raises:
            ValueError: If `op` could not have been placed on the circuit
                due to either an invalid location or gate radix mismatch.

        Examples:
            >>> from bqskit.ir.gates import HGate
            >>> circ = Circuit(1)
            >>> op = Operation(HGate(), [0])
            >>> circ.append(op)
            >>> circ.count(op)
            1
            >>> circ.append(op)
            >>> circ.count(op)
            2
        """

        count = 0
        if isinstance(op, Operation):
            self.check_valid_operation(op)
            if op.gate not in self._gate_info:
                return 0

            qudit_index = op.location[0]
            for _op in self.operations(qudits_or_region=[qudit_index]):
                if _op == op:
                    count += 1
        elif isinstance(op, Gate):
            if op not in self._gate_info:
                return 0
            return self._gate_info[op]
        else:
            raise TypeError('Expected gate or operation, got %s.' % type(op))

        return count

    def pop(self, point: CircuitPointLike | None = None) -> Operation:
        """
        Pop the operation at `point`, defaults to last operation.

        Args:
            point (CircuitPointLike | None): The cycle and qudit index
                to pop from.

        Returns:
            Operation: The popped operation is returned.

        Raises:
            IndexError: If the `point` is out-of-range, or if no operation
                exists at `point`.

        Examples:
            >>> from bqskit.ir.gates import HGate
            >>> circ = Circuit(1)
            >>> circ.append_gate(HGate(), [0])
            >>> circ.num_operations
            1
            >>> circ.pop((0, 0))
            HGate@(0,)
            >>> circ.num_operations
            0
        """

        # Use given point
        if point is not None:
            if not self.is_point_in_range(point):
                raise IndexError('Out-of-range point: %s.' % str(point))

        # Or find last gate in simulation order
        else:
            cycle_index = self.num_cycles - 1
            for i, op in enumerate(reversed(self._circuit[cycle_index])):
                if op is not None:
                    point = (cycle_index, self.num_qudits - 1 - i)
                    break

        if point is None:
            raise IndexError('Pop from empty circuit.')

        op = self[point]
        point = self.normalize_point(point)
        point = CircuitPoint(point[0], op.location[0])

        # Remove it from _dag
        prevs, nexts = self._dag[point]
        for q in op.location:
            prev = prevs[q]
            if prev is not None:
                self._dag[prev][1][q] = nexts[q]

            next = nexts[q]
            if next is not None:
                self._dag[next][0][q] = prevs[q]

            if self._front[q] == point:
                self._front[q] = nexts[q]

            if self._rear[q] == point:
                self._rear[q] = prevs[q]
        self._dag.pop(point)

        # Remove it from _circuit
        for qudit_index in op.location:
            self._circuit[point[0]][qudit_index] = None

        if self._is_cycle_idle(point.cycle):
            self.pop_cycle(point.cycle)

        # Update gate and graph counts
        self._gate_info[op.gate] -= 1
        if self._gate_info[op.gate] <= 0:
            self._gate_info.pop(op.gate)

        for pair in op.location.pairs:
            self._graph_info[pair] -= 1
            if self._graph_info[pair] <= 0:
                self._graph_info.pop(pair)

        return op

    def batch_pop(self, points: Iterable[CircuitPointLike]) -> Circuit:
        """
        Pop all operatons at `points` at once.

        Args:
            points (Iterable[CircuitPointLike]): Remove operations
                at these points all at the same time.

        Returns:
            Circuit: The circuit formed from all the popped operations.

        Raises:
            IndexError: If any of `points` are out-of-range.

            IndexError: If all of `points` are invalid.
        """
        if not all(self.is_point_in_range(point) for point in points):
            raise IndexError('Out-of-range point.')

        # Sort points
        points = [self.normalize_point(point) for point in points]
        points = [p for p in points if not self.is_point_idle(p)]
        ops_and_cycles = {(self[point], point[0]) for point in points}
        ops_and_cycles = sorted(list(ops_and_cycles), key=lambda x: x[1])

        if len(points) == 0:
            raise IndexError('Must batch pop at least one point.')

        # Pop from circuit
        for op, cycle in reversed(ops_and_cycles):
            self.pop((cycle, op.location[0]))

        # Form new circuit and return
        ops = [op for op, _ in ops_and_cycles]
        qudits = list(set(sum((tuple(op.location) for op in ops), ())))
        qudits = sorted(qudits)
        radixes = [self.radixes[q] for q in qudits]
        circuit = Circuit(len(radixes), radixes)
        for op in ops:
            location = [qudits.index(q) for q in op.location]
            circuit.append(Operation(op.gate, location, op.params))
        return circuit

    def replace(self, point: CircuitPointLike, op: Operation) -> None:
        """
        Replace the operation at `point` with `op`.

        Args:
            point (CircuitPointLike): The index of the operation to
                replace.

            op (Operation): The to-be-inserted operation.

        Raises:
            IndexError: If there is no operation at `point`.

            ValueError: If `op` cannot be placed on the circuit due to
                either an invalid location or gate radix mismatch.

            ValueError: If `point.qudit` is not in `op.location`
        """
        if len(self[point].location.intersection(op.location)) == 0:
            raise ValueError("Point's qudit is not in operation's location.")

        old_op = self._circuit[point[0]][point[1]]
        if old_op is not None and set(old_op.location) == set(op.location):
            if old_op.location[0] != op.location[0]:
                old_point = CircuitPoint(point[0], old_op.location[0])
                new_point = CircuitPoint(point[0], op.location[0])
                prevs, nexts = self._dag[old_point]
                for q in old_op.location:
                    if self._front[q] == old_point:
                        self._front[q] = new_point
                    if self._rear[q] == old_point:
                        self._rear[q] = new_point
                    prev = prevs[q]
                    if prev is not None:
                        self._dag[prev][1][q] = new_point
                    next = nexts[q]
                    if next is not None:
                        self._dag[next][0][q] = new_point
                self._dag[new_point] = self._dag[old_point]
                self._dag.pop(old_point)

            self._gate_info[old_op.gate] -= 1
            if self._gate_info[old_op.gate] <= 0:
                self._gate_info.pop(old_op.gate)

            if op.gate not in self._gate_info:
                self._gate_info[op.gate] = 0
            self._gate_info[op.gate] += 1

            for q in old_op.location:
                self._circuit[point[0]][q] = op

            return

        self.pop(point)
        self.insert(point[0], op)

    def batch_replace(
        self,
        points: Sequence[CircuitPointLike],
        ops: Sequence[Operation],
    ) -> None:
        """
        Replace the operations at `points` with `ops`.

        Args:
            points (Sequence[CircuitPointLike]): The indices of the
                operations to replace.

            ops (Sequence[Operation]): The to-be-inserted operations.

        Raises:
            IndexError: If there is no operation at some point.

            ValueError: If any op cannot be placed on the circuit due to
                either an invalid location or gate radix mismatch.

            ValueError: If any `point.qudit` is not in `op.location`

            ValueError: If `points` and `ops` have different lengths.
        """
        if len(points) != len(ops):
            raise ValueError('Points and Ops have different lengths.')

        points_and_ops = sorted(zip(points, ops), key=lambda x: x[0][0])
        num_cycles = self.num_cycles

        for point, op in points_and_ops:
            shrink_amount = num_cycles - self.num_cycles
            shifted_point = (point[0] - shrink_amount, point[1])
            self.replace(shifted_point, op)

    def replace_gate(
        self,
        point: CircuitPointLike,
        gate: Gate,
        location: CircuitLocationLike,
        params: RealVector = [],
    ) -> None:
        """Replace the operation at 'point' with `gate`."""
        self.replace(point, Operation(gate, location, params))

    def replace_with_circuit(
        self,
        point: CircuitPointLike,
        circuit: Circuit,
        as_circuit_gate: bool = False,
        move: bool = False,
    ) -> None:
        """Replace the operation at 'point' with `circuit`."""
        op = self.pop(point)

        if circuit.num_qudits != op.num_qudits:
            raise ValueError('Cannot replace operation with circuit.')

        if circuit.radixes != tuple(self.radixes[x] for x in op.location):
            raise ValueError('Cannot replace operation with circuit.')

        self.insert_circuit(
            point[0],
            circuit,
            op.location,
            as_circuit_gate,
            move,
        )

    def copy(self) -> Circuit:
        """Return a deep copy of this circuit."""
        circuit = Circuit(self.num_qudits, self.radixes)
        circuit._circuit = copy.deepcopy(self._circuit)
        circuit._gate_info = copy.deepcopy(self._gate_info)
        circuit._graph_info = copy.deepcopy(self._graph_info)
        circuit._front = copy.deepcopy(self._front)
        circuit._rear = copy.deepcopy(self._rear)
        circuit._dag = copy.deepcopy(self._dag)
        return circuit

    def become(self, circuit: Circuit, deepcopy: bool = True) -> None:
        """Become a copy of `circuit`."""
        if deepcopy:
            self._num_qudits = circuit.num_qudits
            self._radixes = circuit.radixes
            self._circuit = copy.deepcopy(circuit._circuit)
            self._gate_info = copy.deepcopy(circuit._gate_info)
            self._graph_info = copy.deepcopy(circuit._graph_info)
            self._front = copy.deepcopy(circuit._front)
            self._rear = copy.deepcopy(circuit._rear)
            self._dag = copy.deepcopy(circuit._dag)
        else:
            self._num_qudits = circuit.num_qudits
            self._radixes = circuit.radixes
            self._circuit = copy.copy(circuit._circuit)
            self._gate_info = copy.copy(circuit._gate_info)
            self._graph_info = copy.copy(circuit._graph_info)
            self._front = copy.copy(circuit._front)
            self._rear = copy.copy(circuit._rear)
            self._dag = copy.copy(circuit._dag)

    def clear(self) -> None:
        """Clear the circuit."""
        self._circuit = []
        self._gate_info = {}
        self._graph_info = {}
        self._front = {i: None for i in range(self.num_qudits)}
        self._rear = {i: None for i in range(self.num_qudits)}
        self._dag = {}

    def compress(self) -> None:
        """Compress the circuit's cycles."""
        compressed_circuit = Circuit(self.num_qudits, self.radixes)
        for op in self:
            compressed_circuit.append(op)
        self.become(compressed_circuit)

    # endregion

    # region Region Methods

    def is_valid_region(
        self,
        region: CircuitRegionLike,
        strict: bool = False,
    ) -> bool:
        """Return true if `region` is valid in the context of this circuit."""
        try:
            self.check_region(region, strict)
        except ValueError:
            return False
        return True

    def check_region(
        self,
        region: CircuitRegionLike,
        strict: bool = False,
    ) -> None:
        """
        Check `region` to be a valid in the context of this circuit.

        A CircuitRegion is valid if it is within the bounds of the circuit
        and for every pair of operations in the region, there is no path
        between them that exits the region.

        Args:
            region (CircuitRegionLike): The region to check.

            strict (bool): If True, fail on any disconnect, even if there
                are no gates in the disconnect. (Default: False)

        Raises:
            ValueError: If `region` includes qudits not in the circuit,
                or if the region is too large for the circuit.

            ValueError: If the region cannot be folded due to a possible
                change to simulation order. This can happen when the
                region is disconnected between qudits and outside gates
                occur in the disconnect.
        """
        if not CircuitRegion.is_region(region):
            raise TypeError(f'Expected a CircuitRegion, got: {type(region)}.')

        region = CircuitRegion(region)

        if not CircuitLocation.is_location(region.location, self.num_qudits):
            raise ValueError('Region circuit location mismatch.')

        if region.max_cycle >= self.num_cycles:
            raise ValueError(
                'Region goes off circuit; '
                f'circuit only has {self.num_cycles} cycles, '
                f"but region's maximum cycle is {region.max_cycle}.",
            )

        if strict:
            for qudit_index, cycle_intervals in region.items():
                for other_qudit_index, other_cycle_intervals in region.items():
                    if not cycle_intervals.overlaps(other_cycle_intervals):
                        raise ValueError('Disconnect detected in region.')

        cycles_ops = self.operations_with_cycles(
            qudits_or_region=region, exclude=True,
        )
        points = [(cop[0], cop[1].location[0]) for cop in cycles_ops]
        known_to_never_reenter = set()

        # Walk back from max cycle
        for pt in sorted(points, key=lambda x: x[0], reverse=True):

            # Max cycle is valid in base case
            if pt[0] == region.max_cycle:
                continue

            frontier = self.next(pt)
            while frontier:
                pt2 = frontier.pop()

                # Walk only paths that exit the region
                if pt2 in points:
                    continue

                # Stop walking after the max cycle
                if pt2[0] >= region.max_cycle:
                    continue

                # Skip this point if previously determined it to be good
                if pt2 in known_to_never_reenter:
                    continue

                expansion = self.next(pt2)

                # If there is a path that re-enters the region, fail
                if any(p in points for p in expansion):
                    raise ValueError('Disconnect detected in region.')

                frontier.update(expansion)
                known_to_never_reenter.add(pt2)

    def straighten(
        self,
        region: CircuitRegionLike,
    ) -> tuple[CircuitRegion, int, CircuitRegion]:
        """
        Push gates back so the region has a single starting cycle.

        Args:
            region (CircuitRegionLike): The region to straighten.

        Returns:
            (tuple[CircuitRegion, int, CircuitRegion]):
                Statistics on how gates where moved to straighten this
                region. The first return value is the straightened region.
                The second integer return value is the net number of
                new cycles inserted at `region.min_cycle`. The last
                return value is the shadow region, or the portion of the
                middle region which did not move. See the Notes for
                more info.

        Raises:
            ValueError: If the region is invalid, see `circuit.check_region`.

        Notes:
            When isolating a region, the circuit is divided into three
            parts by `region.min_cycle` and `region.max_min_cycle`. The
            left region is left unchanged and the right region
            potentially moves to the right. Gates in the middle can
            either be moved to the right or left unchanged depending
            on their location. The last return value describes the
            cycle-qudits that did not move from the middle portion.
        """
        if len(region) == 0:
            return CircuitRegion({}), 0, CircuitRegion({})

        self.check_region(region)
        region = self.downsize_region(region)

        # Add idle cycles to create space
        shadow_length = region.max_min_cycle - region.min_cycle
        shadow_start = region.min_cycle
        for i in range(shadow_length):
            self._insert_cycle(shadow_start)
        region = region.shift_right(shadow_length)

        # Track region shadow and move gates
        idle_cycles: list[int] = []
        shadow_qudits: set[int] = set(region.keys())
        # shadow_map = {qudit: region[qudit].lower for qudit in shadow_qudits}
        shadow_map = {
            qudit: min(
                region.min_cycle - 1,
                region[qudit].upper - shadow_length,
            )
            for qudit in shadow_qudits
        }

        for i in range(shadow_length):
            old_cycle_index = region.max_min_cycle - 1 - i
            new_cycle_index = region.min_cycle - 1 - i
            gate_moved = False
            qudits_to_add_to_shadow: list[int] = []

            for qudit_index in shadow_qudits:
                if (
                    qudit_index not in region
                    or old_cycle_index < region[qudit_index][0]
                ):
                    op = self._circuit[old_cycle_index][qudit_index]
                    if op is not None:
                        gate_moved = True
                        s_point = CircuitPoint(old_cycle_index, op.location[0])
                        d_point = CircuitPoint(new_cycle_index, op.location[0])

                        prevs, nexts = self._dag[s_point]
                        for qudit in op.location:
                            self._circuit[new_cycle_index][qudit] = op
                            self._circuit[old_cycle_index][qudit] = None
                            prev = prevs[qudit]
                            if prev is not None:
                                self._dag[prev][1][qudit] = d_point
                            next = nexts[qudit]
                            if next is not None:
                                self._dag[next][0][qudit] = d_point
                            if self._front[qudit] == s_point:
                                self._front[qudit] = d_point
                            if self._rear[qudit] == s_point:
                                self._rear[qudit] = d_point
                        self._dag.pop(s_point)
                        self._dag[d_point] = (prevs, nexts)
                        qudits_to_add_to_shadow.extend(op.location)

            shadow_qudits.update(qudits_to_add_to_shadow)
            for qudit in shadow_qudits:
                if qudit not in shadow_map:
                    shadow_map[qudit] = new_cycle_index

            if not gate_moved:
                idle_cycles.append(new_cycle_index)

        for i, cycle_index in enumerate(sorted(idle_cycles)):
            self.pop_cycle(cycle_index - i)

        region = region.shift_left(len(idle_cycles))

        # Prep output
        region = CircuitRegion({
            qudit_index: (region.min_cycle, region[qudit_index][1])
            for qudit_index in region
        })
        net_new_cycles = shadow_length - len(idle_cycles)
        shadow_region = CircuitRegion({
            qudit_index: (shadow_start, shadow_map[qudit_index])
            for qudit_index in shadow_qudits
            if shadow_start <= shadow_map[qudit_index]
        })
        return region, net_new_cycles, shadow_region

    def fold(self, region: CircuitRegionLike) -> CircuitPoint:
        """
        Fold the specified `region` into a CircuitGate.

        Args:
            region (CircuitRegionLike): The region to fold into a
                CircuitGate.

        Returns:
            (CircuitPoint): The resulting CircuitGate's location.

        Raises:
            ValueError: If `region` is invalid or cannot be straightened.
        """
        if len(region) == 0:
            raise ValueError('Empty region cannot be folded.')

        region = self.straighten(region)[0]
        circuit = self.batch_pop(region.points)

        # Insert popped circuit as a CircuitGate
        self.insert_circuit(
            region.min_cycle,
            circuit,
            sorted(list(region.keys())),
            True,
        )

        return CircuitPoint(region.min_cycle, region.min_qudit)

    def unfold(self, point: CircuitPointLike) -> None:
        """Unfold the CircuitGate at `point` into the circuit."""
        if not isinstance(self[point].gate, CircuitGate):
            raise ValueError('Expected to unfold a CircuitGate.')

        op = self[point]
        circuit: Circuit = op.gate._circuit  # type: ignore
        circuit.set_params(op.params)
        self.replace_with_circuit(point, circuit)

    def batch_unfold(self, points: Sequence[CircuitPointLike]) -> None:
        """Unfold the CircuitGates at `points` into the circuit."""
        points = {(point[0], self[point].location[0]) for point in points}
        for point in reversed(sorted(points)):
            self.unfold(point)

    def unfold_all(self) -> None:
        """Unfold all CircuitGates in the circuit."""
        while any(isinstance(gate, CircuitGate) for gate in self.gate_set):
            unfolded_circuit = Circuit(self.num_qudits, self.radixes)
            for op in self:
                if isinstance(op.gate, CircuitGate):
                    circuit = op.gate._circuit
                    circuit.set_params(op.params)
                    unfolded_circuit.append_circuit(circuit, op.location)
                else:
                    unfolded_circuit.append(op)
            self.become(unfolded_circuit)

    def surround(
        self,
        point: CircuitPointLike | CircuitRegionLike,
        num_qudits: int,
        bounding_region: CircuitRegionLike | None = None,
        fail_quickly: bool | None = None,
        filter: Callable[[CircuitRegion], bool] | None = None,
        scoring_fn: Callable[[CircuitRegion], float] | None = None,
    ) -> CircuitRegion:
        """
        Retrieve the maximal connected region in this circuit with `point`.

        Args:
            point (CircuitPointLike | CircuitRegionLike): Find a surrounding
                region for this point (or region). This point (or region)
                will be in the final CircuitRegion.

            num_qudits (int): The maximum number of qudits to include in
                the surrounding region.

            bounding_region (CircuitRegionLike | None): An optional
                region that bounds the resulting region.

            fail_quickly (bool | None): If set to true, will not branch on
                an invalid region. This will lead to a much faster
                result in some cases at the cost of only approximating
                the maximal region. (Deprecated, does nothing now besides
                print a warning if a bool.)

            filter (Callable[[CircuitRegion], bool] | None): The filter
                function determines if a candidate region is valid in the
                caller's context. This is used to prune the search space
                of the surround function. If None, then no filtering is
                done. It takes a CircuitRegion and returns a boolean.
                Only regions that pass the filter are considered.

            scoring_fn (Callable[[CircuitRegion], float] | None): The
                scoring function determines the "best" surrounding region.
                If left as None, then this will default to the region with
                the most number of gates with larger gates worth more.
                It takes a CircuitRegion and returns a float. Larger scores
                are better.

        Raises:
            IndexError: If `point` is not a valid index.

            ValueError: If `num_qudits` is nonpositive.

            ValueError: If the operation at `point` is too large for
                `num_qudits`.

            ValueError: If `bounding_region` is invalid.

            ValueError: If the initial node does not pass the filter.

        Notes:
            This algorithm explores outward horizontally as much as possible.
            When a gate is encountered that involves another qudit not
            currently in the region, a decision needs to be made on whether
            that gate will be included or not. These decisions form a tree;
            an exhaustive search is employed to find the maximal region
            from this decision tree.
        """

        if not is_integer(num_qudits):
            raise TypeError(
                f'Expected an integer num_qudits, got {type(num_qudits)}.',
            )

        if num_qudits <= 0:
            raise ValueError(
                f'Expected a positive integer num_qudits, got {num_qudits}.',
            )

        if filter is not None and not callable(filter):
            raise TypeError(f'Expected callable filter, got {type(filter)}.')

        def default_scoring_fn(region: CircuitRegion) -> float:
            return float(sum(op.num_qudits * 100 for op in self[region]))

        if scoring_fn is None:
            scoring_fn = default_scoring_fn

        if not callable(scoring_fn):
            raise TypeError(
                f'Expected callable scoring_fn, got {type(scoring_fn)}.',
            )

        if fail_quickly is not None:
            warnings.warn(
                'The fail_quickly argument is deprecated and does nothing. '
                'Surround will always attempt to find the maximal region. '
                'This argument will be removed in a future release and this '
                'warning will become an error.',
                DeprecationWarning,
            )

        if bounding_region is not None:
            bounding_region = CircuitRegion(bounding_region)

        if CircuitPoint.is_point(point):
            if self.is_point_idle(point):
                init_region = CircuitRegion({point[1]: (point[0], point[0])})
            else:
                init_region = self.get_region([point])
        elif CircuitRegion.is_region(point):
            init_region = CircuitRegion(point)
        else:
            raise TypeError(
                f'Expected CircuitPoint or CircuitRegion, got {type(point)}.',
            )

        if init_region.num_qudits > num_qudits:
            raise ValueError('Initial region is too large for num_qudits.')

        if filter is not None and not filter(init_region):
            raise ValueError('Initial region does not pass filter.')

        # Initialize Search
        frontier: list[CircuitRegion] = [init_region]
        seen: set[CircuitRegion] = set()

        # Track best so far
        best_score = (scoring_fn(init_region), init_region.num_qudits)
        best_region = init_region

        # Exhaustive Search
        while len(frontier) > 0:
            node = frontier.pop(0)

            # Evaluate node
            node_score = (scoring_fn(node), node.num_qudits)
            if node_score > best_score:
                best_region = node
                best_score = node_score

            # Expand node
            for point in self.next(node).union(self.prev(node)):
                # Create new region by adding the gate at this point
                region_bldr = {k: v for k, v in node.items()}
                op = self[point]
                valid_region = True
                need_to_fully_check = False
                for qudit in op.location:
                    if qudit not in region_bldr:
                        region_bldr[qudit] = CycleInterval(point[0], point[0])
                        need_to_fully_check = True

                    elif point[0] < region_bldr[qudit][0]:
                        # Check for gates in the middle not in region
                        if any(
                            not self.is_point_idle((i, qudit))
                            for i in range(point[0] + 1, region_bldr[qudit][0])
                        ):
                            valid_region = False
                            break

                        # Absorb Single-qudit gates
                        index = point[0]
                        while index > 0:
                            if not self.is_point_idle((index - 1, qudit)):
                                prev_op = self[index - 1, qudit]
                                if len(prev_op.location) != 1:
                                    break
                            index -= 1

                        region_bldr[qudit] = CycleInterval(
                            index,
                            region_bldr[qudit][1],
                        )

                    elif point[0] > region_bldr[qudit][1]:
                        # Check for gates in the middle not in region
                        if any(
                            not self.is_point_idle((i, qudit))
                            for i in range(region_bldr[qudit][1] + 1, point[0])
                        ):
                            valid_region = False
                            break

                        # Absorb Single-qudit gates
                        index = point[0]
                        while index < self.num_cycles - 1:
                            if not self.is_point_idle((index + 1, qudit)):
                                next_op = self[index + 1, qudit]
                                if len(next_op.location) != 1:
                                    break
                            index += 1

                        region_bldr[qudit] = CycleInterval(
                            region_bldr[qudit][0],
                            index,
                        )

                # Discard too large regions
                if len(region_bldr) > num_qudits:
                    continue

                # Discard invalid regions
                if not valid_region:
                    continue

                if need_to_fully_check:
                    if not self.is_valid_region(region_bldr):
                        continue

                new_region = CircuitRegion(region_bldr)

                # Check uniqueness
                if new_region in seen:
                    continue

                # Check filter
                if filter is not None and not filter(new_region):
                    continue

                # Expand frontier
                frontier.append(new_region)
                seen.add(new_region)

        return best_region

    def get_region(self, points: Iterable[CircuitPointLike]) -> CircuitRegion:
        """
        Calculate the minimal region from a sequence of points.

        Args:
            points (Iterable[CircuitPointLike]): The positions of operations
                to group together.

        Returns:
            (CircuitRegion): The region given by `points`.

        Raises:
            ValueError: If `points` do not form a valid convex region
                in the circuit. This happens when there exists an operation
                within the bounds of `points`, but not contained in it.

            IndexError: If any of `points` are out-of-range.
        """

        if not is_iterable(points):
            raise TypeError(
                'Expected iterable of points, got %s.' % type(points),
            )

        points = list(points)

        if not all(CircuitPoint.is_point(point) for point in points):
            checks = [CircuitPoint.is_point(point) for point in points]
            raise TypeError(
                'Expected iterable of points'
                f', got {points[checks.index(False)]} for at least one point.',
            )

        if len(points) == 0:
            return CircuitRegion({})

        # Collect operations
        ops_and_cycles = list({
            (point[0], self[point])
            for point in points
            if not self.is_point_idle(point)
        })

        # Calculate the bounding region to be folded
        # The region is represented by the max and min cycle for each qudit.
        region: dict[int, tuple[int, int]] = {}

        for point in points:
            # Flip negative indices
            cycle_index = point[0]
            if cycle_index < 0:
                cycle_index = self.num_cycles + cycle_index

            if self.is_point_idle(point):
                continue

            for qudit_index in self[point].location:
                if qudit_index not in region:
                    region[qudit_index] = (self.num_cycles, -1)

                if cycle_index < region[qudit_index][0]:
                    region[qudit_index] = (cycle_index, region[qudit_index][1])

                if cycle_index > region[qudit_index][1]:
                    region[qudit_index] = (region[qudit_index][0], cycle_index)

        # Count operations within region
        ops_in_region = set()
        for qudit_index, bounds in region.items():
            for i, cycle in enumerate(self._circuit[bounds[0]:bounds[1] + 1]):
                op = cycle[qudit_index]
                if op is not None:
                    ops_in_region.add((bounds[0] + i, op))

        # All operations in the region must be getting folded
        if len(ops_and_cycles) != len(ops_in_region):
            raise ValueError(
                'Operations cannot be grouped in a region due to'
                ' another operation in the middle.',
            )

        self.check_region(region)

        return CircuitRegion(region)

    def downsize_region(self, region: CircuitRegionLike) -> CircuitRegion:
        """Remove all idle qudits-cycles in `region` while keeping it valid."""
        return self.get_region(CircuitRegion(region).points)

    def get_operations(
            self,
            points: Iterable[CircuitPointLike],
    ) -> list[Operation]:
        """Retrieve operations from `points` without throwing IndexError."""
        if not is_iterable(points):
            raise TypeError(
                f'Expected a circuit point iterable, got {type(points)}.',
            )

        if not all(CircuitPoint.is_point(point) for point in points):
            raise TypeError(
                f'Expected a circuit point iterable, got {type(points)}.',
            )

        ops: list[tuple[int, Operation]] = list()
        for point in points:
            try:
                ops.append((point[0], self.get_operation(point)))
            except IndexError:
                continue

        return [op_and_cycle[1] for op_and_cycle in list(dict.fromkeys(ops))]

    def get_slice(self, points: Sequence[CircuitPointLike]) -> Circuit:
        """Return a copy of a slice of this circuit."""
        # Sort points
        points = sorted(points)

        # Collect operations avoiding duplicates
        ops_and_cycles: list[tuple[Operation, int]] = list({
            (self[point], point[0])
            for point in points
            if not self.is_point_idle(point)
        })

        if len(ops_and_cycles) == 0:
            raise IndexError('No operations exists at any of the points.')

        ops_and_cycles.sort(key=lambda x: (x[1], *x[0].location))
        ops = [op for op, _ in ops_and_cycles]

        if len(ops_and_cycles) == 0:
            raise IndexError('No operations exists at any of the points.')

        # Form new circuit and return
        qudits = list(set(sum((tuple(op.location) for op in ops), ())))
        qudits = sorted(qudits)
        radixes = [self.radixes[q] for q in qudits]
        circuit = Circuit(len(radixes), radixes)
        for op in ops:
            location = [qudits.index(q) for q in op.location]
            circuit.append(Operation(op.gate, location, op.params))
        return circuit

    # endregion

    # region Parameter Methods

    def get_param(self, param_index: int) -> float:
        """Return the parameter at param_index."""
        cycle, qudit, param = self.get_param_location(param_index)
        return self[cycle, qudit].params[param]

    def set_param(self, param_index: int, value: float) -> None:
        """Set a circuit parameter."""
        cycle, qudit, param = self.get_param_location(param_index)
        self[cycle, qudit].params[param] = value

    def set_params(self, params: RealVector) -> None:
        """Set all parameters at once."""
        self.check_parameters(params)
        param_index = 0
        for op in self:
            op.params = list(
                params[param_index: param_index + op.num_params],
            )
            param_index += op.num_params

    def freeze_param(self, param_index: int) -> None:
        """Freeze a circuit parameter to its current value."""
        cycle, qudit, param = self.get_param_location(param_index)
        op = self[cycle, qudit]
        gate = op.gate.with_frozen_params({param: op.params[param]})
        params = op.params.copy()
        params.pop(param)
        self.replace_gate((cycle, qudit), gate, op.location, params)

    def get_param_location(self, param_index: int) -> tuple[int, int, int]:
        """
        Converts a param_index to a cycle, qudit, and operation-param index.

        Args:
            param_index (int): The parameter index to convert.

        Returns:
            (tuple[int, int, int]): A tuple of cycle_index, qudit_index,
                and operation-param index. The operation the parameter
                belongs to will be at circuit[cycle_index, qudit_index].
                This parameter in that operation is indexed by
                the operation-param index.

        Raises:
            IndexError: If the param_index is invalid.

        Examples:
            >>> from bqskit.ir.gates import U3Gate
            >>> circ = Circuit(1)
            >>> circ.append_gate(U3Gate(), [0])
            >>> circ.append_gate(U3Gate(), [0])
            >>> circ.num_params
            6
            >>> circ.get_param_location(4)
            (1, 0, 1)
        """
        if param_index < 0:
            raise IndexError('Negative parameter index is not supported.')

        count = 0
        for cycle, op in self.operations_with_cycles():
            count += len(op.params)
            if count > param_index:
                param = param_index - (count - len(op.params))
                return (cycle, op.location[0], param)

        raise IndexError('Out-of-range parameter index.')

    # endregion

    # region Circuit Logic Methods

    def get_inverse(self) -> Circuit:
        """Return the circuit's inverse circuit."""
        circuit = Circuit(self.num_qudits, self.radixes)
        for op in reversed(self):
            circuit.append(op.get_inverse())
        return circuit

    def get_unitary(self, params: RealVector = []) -> UnitaryMatrix:
        """
        Return the unitary matrix of the circuit.

        Args:
            params (RealVector): Optionally specify parameters
                overriding the ones stored in the circuit. (Default:
                use parameters already in circuit.)

        Returns:
            The UnitaryMatrix object that the circuit implements.

        Raises:
            ValueError: If parameters are specified and invalid.

        Examples:
            >>> from bqskit.ir.gates import HGate
            >>> circ = Circuit(1)
            >>> op = Operation(HGate(), [0])
            >>> circ.append(op)
            >>> circ.get_unitary() == HGate().get_unitary()
            True
        """
        if len(params) != 0:
            self.check_parameters(params)
            param_index = 0

        utry = UnitaryBuilder(self.num_qudits, self.radixes)

        for op in self:
            if len(params) != 0:
                gparams = params[param_index:param_index + op.num_params]
                utry.apply_right(op.get_unitary(gparams), op.location)
                param_index += op.num_params
            else:
                utry.apply_right(op.get_unitary(), op.location)

        return utry.get_unitary()

    def get_statevector(
        self,
        in_state: StateLike,
        params: RealVector = [],
    ) -> StateVector:
        """
        Return the result of applying `self` to `in_state`

        Args:
            params (RealVector): Optionally specify parameters
                overriding the ones stored in the circuit. (Default:
                use parameters already in circuit.)

        Returns:
            The StateVector object for the new state after the circuit

        Raises:
            ValueError: If parameters are specified and invalid.

        Examples:
            >>> from bqskit.ir.gates import HGate
            >>> circ = Circuit(1)
            >>> op = Operation(HGate(), [0])
            >>> circ.append(op)
            >>> V = StateVector([1,0])
            >>> np.allclose(circ.get_statevector(V), np.array([1,1])/np.sqrt(2))
            True
        """
        if len(params) != 0:
            self.check_parameters(params)
            param_index = 0

        new_state = StateVector(in_state)

        for op in self:
            if len(params) != 0:
                gparams = params[param_index:param_index + op.num_params]
                new_state.apply(op.get_unitary(gparams), op.location)
                param_index += op.num_params
            else:
                new_state.apply(op.get_unitary(), op.location)

        return new_state

    def get_grad(self, params: RealVector = []) -> npt.NDArray[np.complex128]:
        """Return the gradient of the circuit."""
        return self.get_unitary_and_grad(params)[1]

    def get_unitary_and_grad(
        self, params: RealVector = [],
    ) -> tuple[UnitaryMatrix, npt.NDArray[np.complex128]]:
        """Return the unitary and gradient of the circuit."""
        if len(params) != 0:
            self.check_parameters(params)
            param_index = 0

        # Collect matrices, gradients, and locations
        matrices = []
        grads = []
        locations = []

        for op in self:
            if len(params) != 0:
                gparams = params[param_index:param_index + op.num_params]
                param_index += op.num_params
                M, dM = op.get_unitary_and_grad(gparams)
                matrices.append(M)
                grads.append(dM)
                locations.append(op.location)
            else:
                M, dM = op.get_unitary_and_grad()
                matrices.append(M)
                grads.append(dM)
                locations.append(op.location)

        # Calculate gradient
        left = UnitaryBuilder(self.num_qudits, self.radixes)
        right = UnitaryBuilder(self.num_qudits, self.radixes)
        full_grads = []

        for M, loc in zip(matrices, locations):
            right.apply_right(M, loc)

        for M, dM, loc in zip(matrices, grads, locations):
            right.apply_left(M, loc, inverse=True)
            right_utry = right.get_unitary()
            for grad in dM:
                full_grads.append(right_utry @ left.eval_apply_right(grad, loc))
            left.apply_right(M, loc)

        return left.get_unitary(), np.array(full_grads)

    def perform(
        self,
        compiler_pass: BasePass,
        data: dict[str, Any] | None = None,
    ) -> None:
        """
        Execute the provided `compiler_pass` on this circuit.

        This function is necessary since BQSKit Pass objects cannot have
        their :func:`~bqskit.compiler.basepass.run` function directly called
        on a circuit.

        Args:
            compiler_pass (BasePass): The BQSKit pass to perform on this
                circuit.

            data (dict[str, Any] | None): Optionally provide additional
                pass data to the compiler pass.

        Note:
            You cannot perform a pass using this method while a local
            :class:`~bqskit.compiler.compiler.Compiler` object is live.
            Rather, you should use the
            :class:`~bqskit.compiler.compiler.Compiler` directly.
        """
        from bqskit.compiler.compiler import Compiler

        with Compiler() as compiler:
            task_id = compiler.submit(self, [compiler_pass], data=data)
            self.become(compiler.result(task_id))  # type: ignore

    def instantiate(
        self,
        target: StateLike | UnitaryLike | StateSystem,
        method: str | Instantiater | None = None,
        multistarts: int = 1,
        seed: int | None = None,
        multistart_gen: MultiStartGenerator | None = None,
        score_fn_gen: CostFunctionGenerator | None = None,
        **kwargs: Any,
    ) -> Circuit:
        """
        Instantiate the circuit with respect to a target state or unitary.

        Attempts to change the parameters of the circuit such that the
        circuit either implements the target unitary or maps the zero
        state to the target state.

        Args:
            target (StateLike | UnitaryLike | StateSystem): The target unitary
                or state. If a unitary is specified, the method changes
                the circuit's parameters in an effort to get closer to
                implementing the target. If a state is specified, the
                method changes the circuit's parameters in an effort to
                get closer to producing the target state when starting
                from the zero state.

            method (str | Instantiater | None): The method with which to
                instantiate the circuit. Currently, `"qfactor"` and
                `"minimization"` are supported. If left None, attempts to
                pick best method. You can also pass an :class:`Instantiater`
                directly through this.

            multistarts (int): The number of starting points to sample
                instantiation with. (Default: 1)

            seed (int | None): The seed for any pseudo-random number generators
                to use. Note that this is not guaranteed to make this method
                reproducible.

            multistart_gen (MultiStartGenerator): (Deprecated)

            score_fn_gen (CostFunctionGenerator):  (Deprecated)

            kwargs (dict[str, Any]): Method specific options, passed
                directly to method constructor. For more info, see
                `bqskit.ir.opt.instantiaters`.

        Returns:
            Circuit: A reference to `self` is returned

        Raises:
            ValueError: If `method` is invalid.

            ValueError: If `circuit` is incompatible with any method.

            ValueError: If `target` dimension doesn't match with circuit.

            ValueError: If `multistarts` is not a positive integer.

            ValueError: If `seed` is not an integer or `None`
        """
        if multistart_gen is not None or score_fn_gen is not None:
            warnings.warn(
                'Multistart handling has moved from the `circuit.instantiate`'
                ' method to the Instantiater class. See'
                ' `Instantiater.multi_start_instantiate` for more info. If you'
                ' would like to override how starts are generated or'
                ' instantiation results are processed, you should subclass'
                ' an Instantiater. An Instantiatier object can be passed to'
                ' `circuit.instantiate` through the `method` parameter. It'
                ' can also be passed to most BQSKit compiler passes through'
                " the `instantiate_options={'method':...} parameter. The use"
                ' of `multistart_gen` and `score_fn_gen` parameters in'
                ' `circuit.instantiate` is deprecated, and this warning'
                ' will turn into an error in the future.',
                DeprecationWarning,
            )

        # Set seed if specified
        if seed is not None:
            if not isinstance(seed, int):
                raise ValueError(
                    f'Expected seed to be an integer, got {type(seed)}.',
                )
            seed_random_sources(seed)

        # Use given Instantiater if one is specified.
        if isinstance(method, Instantiater):
            instantiater = method
            if not instantiater.is_capable(self):
                raise ValueError(
                    'Circuit cannot be instantiated using the '
                    f'{method} method.'
                    f'\n{instantiater.get_violation_report(self)}',
                )

        # Find best Instantiater if none specified
        elif method is None:
            err = ''
            instantiater = None
            for inst in instantiater_order:
                inst_t = cast(Instantiater, inst)
                if inst_t.is_capable(self):
                    instantiater = inst_t(**kwargs)  # type: ignore
                    break
                err += inst_t.get_violation_report(self) + '\n'
            if instantiater is None:
                raise ValueError(f'No capable instantiater.\n{err}')

        # If method is specified by name; match it
        elif isinstance(method, str):
            instantiater = None
            for inst in instantiater_order:
                inst_t = cast(Instantiater, inst)
                if inst_t.get_method_name().lower() == method.lower():
                    if not inst_t.is_capable(self):
                        raise ValueError(
                            'Circuit cannot be instantiated using the '
                            f'{method} method.'
                            f'\n{inst_t.get_violation_report(self)}',
                        )
                    instantiater = inst_t(**kwargs)  # type: ignore
                    break
            if instantiater is None:
                raise ValueError(f'No such instantiatation method {method}.')

        else:
            raise TypeError(
                'Expected a instantiater or name for method,'
                f' got {type(method)}.',
            )

        instantiater = cast(Instantiater, instantiater)

        # Instantiate
        instantiater.multi_start_instantiate_inplace(self, target, multistarts)
        return self

    def minimize(self, cost: CostFunction, **kwargs: Any) -> None:
        """
        Minimize the circuit's cost with respect to some CostFunction.

        Attempts to change the parameters of the circuit such that the
        circuit's cost according to `cost` is best minimized.

        Args:
            cost (CostFunction): The cost function to use when evaluting
                the circuit's cost.

            minimizer (str): The minimization method to use. If unspecified,
                attempts to assign best method. (kwarg)
        """
        minimizer = kwargs.get('minimizer', CeresMinimizer())
        self.set_params(minimizer.minimize(cost, self.params))

    # endregion

    # region Measurement Methods

    def remove_all_measurements(self) -> None:
        """Remove all measurement placeholders from the circuit."""
        while any(isinstance(g, MeasurementPlaceholder) for g in self.gate_set):
            for g in self.gate_set:
                if isinstance(g, MeasurementPlaceholder):
                    self.remove(g)

    # endregion

    # region Collection and Iteration Methods

    @overload
    def __getitem__(self, point: CircuitPointLike) -> Operation:
        ...

    @overload
    def __getitem__(
            self, points: Iterable[CircuitPointLike],
    ) -> list[Operation]:
        ...

    @overload
    def __getitem__(self, region: CircuitRegionLike) -> list[Operation]:
        ...

    @overload
    def __getitem__(
        self,
        slice: int | slice,
    ) -> list[Operation]:
        ...

    @overload
    def __getitem__(
        self,
        slices: tuple[Iterable[int] | slice, Iterable[int] | slice]
            | tuple[int, Iterable[int] | slice]
            | tuple[Iterable[int] | slice, int],
    ) -> list[Operation]:
        ...

    def __getitem__(
        self,
        indices: CircuitPointLike
            | Iterable[CircuitPointLike]
            | CircuitRegionLike
            | int | Iterable[int] | slice
            | tuple[Iterable[int] | slice, Iterable[int] | slice]
            | tuple[int, Iterable[int] | slice]
            | tuple[Iterable[int] | slice, int],
    ) -> Operation | list[Operation]:
        """
        Retrieve operations from the circuit.

        Args:
            indices (
                CircuitPointLike
                | Iterable[CircuitPointLike]
                | CircuitRegionLike
                | int | slice
                | tuple[Iterable[int] | slice, Iterable[int] | slice]
                | tuple[int, Iterable[int] | slice]
                | tuple[Iterable[int] | slice, int],
            ):
                This parameter describes the area in the circuit to retrieve
                operations from. If a point is given, then the operation
                at that point will be returned. In all other cases,
                a list of operations will be returned. You can specify
                a sequence of points to directly get operations from all
                those points. You can specify a cycle index, a sequence of
                cycle indices, or a slice to retrieve operations from those
                cycles. You can also specify a tuple that describes all
                cycles and qudits to sample from. Lastly, you can specify
                a circuit region to get all the operations there.

        Returns:
            (Operation | list[Operation]): Either a specific operation is
                returned or a list of them depending on the type of `indices`.

        Raises:
            IndexError: If any specified point is invalid or out-of-range.
        """

        if CircuitPoint.is_point(indices):
            return self.get_operation(indices)

        if is_iterable(indices):
            if all(CircuitPoint.is_point(point) for point in indices):
                return self.get_operations(indices)

        if CircuitRegion.is_region(indices):
            return self[CircuitRegion(indices).points]

        if is_integer(indices):
            return list({
                op
                for op in self._circuit[indices]
                if op is not None
            })

        # if is_iterable(indices):
        #     if all(is_integer(cycle_index) for cycle_index in indices):
        #         return sum([self[cycle_index] for cycle_index in indices], [])

        if isinstance(indices, slice):
            start, stop, step = indices.indices(self.num_cycles)
            acm: list[Operation] = []
            return sum((self[index] for index in range(start, stop, step)), acm)

        if isinstance(indices, tuple) and len(indices) == 2:
            cycle_indices, qudit_indices = indices
            cycles, qudits = None, None

            if is_integer(cycle_indices):
                cycles = [cycle_indices]

            elif isinstance(cycle_indices, slice):
                start, stop, step = cycle_indices.indices(self.num_cycles)
                cycles = list(range(start, stop, step))

            elif is_iterable(cycle_indices):
                if all(is_integer(index) for index in cycle_indices):
                    cycles = list(cycle_indices)

            if is_integer(qudit_indices):
                qudits = [qudit_indices]

            elif isinstance(qudit_indices, slice):
                start, stop, step = qudit_indices.indices(self.num_qudits)
                qudits = list(range(start, stop, step))

            elif is_iterable(qudit_indices):
                if all(is_integer(index) for index in qudit_indices):
                    qudits = list(qudit_indices)

            if cycles is not None and qudits is not None:
                return self[[
                    CircuitPoint(cycle, qudit)
                    for cycle in cycles
                    for qudit in qudits
                ]]

        raise TypeError(
            'Invalid index type. Expected point'
            ', sequence of points, slice, region, or pair of indices'
            ', got %s.' % type(indices),
        )

    def __iter__(self) -> Iterator[Operation]:
        """Return an iterator that iterates through all operations."""
        return CircuitIterator(self)  # type: ignore

    def __reversed__(self) -> Iterator[Operation]:
        """Return a reverse iterator that iterates through all operations."""
        return CircuitIterator(self, reverse=True)  # type: ignore

    def __contains__(self, op: object) -> bool:
        """Return true if `op` is in the circuit."""
        if isinstance(op, Operation):
            try:
                self.check_valid_operation(op)
            except ValueError:
                return False

            if op.gate not in self._gate_info:
                return False

            for _op in self.operations(qudits_or_region=[op.location[0]]):
                if op == _op:
                    return True

            return False

        elif isinstance(op, Gate):
            return op in self._gate_info

        else:
            return False

    def __len__(self) -> int:
        """Return the number of operations in the circuit."""
        return self.num_operations

    def operations(
        self,
        start: CircuitPointLike = CircuitPoint(0, 0),
        end: CircuitPointLike | None = None,
        qudits_or_region: CircuitRegionLike | Sequence[int] | None = None,
        exclude: bool = False,
        reverse: bool = False,
    ) -> Iterator[Operation]:
        """Create and return an iterator, for more info see CircuitIterator."""
        return CircuitIterator(
            self,
            start,
            end,
            qudits_or_region,
            exclude,
            reverse,
        )  # type: ignore

    def operations_with_cycles(
        self,
        start: CircuitPointLike = CircuitPoint(0, 0),
        end: CircuitPointLike | None = None,
        qudits_or_region: CircuitRegionLike | Sequence[int] | None = None,
        exclude: bool = False,
        reverse: bool = False,
    ) -> Iterator[tuple[int, Operation]]:
        """Create and return an iterator, for more info see CircuitIterator."""
        return CircuitIterator(
            self,
            start,
            end,
            qudits_or_region,
            exclude,
            reverse,
            True,
        )  # type: ignore

    # endregion

    # region Operator Overloads

    def __invert__(self) -> Circuit:
        """Invert the circuit."""
        return self.get_inverse()

    def __eq__(self, rhs: object) -> bool:
        """
        Check for circuit equality.

        Two circuits are equal if: 1) They have the same number of operations.
        2) All qudit radixes are equal. 3) All operations in simulation order
        are equal.
        """
        if not isinstance(rhs, Circuit):
            return NotImplemented

        if self is rhs:
            return True

        if self._gate_info != rhs._gate_info:
            return False

        for r1, r2 in zip(self.radixes, rhs.radixes):
            if r1 != r2:
                return False

        return all(op1 == op2 for op1, op2 in zip(self, rhs))

    def __ne__(self, rhs: object) -> bool:
        """Check for circuit inequality, see __eq__ for more info."""
        return not self == rhs

    def __add__(self, rhs: Circuit) -> Circuit:
        """Return a concatenated circuit copy."""
        circuit = Circuit(self.num_qudits, self.radixes)
        circuit.append_circuit(self, list(range(self.num_qudits)))
        circuit.append_circuit(rhs, list(range(self.num_qudits)))
        return circuit

    def __mul__(self, rhs: int) -> Circuit:
        """Return a repeated circuit copy."""
        circuit = Circuit(self.num_qudits, self.radixes)
        for x in range(rhs):
            circuit.append_circuit(self, list(range(self.num_qudits)))
        return circuit

    def __radd__(self, lhs: Circuit) -> Circuit:
        """Return a concatenated circuit copy."""
        circuit = Circuit(self.num_qudits, self.radixes)
        circuit.append_circuit(lhs, list(range(self.num_qudits)))
        circuit.append_circuit(self, list(range(self.num_qudits)))
        return circuit

    def __iadd__(self, rhs: Circuit) -> None:
        """Return a concatenated circuit copy."""
        self.append_circuit(rhs, list(range(self.num_qudits)))

    def __imul__(self, rhs: int) -> None:
        """Return a repeated circuit copy."""
        circuit = self.copy()
        for x in range(rhs - 1):
            self.append_circuit(circuit, list(range(self.num_qudits)))

    # endregion

    # region IO Methods

    def __str__(self) -> str:
        """String representation of the circuit."""
        op_string = '['
        if self.num_operations == 1:
            op_string += str(list(self.operations())[0])
        elif self.num_operations == 2:
            op_string += str(list(self.operations())[0])
            op_string += ', '
            op_string += str(list(self.operations())[1])
        elif self.num_operations > 2:
            op_string += str(list(self.operations())[0])
            op_string += ' ... '
            op_string += str(list(self.operations())[-1])
        op_string += ']'
        num_qudits = self.num_qudits
        return f'Circuit({num_qudits}){op_string}'

    def __repr__(self) -> str:
        """Repr representation of the circuit."""
        string = f'Circuit({self.num_qudits})'
        for cycle in self._circuit[:100]:
            string += f'\n\t{cycle}'
        if self.num_cycles > 100:
            string += '...'
        return string

    def save(self, filename: str) -> None:
        """Save the circuit to a file."""
        language = get_language(filename.split('.')[-1])

        with open(filename, 'w') as f:
            f.write(language.encode(self))

    def to(self, type: str) -> str:
        """Convert circuit to language."""
        return get_language(type).encode(self)

    @staticmethod
    def from_file(filename: str) -> Circuit:
        """Restore a circuit from a file."""
        language = get_language(filename.split('.')[-1])

        with open(filename) as f:
            return language.decode(f.read())

    @staticmethod
    def from_unitary(utry: UnitaryLike) -> Circuit:
        """Construct a circuit from a single unitary."""
        utry = UnitaryMatrix(utry)
        circuit = Circuit(utry.num_qudits, utry.radixes)
        circuit.append_gate(
            ConstantUnitaryGate(utry), list(range(utry.num_qudits)),
        )
        return circuit

    @staticmethod
    def from_operation(op: Operation) -> Circuit:
        """Construct a circuit from a single operation."""
        circuit = Circuit(op.num_qudits, op.radixes)
        circuit.append_gate(op.gate, list(range(circuit.num_qudits)), op.params)
        return circuit

    def __reduce__(self) -> tuple[
        Callable[
            [int, tuple[int, ...], list[tuple[bool, bytes]], bytes],
            Circuit,
        ],
        tuple[int, tuple[int, ...], list[tuple[bool, bytes]], bytes],
    ]:
        """Return the pickle state of the circuit."""
        serialized_gates: list[tuple[bool, bytes]] = []
        gate_table = {}
        for gate in self.gate_set:
            gate_table[gate] = len(serialized_gates)
            if gate.__class__.__module__.startswith('bqskit'):
                serialized_gates.append((False, pickle.dumps(gate)))
            else:
                serialized_gates.append((True, dill.dumps(gate, recurse=True)))

        cycles: list[list[tuple[int, tuple[int, ...], list[float]]]] = []
        last_cycle = -1
        for cycle, op in self.operations_with_cycles():

            if cycle != last_cycle:
                last_cycle = cycle
                cycles.append([])

            marshalled_op = (
                gate_table[op.gate],
                op.location._location,
                op.params,
            )
            cycles[-1].append(marshalled_op)

        data = (
            self.num_qudits,
            self.radixes,
            serialized_gates,
            pickle.dumps(cycles),
        )
        return (rebuild_circuit, data)

    # endregion


def rebuild_circuit(
    num_qudits: int,
    radixes: tuple[int, ...],
    serialized_gates: list[tuple[bool, bytes]],
    serialized_cycles: bytes,
) -> Circuit:
    """Rebuild a circuit from a pickle state."""
    circuit = Circuit(num_qudits, radixes)

    gate_table = {}
    for i, (is_dill, serialized_gate) in enumerate(serialized_gates):
        if is_dill:
            gate = dill.loads(serialized_gate)
        else:
            gate = pickle.loads(serialized_gate)
        gate_table[i] = gate

    cycles = pickle.loads(serialized_cycles)
    for i, cycle in enumerate(cycles):
        circuit._append_cycle()
        for marshalled_op in cycle:
            gate = gate_table[marshalled_op[0]]
            location = marshalled_op[1]
            params = marshalled_op[2]
            circuit._append(Operation(gate, location, params), i)

    return circuit
