"""
\********************************************************************************
* Copyright (c) 2023 the Qrisp authors
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0.
*
* This Source Code may also be made available under the following Secondary
* Licenses when the conditions for such availability set forth in the Eclipse
* Public License, v. 2.0 are satisfied: GNU General Public License, version 2
* with the GNU Classpath Exception which is
* available at https://www.gnu.org/software/classpath/license.html.
*
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
********************************************************************************/
"""

from qrisp import *
import numpy as np
import sympy as sp
from operator import itemgetter

def QUBO_obj(bitstring, Q):
    x = np.array(list(bitstring), dtype=int)
    cost = x.T @ Q @ x
    return cost

def create_QUBO_cl_cost_function(Q):
    """
    Creates the classical cost function for a QUBO instance with matrix ``Q`` that we are attempting to solve.

    Parameters
    ----------
    Q : np.array
        QUBO matrix to solve.

    Returns
    -------
    cl_cost_function : function
        The classical cost function for the problem instance, which takes a dictionary of measurement results as input.

    """    
    def cl_cost_function(counts):
        
        def QUBO_obj(bitstring, Q):
            x = np.array(list(bitstring), dtype=int)
            cost = x.T @ Q @ x
            return cost
        
        energy = 0
        for meas, meas_count in counts.items():
            obj_for_meas = QUBO_obj(meas,Q)
            energy += obj_for_meas * meas_count
        return energy
    
    return cl_cost_function


# MAPPING QUBO TO GATES                       1 - Z_i                 1
#                               x_i  <-----> ---------:     x^T Q x = - SUM_{i,j}  Q_{ij} (1-Z_i)(1-Z_j) = SUM_{i,j} Q_ij*(Z_i*Z_j - Z_i - Z_j + 1)/4
#                                                2                    4  


def create_QUBO_cost_operator(Q):
    """
    Creates the cost operator for a QUBO instance with matrix ``Q``.
    In the QAOA overview section this is also called the phase separator $U_P$.

    Parameters
    ----------
    Q : np.array
        QUBO matrix to solve.

    Returns
    -------
    cost_operator : function
        A function receiving a :ref:`QuantumVariable` and a real parameter $\gamma$.
        This function performs the application of the cost operator.

    """
    
    def QUBO_cost_operator(qv, gamma):

        # Rescaling for enhancing the performance of the QAOA
        gamma = gamma/np.linalg.norm(Q)

        gphase(-gamma/4*(np.sum(Q)+np.trace(Q)),qv[0])

        for i in range(len(Q)):
            rz(-gamma/2*(sum(Q[i])+sum(Q[:,i])), qv[i])
            for j in range(len(Q)):
                if i != j and Q[i][j] != 0:
                    rzz(gamma/2*Q[i][j], qv[i], qv[j])

    return QUBO_cost_operator


def QUBO_problem(Q):
    """
    Creates a QAOA problem instance with appropriate phase separator, mixer, and
    classical cost function.

    Parameters
    ----------
    Q : np.array
        QUBO matrix to solve.

    Returns
    -------
    :ref:`QAOAProblem`
        A QAOA problem instance for a given QUBO matrix ``Q``.

    """    
    from qrisp.qaoa import QAOAProblem, RX_mixer
    
    return QAOAProblem(create_QUBO_cost_operator(Q), RX_mixer, create_QUBO_cl_cost_function(Q))


def solve_QUBO(Q, depth, shots=5000, max_iter = 50, backend = None, print_res = False):
    """
    Solves a Quadratic Unconstrained Binary Optimization (QUBO) problem using the Quantum Approximate Optimization Algorithm (QAOA). 

    This function creates the QAOA problem for a given QUBO. 
    It defines a quantum argument as a :ref:`QuantumArray` of ``len(Q)`` :ref:`QuantumVariables <QuantumVariable>` with size 1. 
    It then runs the QAOA with the given quantum argument, ``depth`` (number of layers), maximum ``iterations`` of the classical optimizer, and ``shots`` and ``backend`` as measurement keyword arguments. 
    The method performs classical post-processing on the solutions of the QAOA optimization: 
    it calculates the cost for each such solution, sorts the solutions by their cost in ascending order, and returns the optimal solution.

    .. warning::
     
        For small QUBO instance the number of ``shots`` typically exceeds the number of possible solutions.
        In this case, even QAOA with ``depth=0``, i.e., sampling from a uniform superposition, may yield the optimal solution as the classical post-processing amounts to brute force search!
        Performance of :meth:`solve_QUBO <qrisp.qaoa.problems.QUBO.solve_QUBO>` for small instance may not be indicative of performance for large instances. 

    Parameters
    ----------
    Q : np.array
        QUBO matrix to solve.
    depth : int
        The depth (amount of layers) of the QAOA circuit.
    shots : int
        The number of shots. The default is 5000.
    max_iter : int, optional
        The maximal amount of iterations of the ``COBYLA`` optimizer in the QAOA algorithm.
        The default is 50.
    backend : :ref:`BackendClient`, optional
        The backend to be used for the quantum simulation. 
        By default, the Qrisp simulator is used.
    print_res : Boolean, optional
        If set to ``True``, the 5 best solutions are printed. The default is ``False``.

    Returns
    -------
    optimal_solution : tuple
        A tuple where the first element is the cost value and the second element is the optimal bitstring. 
        If ``print_res`` is set to ``True``, the function prints the 5 best solutions with their respective costs.

    Examples
    --------

    ::

        from qrisp.qaoa import solve_QUBO
        import numpy as np

        Q = np.array(
            [
                [-17,  10,  10,  10,   0,  20],
                [ 10, -18,  10,  10,  10,  20],
                [ 10,  10, -29,  10,  20,  20],
                [ 10,  10,  10, -19,  10,  10],
                [ 0,   10,  20,  10, -17,  10],
                [ 20,  20,  20,  10,  10, -28],
            ]
        )

        solve_QUBO(Q, depth = 1, print_res=True)

    """

    # Define quantum argument as a QuantumArray of len(G) QuantumVariables with size 1 or as a QuantumVariable with size len(G)
    qarg = QuantumArray(qtype = QuantumVariable(1), shape = len(Q))

    QUBO_instance = QUBO_problem(Q)

    if backend is None:
        from qrisp.default_backend import def_backend
        backend = def_backend
    else:
        backend = backend

    # Run QAOA with given quantum arguments, depth, measurement keyword arguments and maximum iterations for optimization
    res = QUBO_instance.run(qarg, depth, mes_kwargs={"backend" : backend, "shots" : shots}, max_iter = max_iter, init_type='tqa') # runs the simulation

    # Calculate the cost for each solution
    costs_and_solutions = [(QUBO_obj(bitstring, Q), bitstring) for bitstring in res.keys()]

    # Sort the solutions by their cost in ascending order
    sorted_costs_and_solutions = sorted(costs_and_solutions, key=itemgetter(0))

    optimal_solution = sorted_costs_and_solutions[0]

    if print_res is True:
        # Get the best solutions and print them
        for i in range(5):
            print(f"Solution {i+1}: {sorted_costs_and_solutions[i][1]} with cost: {sorted_costs_and_solutions[i][0]} and probability: {res[sorted_costs_and_solutions[i][1]]}")
    
    return optimal_solution

