# Copyright 2018-2020 Xanadu Quantum Technologies Inc.

# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at

#     http://www.apache.org/licenses/LICENSE-2.0

# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
This is the top level module from which all basic functions and classes of
PennyLane can be directly imported.
"""
import pkg_resources

import numpy as _np
from autograd import grad as _grad
from autograd import jacobian as _jacobian

from semantic_version import Version, Spec

# QueuingContext needs to be imported before all other pennylane imports
from ._queuing import QueuingContext  # pylint: disable=wrong-import-order
import pennylane.operation

import pennylane.init
import pennylane.templates
import pennylane.qnn
import pennylane.qaoa as qaoa
from pennylane.templates import template, broadcast, layer
from pennylane.about import about
from pennylane.vqe import Hamiltonian, VQECost

from .circuit_graph import CircuitGraph
from .configuration import Configuration
from ._device import Device, DeviceError
from .collections import apply, map, sum, dot, QNodeCollection
from ._qubit_device import QubitDevice
from .measure import expval, var, sample, probs
from .ops import *
from .optimize import *
from .qnodes import qnode, QNode, QuantumFunctionError
from .utils import inv
from ._version import __version__
from .io import *

import pennylane.tape  # pylint: disable=wrong-import-order
from .tape import enable_tape, disable_tape

# Look for an existing configuration file
default_config = Configuration("config.toml")


# get list of installed devices
plugin_devices = {
    entry.name: entry for entry in pkg_resources.iter_entry_points("pennylane.plugins")
}


# get chemistry plugin
class NestedAttrError:
    """This class mocks out the qchem module in case
    it is not installed. Any attempt to print an instance
    of this class, or to access an attribute of this class,
    results in an import error, directing the user to the installation
    instructions for PennyLane Qchem"""

    error_msg = (
        "PennyLane-QChem not installed. \n\nTo access the qchem "
        "module, you can install PennyLane-QChem via pip:"
        "\n\npip install pennylane-qchem"
        "\n\nFor more details, see the quantum chemistry documentation:"
        "\nhttps://pennylane.readthedocs.io/en/stable/introduction/chemistry.html"
    )

    def __str__(self):
        raise ImportError(self.error_msg) from None

    def __getattr__(self, name):
        raise ImportError(self.error_msg) from None

    __repr__ = __str__


qchem = NestedAttrError()

for entry in pkg_resources.iter_entry_points("pennylane.qchem"):
    if entry.name == "OpenFermion":
        qchem = entry.load()


def device(name, *args, **kwargs):
    r"""device(name, wires=1, *args, **kwargs)
    Load a :class:`~.Device` and return the instance.

    This function is used to load a particular quantum device,
    which can then be used to construct QNodes.

    PennyLane comes with support for the following devices:

    * :mod:`'default.qubit' <pennylane.devices.default_qubit>`: a simple
      state simulator of qubit-based quantum circuit architectures.

    * :mod:`'default.gaussian' <pennylane.devices.default_gaussian>`: a simple simulator
      of Gaussian states and operations on continuous-variable circuit architectures.

    * :mod:`'default.qubit.tf' <pennylane.devices.default_qubit_tf>`: a state simulator
      of qubit-based quantum circuit architectures written in TensorFlow, which allows
      automatic differentiation through the simulation.

    * :mod:`'default.qubit.autograd' <pennylane.devices.default_qubit_autograd>`: a state simulator
      of qubit-based quantum circuit architectures which allows
      automatic differentiation through the simulation via python's autograd library.

    In addition, additional devices are supported through plugins — see
    the  `available plugins <https://pennylane.ai/plugins.html>`_ for more
    details.

    All devices must be loaded by specifying their **short-name** as listed above,
    followed by the **wires** (subsystems) you wish to initialize. The *wires*
    argument can be an integer, in which case the wires of the device are addressed
    by consecutive integers:

    .. code-block:: python

        dev = qml.device('default.qubit', wires=5)

        def circuit():
           qml.Hadamard(wires=1)
           qml.Hadamard(wires=[0])
           qml.CNOT(wires=[3, 4])
           ...

    The *wires* argument can also be a sequence of unique numbers or strings, specifying custom wire labels
    that the user employs to address the wires:

    .. code-block:: python

        dev = qml.device('default.qubit', wires=['ancilla', 'q11', 'q12', -1, 1])

        def circuit():
           qml.Hadamard(wires='q11')
           qml.Hadamard(wires=['ancilla'])
           qml.CNOT(wires=['q12', -1] )
           ...


    Some devices may accept additional arguments. For instance,
    ``default.gaussian`` accepts the keyword argument ``hbar``, to set
    the convention used in the commutation relation :math:`[\x,\p]=i\hbar`
    (by default set to 2).

    Please refer to the documentation for the individual devices to see any
    additional arguments that might be required or supported.

    Args:
        name (str): the name of the device to load
        wires (int): the number of wires (subsystems) to initialise
            the device with

    Keyword Args:
        config (pennylane.Configuration): a PennyLane configuration object
            that contains global and/or device specific configurations.
    """
    if name in plugin_devices:
        options = {}

        # load global configuration settings if available
        config = kwargs.get("config", default_config)

        if config:
            # combine configuration options with keyword arguments.
            # Keyword arguments take preference, followed by device options,
            # followed by plugin options, followed by global options.
            options.update(config["main"])
            options.update(config[name.split(".")[0] + ".global"])
            options.update(config[name])

        kwargs.pop("config", None)
        options.update(kwargs)

        # loads the device class
        plugin_device_class = plugin_devices[name].load()

        if Version(version()) not in Spec(plugin_device_class.pennylane_requires):
            raise DeviceError(
                "The {} plugin requires PennyLane versions {}, however PennyLane "
                "version {} is installed.".format(
                    name, plugin_device_class.pennylane_requires, __version__
                )
            )

        # load device
        return plugin_device_class(*args, **options)

    raise DeviceError("Device does not exist. Make sure the required plugin is installed.")


def grad(func, argnum=None):
    """Returns the gradient as a callable function of (functions of) QNodes.

    Function arguments with the property ``requires_grad`` set to ``False``
    will automatically be excluded from the gradient computation, unless
    the ``argnum`` keyword argument is passed.

    Args:
        func (function): a plain QNode, or a Python function that contains
            a combination of quantum and classical nodes

    Keyword Args:
        argnum (int, list(int), None): Which argument(s) to take the gradient
            with respect to. By default, the arguments themselves are used
            to determine differentiability, by examining the ``requires_grad``
            property. Providing this keyword argument overrides this behaviour,
            allowing argument differentiability to be defined manually for the returned gradient function.

    Returns:
        function: The function that returns the gradient of the input
        function with respect to the differentiable arguments, or, if specified,
        the arguments in ``argnum``.
    """
    # pylint: disable=no-value-for-parameter
    if argnum is not None:
        # for backwards compatibility with existing code
        # that manually specifies argnum
        return _grad(func, argnum)

    def _gradient_function(*args, **kwargs):
        """Inspect the arguments for differentiability, and
        compute the autograd gradient function with required argnums
        dynamically.

        This wrapper function is returned to the user instead of autograd.grad,
        so that we can take into account cases where the user computes the
        gradient function once, but then calls it with arguments that change
        in differentiability.
        """
        argnum = []

        for idx, arg in enumerate(args):
            if getattr(arg, "requires_grad", True):
                argnum.append(idx)

        return _grad(func, argnum)(*args, **kwargs)

    return _gradient_function


def jacobian(func, argnum=None):
    """Returns the Jacobian as a callable function of vector-valued
    (functions of) QNodes.

    This is a wrapper around the :mod:`autograd.jacobian` function.

    Args:
        func (function): A vector-valued Python function or QNode that contains
            a combination of quantum and classical nodes. The output of the computation
            must consist of a single NumPy array (if classical) or a tuple of
            expectation values (if a quantum node)
        argnum (int or Sequence[int]): Which argument to take the gradient
            with respect to. If a sequence is given, the Jacobian matrix
            corresponding to all input elements and all output elements is returned.

    Returns:
        function: the function that returns the Jacobian of the input
        function with respect to the arguments in argnum
    """
    # pylint: disable=no-value-for-parameter

    if argnum is not None:
        # for backwards compatibility with existing code
        # that manually specifies argnum
        if isinstance(argnum, int):
            return _jacobian(func, argnum)

        return lambda *args, **kwargs: _np.stack(
            [_jacobian(func, arg)(*args, **kwargs) for arg in argnum]
        ).T

    def _jacobian_function(*args, **kwargs):
        """Inspect the arguments for differentiability, and
        compute the autograd gradient function with required argnums
        dynamically.

        This wrapper function is returned to the user instead of autograd.jacobian,
        so that we can take into account cases where the user computes the
        jacobian function once, but then calls it with arguments that change
        in differentiability.
        """
        argnum = []

        for idx, arg in enumerate(args):
            if getattr(arg, "requires_grad", True):
                argnum.append(idx)

        if not argnum:
            return tuple()

        if len(argnum) == 1:
            return _jacobian(func, argnum[0])(*args, **kwargs)

        return _np.stack([_jacobian(func, arg)(*args, **kwargs) for arg in argnum]).T

    return _jacobian_function


def version():
    """Returns the PennyLane version number."""
    return __version__
