# This code is part of Qiskit.
#
# (C) Copyright IBM 2021.
#
# 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.
"""Base classes for an approximate circuit definition."""

from abc import ABC, abstractmethod
from typing import Optional
import numpy as np

from qiskit import QuantumCircuit


class ApproximateCircuit(QuantumCircuit, ABC):
    """A base class that represents an approximate circuit."""

    def __init__(self, num_qubits: int, name: Optional[str] = None) -> None:
        """
        Args:
            num_qubits: number of qubit this circuit will span.
            name: a name of the circuit.
        """
        super().__init__(num_qubits, name=name)

    @property
    @abstractmethod
    def thetas(self) -> np.ndarray:
        """
        The property is not implemented and raises a ``NotImplementedException`` exception.

        Returns:
            a vector of parameters of this circuit.
        """
        raise NotImplementedError

    @abstractmethod
    def build(self, thetas: np.ndarray) -> None:
        """
        Constructs this circuit out of the parameters(thetas). Parameter values must be set before
            constructing the circuit.

        Args:
            thetas: a vector of parameters to be set in this circuit.
        """
        raise NotImplementedError


class ApproximatingObjective(ABC):
    """
    A base class for an optimization problem definition. An implementing class must provide at least
    an implementation of the ``objective`` method. In such case only gradient free optimizers can
    be used. Both method, ``objective`` and ``gradient``, preferable to have in an implementation.
    """

    def __init__(self) -> None:
        # must be set before optimization
        self._target_matrix = None

    @abstractmethod
    def objective(self, param_values: np.ndarray) -> float:
        """
        Computes a value of the objective function given a vector of parameter values.

        Args:
            param_values: a vector of parameter values for the optimization problem.

        Returns:
            a float value of the objective function.
        """
        raise NotImplementedError

    @abstractmethod
    def gradient(self, param_values: np.ndarray) -> np.ndarray:
        """
        Computes a gradient with respect to parameters given a vector of parameter values.

        Args:
            param_values: a vector of parameter values for the optimization problem.

        Returns:
            an array of gradient values.
        """
        raise NotImplementedError

    @property
    def target_matrix(self) -> np.ndarray:
        """
        Returns:
            a matrix being approximated
        """
        return self._target_matrix

    @target_matrix.setter
    def target_matrix(self, target_matrix: np.ndarray) -> None:
        """
        Args:
            target_matrix: a matrix to approximate in the optimization procedure.
        """
        self._target_matrix = target_matrix

    @property
    @abstractmethod
    def num_thetas(self) -> int:
        """

        Returns:
            the number of parameters in this optimization problem.
        """
        raise NotImplementedError
