# Copyright (C) 2023 qBraid
#
# This file is part of the qBraid-SDK
#
# The qBraid-SDK is free software released under the GNU General Public License v3
# or later. You can redistribute and/or modify it under the terms of the GPL v3.
# See the LICENSE file in the project root or <https://www.gnu.org/licenses/gpl-3.0.html>.
#
# THERE IS NO WARRANTY for the qBraid-SDK, as per Section 15 of the GPL v3.
"""
Module for transpiling quantum programs between different quantum programming languages

"""
import logging
import warnings
from copy import deepcopy
from typing import TYPE_CHECKING, Callable, List, Optional

from qbraid._qprogram import QPROGRAM_LIBS
from qbraid.inspector import get_program_type
from qbraid.transpiler.exceptions import (
    CircuitConversionError,
    ConversionPathNotFoundError,
    NodeNotFoundError,
)
from qbraid.transpiler.graph import ConversionGraph

if TYPE_CHECKING:
    import cirq

    import qbraid


def _warn_if_unsupported(program_type, program_direction):
    if program_type not in QPROGRAM_LIBS:
        warnings.warn(
            f"Converting {program_direction} unsupported program type '{program_type}'.",
            UserWarning,
        )


def _flatten_cirq(circuit: "cirq.Circuit") -> "cirq.Circuit":
    """
    Flatten a Cirq circuit.

    Args:
        circuit (cirq.Circuit): The Cirq circuit to flatten.

    Returns:
        cirq.Circuit: The flattened Cirq circuit.
    """
    # TODO: potentially replace with native cirq.decompose
    # https://quantumai.google/reference/python/cirq/decompose

    # pylint: disable=import-outside-toplevel
    from cirq.contrib.qasm_import import circuit_from_qasm

    return circuit_from_qasm(circuit.to_qasm())


def _get_path_from_bound_methods(bound_methods: List[Callable]) -> str:
    """
    Constructs a path string from a list of bound methods of Conversion instances.

    This function takes a list of bound methods (specifically 'convert' methods bound to
    Conversion instances) and constructs a path string representing the sequence of
    conversions. Each conversion is defined by the 'source' and 'target' properties of the
    Conversion instance to which each method is bound.

    Args:
        bound_methods: A list of bound methods from Conversion instances.

    Returns:
        A string representing the path of conversions, formatted as
        'source1 -> source2 -> ... -> targetN'.

    Raises:
        AttributeError: If the bound methods do not have the expected 'source'
                        and 'target' attributes.
        IndexError: If the list of bound methods is empty.
    """
    if not bound_methods:
        raise IndexError("The list of bound methods is empty.")

    path = []
    for method in bound_methods:
        instance = method.__self__  # Get the instance to which the method is bound
        if not hasattr(instance, "source") or not hasattr(instance, "target"):
            raise AttributeError("Bound method instance lacks 'source' or 'target' attributes.")
        path.append(instance.source)  # Add the source node of the instance

    # Add the target of the last method's instance to complete the path
    path.append(bound_methods[-1].__self__.target)

    return " -> ".join(path)


def convert_to_package(
    program: "qbraid.QPROGRAM",
    target: str,
    conversion_graph: Optional[ConversionGraph] = None,
    max_path_attempts: int = 3,
    max_path_depth: Optional[int] = None,
) -> "qbraid.QPROGRAM":
    """
    Transpile a quantum program to a target language using a conversion graph.
    This function attempts to find a conversion path from the program's current
    format to the target format. It can limit the search to a certain number of
    attempts and path depths.

    Args:
        program (qbraid.QPROGRAM): The quantum program to transpile.
        target (str): The target language to transpile to.
        conversion_graph (Optional[ConversionGraph]): The graph representing available conversions.
            If None, a default graph is used. Defaults to None.
        max_path_attempts (int): The maximum number of conversion paths to attempt before raising an
            exception. This is useful to avoid excessive computations when multiple paths are
            available. Defaults to 3.
        max_path_depth (Optional[int]): The maximum depth of conversions within a given path to
            allow. For example, a path with a depth of 2 would be ['cirq' -> 'qasm2' -> 'qiskit'],
            whereas a depth  of 1 would be a direct conversion ['cirq' -> 'braket']. Defaults
            to None, i.e. no limit set on the path depth.

    Returns:
        qbraid.QPROGRAM: The transpiled quantum program.

    Raises:
        NodeNotFoundError: If the target or source package is not in the ConversionGraph.
        ConversionPathNotFoundError: If no path is available to conversion between the
            source and target packages.
        CircuitConversionError: If the conversion fails through all attempted paths.
    """
    graph = conversion_graph or ConversionGraph()
    graph_type = "Default" if conversion_graph is None else "Provided"

    if not graph.has_node(target):
        raise NodeNotFoundError(graph_type, target, graph.nodes)

    source = get_program_type(program, require_supported=conversion_graph is None)

    if not graph.has_node(source):
        raise NodeNotFoundError(graph_type, target, graph.nodes)

    if not graph.has_path(source, target):
        raise ConversionPathNotFoundError(source, target)

    if source == target:
        return program

    _warn_if_unsupported(source, "from")
    _warn_if_unsupported(target, "to")

    paths = graph.find_top_shortest_conversion_paths(source, target, top_n=max_path_attempts)

    if max_path_depth is not None:
        paths = [path for path in paths if len(path) <= max_path_depth]
        if len(paths) == 0:
            raise ConversionPathNotFoundError(source, target, max_path_depth)

    for path in paths:
        temp_program = deepcopy(program)
        try:
            for convert_func in path:
                try:
                    temp_program = convert_func(temp_program)
                except Exception:  # pylint: disable=broad-exception-caught
                    if get_program_type(temp_program) == "cirq":
                        temp_program = _flatten_cirq(temp_program)
                        temp_program = convert_func(temp_program)  # Retry conversion
                    else:
                        raise
            logging.info(
                "\nSuccessfully transpiled using conversions: %s",
                _get_path_from_bound_methods(path),
            )
            return temp_program
        except Exception:  # pylint: disable=broad-exception-caught
            logging.info(
                "\nFailed to transpile using conversions: %s",
                _get_path_from_bound_methods(path),
            )
            continue

    raise CircuitConversionError(f"Failed to convert program from '{source}' to '{target}'.")
