# This code is part of Qiskit.
#
# (C) Copyright IBM 2019, 2020.
#
# This code is licensed under the Apache License, Version 2.0. You may
# obtain a copy of this license in the LICENSE.txt file in the root directory
# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
#
# Any modifications or derivative works of this code must retain this
# copyright notice, and modified files need to carry a notice indicating
# that they have been altered from the originals.

"""Nakanishi-Fujii-Todo algorithm."""

from typing import Optional
import logging

import numpy as np
from scipy.optimize import minimize
from scipy.optimize import OptimizeResult
from .optimizer import Optimizer, OptimizerSupportLevel


logger = logging.getLogger(__name__)


class NFT(Optimizer):
    """
    Nakanishi-Fujii-Todo algorithm.

    See https://arxiv.org/abs/1903.12166
    """

    _OPTIONS = ['maxiter', 'maxfev', 'disp', 'reset_interval']

    # pylint: disable=unused-argument
    def __init__(self,
                 maxiter: Optional[int] = None,
                 maxfev: int = 1024,
                 disp: bool = False,
                 reset_interval: int = 32) -> None:
        """
        Built out using scipy framework, for details, please refer to
        https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.minimize.html.

        Args:
            maxiter: Maximum number of iterations to perform.
            maxfev: Maximum number of function evaluations to perform.
            disp: disp
            reset_interval: The minimum estimates directly once
                            in ``reset_interval`` times.

        Notes:
            In this optimization method, the optimization function have to satisfy
            three conditions written in [1]_.

        References:
            .. [1] K. M. Nakanishi, K. Fujii, and S. Todo. 2019.
                Sequential minimal optimization for quantum-classical hybrid algorithms.
                arXiv preprint arXiv:1903.12166.
        """
        super().__init__()
        for k, v in list(locals().items()):
            if k in self._OPTIONS:
                self._options[k] = v

    def get_support_level(self):
        """ return support level dictionary """
        return {
            'gradient': OptimizerSupportLevel.ignored,
            'bounds': OptimizerSupportLevel.ignored,
            'initial_point': OptimizerSupportLevel.required
        }

    def optimize(self, num_vars, objective_function, gradient_function=None,
                 variable_bounds=None, initial_point=None):
        super().optimize(num_vars, objective_function, gradient_function,
                         variable_bounds, initial_point)

        res = minimize(objective_function, initial_point,
                       method=nakanishi_fujii_todo, options=self._options)
        return res.x, res.fun, res.nfev


# pylint: disable=invalid-name
def nakanishi_fujii_todo(fun, x0, args=(), maxiter=None, maxfev=1024,
                         reset_interval=32, eps=1e-32, callback=None, **_):
    """
    Find the global minimum of a function using the nakanishi_fujii_todo
    algorithm [1].
    Args:
        fun (callable): ``f(x, *args)``
            Function to be optimized.  ``args`` can be passed as an optional item
            in the dict ``minimizer_kwargs``.
            This function must satisfy the three condition written in Ref. [1].
        x0 (ndarray): shape (n,)
            Initial guess. Array of real elements of size (n,),
            where 'n' is the number of independent variables.
        args (tuple, optional):
            Extra arguments passed to the objective function.
        maxiter (int):
            Maximum number of iterations to perform.
            Default: None.
        maxfev (int):
            Maximum number of function evaluations to perform.
            Default: 1024.
        reset_interval (int):
            The minimum estimates directly once in ``reset_interval`` times.
            Default: 32.
        eps (float): eps
        **_ : additional options
        callback (callable, optional):
            Called after each iteration.
    Returns:
        OptimizeResult:
            The optimization result represented as a ``OptimizeResult`` object.
            Important attributes are: ``x`` the solution array. See
            `OptimizeResult` for a description of other attributes.
    Notes:
        In this optimization method, the optimization function have to satisfy
        three conditions written in [1].
    References:
        .. [1] K. M. Nakanishi, K. Fujii, and S. Todo. 2019.
        Sequential minimal optimization for quantum-classical hybrid algorithms.
        arXiv preprint arXiv:1903.12166.
    """

    x0 = np.asarray(x0)
    recycle_z0 = None
    niter = 0
    funcalls = 0

    while True:

        idx = niter % x0.size

        if reset_interval > 0:
            if niter % reset_interval == 0:
                recycle_z0 = None

        if recycle_z0 is None:
            z0 = fun(np.copy(x0), *args)
            funcalls += 1
        else:
            z0 = recycle_z0

        p = np.copy(x0)
        p[idx] = x0[idx] + np.pi / 2
        z1 = fun(p, *args)
        funcalls += 1

        p = np.copy(x0)
        p[idx] = x0[idx] - np.pi / 2
        z3 = fun(p, *args)
        funcalls += 1

        z2 = z1 + z3 - z0
        c = (z1 + z3) / 2
        a = np.sqrt((z0 - z2) ** 2 + (z1 - z3) ** 2) / 2
        b = np.arctan((z1 - z3) / ((z0 - z2) + eps * (z0 == z2))) + x0[idx]
        b += 0.5 * np.pi + 0.5 * np.pi * np.sign((z0 - z2) + eps * (z0 == z2))

        x0[idx] = b
        recycle_z0 = c - a

        niter += 1

        if callback is not None:
            callback(np.copy(x0))

        if maxfev is not None:
            if funcalls >= maxfev:
                break

        if maxiter is not None:
            if niter >= maxiter:
                break

    return OptimizeResult(fun=fun(np.copy(x0)), x=x0, nit=niter, nfev=funcalls, success=(niter > 1))
