import numpy as np

try:
    import cupy as cp
except ImportError:  # pragma: no cover - exercised on CPU-only installations
    cp = None


def _require_cupy():
    if cp is None:
        raise ImportError(
            "The cuQuantum circuit converter requires cupy. "
            "Install the GPU dependencies or use the CPU backend."
        )
    return cp

# Reference: https://github.com/NVIDIA/cuQuantum/tree/main/python/samples/cutensornet/circuit_converter


class QiboCircuitToEinsum:
    """Convert a circuit to a Tensor Network (TN) representation.

    The circuit is first processed to an intermediate form by grouping each gate matrix
    with its corresponding qubit it is acting on to a list. It is then converted to an
    equivalent TN expression through the class function state_vector_operands()
    following the Einstein summation convention in the interleave format.

    See document for detail of the format: https://docs.nvidia.com/cuda/cuquantum/python/api/generated/cuquantum.contract.html

    The output is to be used by cuQuantum's contract() for computation of the
    state vectors of the circuit.
    """

    def __init__(self, circuit, dtype="complex128"):
        self.backend = _require_cupy()
        self.dtype = getattr(self.backend, dtype)
        self.init_basis_map(self.backend, dtype)
        self.init_intermediate_circuit(circuit)
        self.circuit = circuit

    def state_vector_operands(self):
        """Create the operands for dense vector computation in the interleave
        format.

        Returns:
            Operands for the contraction in the interleave format.
        """
        input_bitstring = "0" * len(self.active_qubits)

        input_operands = self._get_bitstring_tensors(input_bitstring)

        (
            mode_labels,
            qubits_frontier,
            next_frontier,
        ) = self._init_mode_labels_from_qubits(self.active_qubits)

        gate_mode_labels, gate_operands = self._parse_gates_to_mode_labels_operands(
            self.gate_tensors, qubits_frontier, next_frontier
        )

        operands = input_operands + gate_operands
        mode_labels += gate_mode_labels

        out_list = []
        for key in qubits_frontier:
            out_list.append(qubits_frontier[key])

        operand_exp_interleave = [x for y in zip(operands, mode_labels) for x in y]
        operand_exp_interleave.append(out_list)
        return operand_exp_interleave

    def _init_mode_labels_from_qubits(self, qubits):
        n = len(qubits)
        frontier_dict = {q: i for i, q in enumerate(qubits)}
        mode_labels = [[i] for i in range(n)]
        return mode_labels, frontier_dict, n

    def _get_bitstring_tensors(self, bitstring):
        return [self.basis_map[ibit] for ibit in bitstring]

    def _parse_gates_to_mode_labels_operands(
        self, gates, qubits_frontier, next_frontier
    ):
        mode_labels = []
        operands = []

        for tensor, gate_qubits in gates:
            operands.append(tensor)
            input_mode_labels = []
            output_mode_labels = []
            for q in gate_qubits:
                input_mode_labels.append(qubits_frontier[q])
                output_mode_labels.append(next_frontier)
                qubits_frontier[q] = next_frontier
                next_frontier += 1
            mode_labels.append(output_mode_labels + input_mode_labels)
        return mode_labels, operands

    def op_shape_from_qubits(self, nqubits):
        """Modify tensor to cuQuantum shape.

        Parameters:
            nqubits (int): The number of qubits in quantum circuit.

        Returns:
            (qubit_states,input_output) * nqubits
        """
        return (2, 2) * nqubits

    def init_intermediate_circuit(self, circuit):
        """Initialize the intermediate circuit representation.

        This method initializes the intermediate circuit representation by extracting gate matrices and qubit IDs
        from the given quantum circuit.

        Parameters:
            circuit (object): The quantum circuit object.
        """
        self.gate_tensors = []
        gates_qubits = []

        for gate in circuit.queue:
            gate_qubits = gate.control_qubits + gate.target_qubits
            gates_qubits.extend(gate_qubits)

            # self.gate_tensors is to extract into a list the gate matrix together with the qubit id that it is acting on
            # https://github.com/NVIDIA/cuQuantum/blob/6b6339358f859ea930907b79854b90b2db71ab92/python/cuquantum/cutensornet/_internal/circuit_parser_utils_cirq.py#L32
            required_shape = self.op_shape_from_qubits(len(gate_qubits))
            self.gate_tensors.append(
                (
                    self.backend.asarray(gate.matrix(), dtype=self.dtype).reshape(
                        required_shape
                    ),
                    gate_qubits,
                )
            )

        # self.active_qubits is to identify qubits with at least 1 gate acting on it in the whole circuit.
        self.active_qubits = np.unique(gates_qubits)

    def init_basis_map(self, backend, dtype):
        """Initialize the basis map for the quantum circuit.

        This method initializes a basis map for the quantum circuit, which maps binary
        strings representing qubit states to their corresponding quantum state vectors.

        Parameters:
            backend (object): The backend object providing the array conversion method.
            dtype (object): The data type for the quantum state vectors.
        """
        asarray = backend.asarray
        state_0 = asarray([1, 0], dtype=dtype)
        state_1 = asarray([0, 1], dtype=dtype)

        self.basis_map = {"0": state_0, "1": state_1}

    def init_inverse_circuit(self, circuit):
        """Initialize the inverse circuit representation.

        This method initializes the inverse circuit representation by extracting gate matrices and qubit IDs
        from the given quantum circuit.

        Parameters:
            circuit (object): The quantum circuit object.
        """
        self.gate_tensors_inverse = []
        gates_qubits_inverse = []

        for gate in circuit.queue:
            gate_qubits = gate.control_qubits + gate.target_qubits
            gates_qubits_inverse.extend(gate_qubits)

            # self.gate_tensors is to extract into a list the gate matrix together with the qubit id that it is acting on
            # https://github.com/NVIDIA/cuQuantum/blob/6b6339358f859ea930907b79854b90b2db71ab92/python/cuquantum/cutensornet/_internal/circuit_parser_utils_cirq.py#L32
            required_shape = self.op_shape_from_qubits(len(gate_qubits))
            self.gate_tensors_inverse.append(
                (
                    self.backend.asarray(gate.matrix()).reshape(required_shape),
                    gate_qubits,
                )
            )

        # self.active_qubits is to identify qubits with at least 1 gate acting on it in the whole circuit.
        self.active_qubits_inverse = np.unique(gates_qubits_inverse)

    def get_pauli_gates(self, pauli_map, dtype="complex128", backend=None):
        """Populate the gates for all pauli operators.

        Parameters:
            pauli_map: A dictionary mapping qubits to pauli operators.
            dtype: Data type for the tensor operands.
            backend: The package the tensor operands belong to.

        Returns:
            A sequence of pauli gates.
        """
        if backend is None:
            backend = _require_cupy()
        asarray = backend.asarray
        pauli_i = asarray([[1, 0], [0, 1]], dtype=dtype)
        pauli_x = asarray([[0, 1], [1, 0]], dtype=dtype)
        pauli_y = asarray([[0, -1j], [1j, 0]], dtype=dtype)
        pauli_z = asarray([[1, 0], [0, -1]], dtype=dtype)

        operand_map = {"I": pauli_i, "X": pauli_x, "Y": pauli_y, "Z": pauli_z}
        gates = []
        for qubit, pauli_char in pauli_map.items():
            operand = operand_map.get(pauli_char)
            if operand is None:
                raise ValueError("pauli string character must be one of I/X/Y/Z")
            gates.append((operand, (qubit,)))
        return gates

    def expectation_operands(self, ham_gates):
        """Create the operands for pauli string expectation computation in the
        interleave format.

        Parameters:
            ham_gates: A list of gates derived from Qibo hamiltonian object.

        Returns:
            Operands for the contraction in the interleave format.
        """
        input_bitstring = "0" * self.circuit.nqubits

        input_operands = self._get_bitstring_tensors(input_bitstring)

        (
            mode_labels,
            qubits_frontier,
            next_frontier,
        ) = self._init_mode_labels_from_qubits(range(self.circuit.nqubits))

        gate_mode_labels, gate_operands = self._parse_gates_to_mode_labels_operands(
            self.gate_tensors, qubits_frontier, next_frontier
        )

        operands = input_operands + gate_operands
        mode_labels += gate_mode_labels

        self.init_inverse_circuit(self.circuit.invert())

        next_frontier = max(qubits_frontier.values()) + 1

        gates_inverse = ham_gates + self.gate_tensors_inverse

        (
            gate_mode_labels_inverse,
            gate_operands_inverse,
        ) = self._parse_gates_to_mode_labels_operands(
            gates_inverse, qubits_frontier, next_frontier
        )
        mode_labels = (
            mode_labels
            + gate_mode_labels_inverse
            + [[qubits_frontier[ix]] for ix in range(self.circuit.nqubits)]
        )
        operands = operands + gate_operands_inverse + operands[: self.circuit.nqubits]

        operand_exp_interleave = [x for y in zip(operands, mode_labels) for x in y]

        return operand_exp_interleave
