# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements.  See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership.  The ASF licenses this file
# to you 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.
# ruff: noqa: F401
"""Provide abstraction for defining optimizers and a set of common optimizers."""

from decimal import Decimal
from typing import Optional, Union

import numpy as np  # type: ignore
import tvm_ffi

import tvm

from ..block_builder import BlockBuilder
from ..expr import Function, TupleGetItem, Var, const
from ..expr import Tuple as RxTuple
from ..op import add, divide, multiply, sqrt, subtract
from ..type import TensorType, TupleType


# TODO(chaofan, yixin): Migrate key logics to C++
class Optimizer:
    """Relax training optimizer. This class could generate relax Functions for optimizing specified
    parameters, and store the states used in the optimization process, such as momentum.

    Parameters
    ----------
    name : str
        The name of the optimizer function. This parameter is provided by subclasses.

    Attributes
    ----------
    dtype : str
        The only dtype of the optimizer. It will be used as the dtype of the optimizer states,
        and the dtype of necessary constants, such as the learning rate. Will be set in `init()`.

    name : str
        The name of the optimizer.

    param_list : List[Var]
        The list of variables to optimize. Will be set in `init()`.

    state : tvm_ffi.Array
        `state` is an runtime Array representing the state of the optimizer. Will be set in
        `init()`.

        The states of the optimizer can store necessary information in the optimization process at
        runtime, such as the number of steps, the momentum in momentum SGD, etc.

        `opt.state` should be used as the last argument of the function that is got through
        `get_function()`, and its new value is returned as the last return value of that function.

        See examples for more details.

    Examples
    --------
    The usage of optimizers should resemble the following pattern. We will take SGD as an example.
    For detailed examples, please see the tutorial.

    .. code-block:: python

        # Construct the optimizer
        opt = relax.optimizer.SGD(0.1)

        # Initialize the parameter list, the dtype and the optimizer state
        # x is the relax Var we want to optimize
        opt.init(x)

        # The above two lines is equivalent to one line:
        opt = relax.optimizer.SGD(0.1).init(x)

        # Get the optimizer function
        # mod is an IRModule constructed earlier
        mod["SGD"] = opt.get_function()

        # Legalize and build mod
        lowered_mod = LegalizeOps()(mod)
        ex = build(lowered_mod, target="llvm")
        vm = VirtualMachine(ex, tvm.cpu())

        # Optimization process
        # param_tuple is a runtime tuple of parameters
        # param_gradient is a runtime tuple of the gradient of the parameters in param_tuple,
        # respectively
        # param_gradient can be gained by the automatic differentiation pass. Please see
        # `relax.transform.Gradient`
        param_tuple, opt.state = vm["SGD"](param_tuple, param_gradient, opt.state)
    """

    dtype: str
    name: str
    param_list: list[Var]
    state: tvm_ffi.Array

    def __init__(self, name: str) -> None:
        self.name = name
        self.param_list = None
        self.state = None
        self.dtype = None

    def init(self, params: Var | list[Var]) -> "Optimizer":
        """Set the parameters, determine the dtype, and construct the initial state for the
        optimizer.

        Parameters
        ----------
        params : Union[Var, List[Var]]
            The parameter or the list of parameters to optimize.

            Parameters should all be Vars of floating point Tensors, including float32, float64,
            float16, etc. Currently, all parameters should have the same dtype, and that dtype
            will be used as the dtype of the optimizer states.

        Returns
        -------
        self : Optimizer
            The optimizer itself.
        """
        if not isinstance(params, list):
            params = [params]
        self._set_params_and_dtype(params)
        # State should be initialized in any implementation of optimizer.
        self.state = None
        return self

    def _set_params_and_dtype(self, params: list[Var]) -> None:
        """Check params is legal and set the param_list and dtype of the optimizer."""
        params_set = set()
        dtype = None
        for x in params:
            if not isinstance(x, Var):
                raise ValueError(f"Parameter {x} is not a Var")
            if not isinstance(x.ty, TensorType):
                raise ValueError(
                    f"Optimizers only support Tensor parameters, but parameter {x.name_hint} has "
                    f"type {x.ty}"
                )
            data_type = tvm.DataType(x.ty.dtype.dtype)
            if data_type.type_code not in (tvm.DataTypeCode.BFLOAT, tvm.DataTypeCode.FLOAT):
                raise ValueError(
                    f"Optimizers only support Tensor parameters of floating point dtype, but dtype "
                    f"of {x.name_hint} is {x.ty.dtype}"
                )
            if dtype is None:
                dtype = x.ty.dtype
            else:
                if dtype != x.ty.dtype:
                    raise ValueError(
                        f"All parameters should have the same dtype, but parameter {x.name_hint} "
                        f"has dtype {x.ty.dtype}, which differs from the previous dtype "
                        f"{dtype}"
                    )
            if x in params_set:
                raise ValueError(f"Parameter {x.name_hint} appears more than once")
            params_set.add(x)
        self.param_list = params
        self.dtype = dtype

    def _check_init(self):
        """Check that the optimizer is initialized. This method should be called at the start of
        get_function().
        """
        if self.param_list is None or self.state is None or self.dtype is None:
            raise RuntimeError("Please call init() for the optimizer before calling get_function()")

    def get_function(self) -> Function:
        """Use blockbuilder to construct an optimizer function that executes updates of the
        parameters and the optimizer state.

        The optimizer function will take in a tuple of parameters, a tuple of gradients of
        parameters, and a tuple of optimizer states. It will return a tuple of updated parameters,
        and a tuple of optimizer states.

        Returns
        -------
        func : Function
            The optimizer function.

        Examples
        --------
        An example of the returned optimizer function. This function executes the stochastic
        gradient descent method with lr = 0.1.

        .. code-block:: python

            @R.function
            def SGD(
                params: R.Tuple(R.Tensor((3, 3), "float32"), R.Tensor((3,), "float32")),
                gradients: R.Tuple(R.Tensor((3, 3), "float32"), R.Tensor((3,), "float32")),
                optim_states: R.Tuple(R.Tensor((), "int64")),
            ) -> R.Tuple(
                R.Tuple(R.Tensor((3, 3), "float32"), R.Tensor((3,), "float32")),
                R.Tuple(R.Tensor((), "int64")),
            ):
                with R.dataflow():
                    num_steps: R.Tensor((), "int64") = optim_states[0]
                    num_steps_new: R.Tensor((), "int64") = R.add(num_steps, R.const(1, "int64"))
                    x: R.Tensor((3, 3), "float32") = params[0]
                    x_grad: R.Tensor((3, 3), "float32") = gradients[0]
                    lv: R.Tensor((3, 3), "float32") = R.multiply(R.const(0.01, "float32"), x_grad)
                    x_new: R.Tensor((3, 3), "float32") = R.subtract(x, lv)
                    y: R.Tensor((3,), "float32") = params[1]
                    y_grad: R.Tensor((3,), "float32") = gradients[1]
                    lv1: R.Tensor((3,), "float32") = R.multiply(R.const(0.01, "float32"), y_grad)
                    y_new: R.Tensor((3,), "float32") = R.subtract(y, lv1)
                    params_new: R.Tuple(R.Tensor((3, 3), "float32"), R.Tensor((3,), "float32")) = (
                        x_new,
                        y_new,
                    )
                    optim_states_new: R.Tuple(R.Tensor((), "int64")) = (num_steps_new,)
                    R.output(params_new, optim_states_new)
                return (params_new, optim_states_new)
        """
        self._check_init()
        raise NotImplementedError()


# TODO(chaofan, yixin): Support symbolic shapes
def _get_shape_as_int_list(var: Var) -> list[int]:
    return [int(val) for val in var.ty.shape]


# We need to subtract on hyperparameters, but do not want to introduce floating point error.
# Floating point error would lead to a few problems, such as making assert_structural_equal not
# pass in unit tests
def _high_precision_subtract(lhs: float, rhs: float) -> float:
    return float(Decimal(str(lhs)) - Decimal(str(rhs)))


class SGD(Optimizer):
    """Implements stochastic gradient descent.

    The returned function of `get_function()` is equivalent to the following numpy code:

    .. code-block:: python

        def SGD(param_tuple, grad_tuple, state_tuple):
            num_steps = state_tuple[0]
            param_tuple_new, state_tuple_new = [], []
            state_tuple_new.append(num_steps + 1)
            for i in range(len(param_tuple)):
                param = param_tuple[i]
                grad = grad_tuple[i]
                param_tuple_new.append(param - lr * (grad + weight_decay * param))
            return param_tuple_new, state_tuple_new

    Parameters
    ----------
    lr : float
        learning rate

    weight_decay : float
        weight decay (L2 penalty) (default: 0)
    """

    def __init__(self, lr: float, weight_decay: float = 0) -> None:
        super().__init__("SGD")
        self.lr = float(lr)
        self.weight_decay = float(weight_decay)

    def init(self, params: Var | list[Var]) -> "SGD":
        """Set the parameters, determine the dtype, and construct the initial state for the
        optimizer.

        The state of SGD is `(num_steps,)`.

        Parameters
        ----------
        params : Union[Var, List[Var]]
            The parameter or the list of parameters to optimize.

            Parameters should all be Vars of floating point Tensors, including float32, float64,
            float16, etc. Currently, all parameters should have the same dtype, and that dtype
            will be used as the dtype of the optimizer states.

        Returns
        -------
        self : SGD
            The SGD optimizer itself.
        """
        if not isinstance(params, list):
            params = [params]
        self._set_params_and_dtype(params)
        self.state = (
            # num_steps = 0
            tvm.runtime.tensor(np.zeros((), "int64")),
        )
        return self

    def get_function(self) -> Function:
        """Use blockbuilder to construct an optimizer function that executes updates of the
        parameters and the optimizer state. `init()` should be called before `get_function()`.

        Returns
        -------
        func : Function
            The optimizer function.
        """
        self._check_init()

        plist = self.param_list
        len_param = len(plist)
        dtype = self.dtype

        # input variables
        param_var = Var("params", TupleType([p.ty for p in plist]))
        grad_var = Var("gradients", TupleType([p.ty for p in plist]))
        state_var = Var("optim_states", TupleType([TensorType((), "int64")]))

        # constants
        lr = const(self.lr, dtype)
        weight_decay = const(self.weight_decay, dtype)
        one = const(1, "int64")

        builder = BlockBuilder()
        with builder.function(self.name, [param_var, grad_var, state_var]):
            with builder.dataflow():
                param_list_new, state_list_new = [], []

                # handle num_steps
                num_steps = builder.emit(TupleGetItem(state_var, 0), "num_steps")
                num_steps_new = builder.emit(add(num_steps, one), "num_steps_new")
                state_list_new.append(num_steps_new)

                # computation logics
                for i in range(len_param):
                    name = self.param_list[i].name_hint
                    p = builder.emit(TupleGetItem(param_var, i), name)
                    g = builder.emit(TupleGetItem(grad_var, i), name + "_grad")
                    if self.weight_decay:
                        g = builder.emit(add(multiply(weight_decay, p), g), name + "_grad_new")
                    p_new = builder.emit(subtract(p, multiply(lr, g)), name + "_new")
                    param_list_new.append(p_new)

                # handle return values
                params_new = builder.emit_output(RxTuple(param_list_new), "params_new")
                optim_states_new = builder.emit_output(RxTuple(state_list_new), "optim_states_new")
            builder.emit_func_output((params_new, optim_states_new))
        return builder.get()[self.name]


class MomentumSGD(Optimizer):
    """Implements stochastic gradient descent with momentum. Optionally supports Nesterov
    momentum.

    The returned function of `get_function()` is equivalent to the following numpy code:

    .. code-block:: python

        def MomentumSGD(param_tuple, grad_tuple, state_tuple):
            num_steps = state_tuple[0]
            param_tuple_new, state_tuple_new = [], []
            state_tuple_new.append(num_steps + 1)

            for i in range(len(param_tuple)):
                param = param_tuple[i]
                grad = grad_tuple[i]
                velocity = state_tuple[i + 1]
                grad = param * weight_decay + grad
                velocity = momentum * velocity + grad * (1 - dampening)
                if nesterov:
                    param = param - (grad + momentum * velocity) * lr
                else:
                    param = param - velocity * lr
                param_tuple_new.append(param)
                state_tuple_new.append(velocity)

            return param_tuple_new, state_tuple_new

    Parameters
    ----------
    lr : float
        learning rate

    momentum : float
        momentum factor (default: 0)

    weight_decay : float
        weight decay (L2 penalty) (default: 0)

    dampening : float
        dampening for momentum (default: 0)

    nesterov : bool
        enables Nesterov momentum (default: False)
    """

    def __init__(
        self,
        lr: float,
        momentum: float,
        dampening: float = 0,
        weight_decay: float = 0,
        nesterov: bool = False,
    ) -> None:
        super().__init__("MomentumSGD")
        self.lr = float(lr)
        self.momentum = float(momentum)
        self.weight_decay = float(weight_decay)
        self.dampening = float(dampening)
        self.nesterov = nesterov

    def init(self, params: Var | list[Var]) -> "MomentumSGD":
        """Set the parameters, determine the dtype, and construct the initial state for the
        optimizer.

        The state of MomentumSGD is
        `(num_steps, velocity_of_param_0, ..., velocity_of_param_n-1)`.

        Parameters
        ----------
        params : Union[Var, List[Var]]
            The parameter or the list of parameters to optimize.

            Parameters should all be Vars of floating point Tensors, including float32, float64,
            float16, etc. Currently, all parameters should have the same dtype, and that dtype
            will be used as the dtype of the optimizer states.

        Returns
        -------
        self : MomentumSGD
            The MomentumSGD optimizer itself.
        """
        if not isinstance(params, list):
            params = [params]
        self._set_params_and_dtype(params)
        self.state = (
            # num_steps = 0
            tvm.runtime.tensor(np.zeros((), "int64")),
            # v_{param} is initialized to all zeros
            *(
                tvm.runtime.tensor(np.zeros(_get_shape_as_int_list(p), p.ty.dtype.dtype))
                for p in self.param_list
            ),
        )
        return self

    def get_function(self) -> Function:
        """Use blockbuilder to construct an optimizer function that executes updates of the
        parameters and the optimizer state. `init()` should be called before `get_function()`.

        Returns
        -------
        func : Function
            The optimizer function.
        """
        self._check_init()
        plist = self.param_list
        len_param = len(plist)
        dtype = self.dtype

        # input variables
        param_var = Var("params", TupleType([p.ty for p in plist]))
        grad_var = Var("gradients", TupleType([p.ty for p in plist]))
        state_var = Var(
            "optim_states",
            TupleType([TensorType((), "int64"), *(p.ty for p in plist)]),
        )

        # constants
        lr = const(self.lr, dtype)
        momentum = const(self.momentum, dtype)
        weight_decay = const(self.weight_decay, dtype)
        dampening_inv = const(_high_precision_subtract(1, self.dampening), dtype)
        one = const(1, "int64")

        builder = BlockBuilder()
        with builder.function(self.name, [param_var, grad_var, state_var]):
            with builder.dataflow():
                param_list_new, state_list_new = [], []

                # handle num_steps
                num_steps = builder.emit(TupleGetItem(state_var, 0), "num_steps")
                num_steps_new = builder.emit(add(num_steps, one), "num_steps_new")
                state_list_new.append(num_steps_new)

                # computation logics
                for i in range(len_param):
                    name = self.param_list[i].name_hint
                    p = builder.emit(TupleGetItem(param_var, i), name)
                    g = builder.emit(TupleGetItem(grad_var, i), name + "_grad")
                    v = builder.emit(TupleGetItem(state_var, i + 1), name + "_v")
                    if self.weight_decay:
                        g = builder.emit(add(multiply(weight_decay, p), g), name + "_grad_new")
                    damp_g = multiply(dampening_inv, g) if self.dampening else g
                    v_new = builder.emit(add(multiply(momentum, v), damp_g), name + "_v_new")
                    g_new = (
                        builder.emit(add(g, multiply(momentum, v_new)), name + "_g_nest")
                        if self.nesterov
                        else v_new
                    )
                    p_new = builder.emit(subtract(p, multiply(lr, g_new)), name + "_new")
                    param_list_new.append(p_new)
                    state_list_new.append(v_new)

                # handle return values
                params_new = builder.emit_output(RxTuple(param_list_new), "params_new")
                optim_states_new = builder.emit_output(RxTuple(state_list_new), "optim_states_new")
            builder.emit_func_output((params_new, optim_states_new))
        return builder.get()[self.name]


class Adam(Optimizer):
    """Implements Adam optimization algorithm.

    The returned function of `get_function()` is equivalent to the following numpy code:

    .. code-block:: python

        def Adam(param_tuple, grad_tuple, state_tuple):
            num_steps = state_tuple[0]
            num_steps_new = num_steps + 1

            param_tuple_new = []
            state_tuple_new = [None] * len(state_tuple)
            state_tuple_new[0] = num_steps_new
            state_tuple_new[1] = state_tuple[1] * betas[0]
            state_tuple_new[2] = state_tuple[2] * betas[1]

            for i in range(len(param_tuple)):
                param = param_tuple[i]
                grad = grad_tuple[i]
                m = state_tuple[i + 3]
                v = state_tuple[i + 3 + len(param_tuple)]
                grad = grad + weight_decay * param
                m = betas[0] * m + (1 - betas[0]) * grad
                v = betas[1] * v + (1 - betas[1]) * grad * grad
                m_hat = m / (1 - betas[0] ** num_steps_new)
                v_hat = v / (1 - betas[1] ** num_steps_new)
                param = param - lr * m_hat / (np.sqrt(v_hat) + eps)
                param_tuple_new.append(param)
                state_tuple_new[i + 3] = m
                state_tuple_new[i + 3 + len(param_tuple)] = v

            return param_tuple_new, state_tuple_new

    Parameters
    ----------
    lr : float
        learning rate

    betas : Tuple[float, float]
        coefficients used for computing running averages of gradient and its square
        (default: (0.9, 0.999))

    eps : float
        term added to the denominator to improve numerical stability (default: 1e-8)

    weight_decay : float
        weight decay (L2 penalty) (default: 0)
    """

    def __init__(
        self,
        lr: float,
        betas: tuple[float, float] = (0.9, 0.999),
        eps: float = 1e-08,
        weight_decay: float = 0,
    ) -> None:
        super().__init__("Adam")
        self.lr = float(lr)
        self.beta1 = float(betas[0])
        self.beta2 = float(betas[1])
        self.eps = float(eps)
        self.weight_decay = float(weight_decay)

    def init(self, params: Var | list[Var]) -> "Adam":
        """Set the parameters, determine the dtype, and construct the initial state for the
        optimizer.

        The state of Adam is

        .. code-block:: python

            (
                num_steps,
                beta_0_prod, # beta0 ** num_steps
                beta_1_prod, # beta1 ** num_steps
                first_momentum_of_param_0, ..., first_momentum_of_param_n-1,
                second_momentum_of_param_0, ..., second_momentum_of_param_n-1
            )

        Parameters
        ----------
        params : Union[Var, List[Var]]
            The parameter or the list of parameters to optimize.

            Parameters should all be Vars of floating point Tensors, including float32, float64,
            float16, etc. Currently, all parameters should have the same dtype, and that dtype
            will be used as the dtype of the optimizer states.

        Returns
        -------
        self : Adam
            The Adam optimizer itself.
        """
        if not isinstance(params, list):
            params = [params]
        self._set_params_and_dtype(params)
        self.state = (
            # num_steps, beta_0_prod, beta_1_prod
            tvm.runtime.tensor(np.zeros((), "int64")),
            tvm.runtime.tensor(np.ones((), self.dtype)),
            tvm.runtime.tensor(np.ones((), self.dtype)),
            # first_momentum
            *(
                tvm.runtime.tensor(np.zeros(_get_shape_as_int_list(p), p.ty.dtype.dtype))
                for p in self.param_list
            ),
            # second_momentum
            *(
                tvm.runtime.tensor(np.zeros(_get_shape_as_int_list(p), p.ty.dtype.dtype))
                for p in self.param_list
            ),
        )
        return self

    def get_function(self) -> Function:
        """Use blockbuilder to construct an optimizer function that executes updates of the
        parameters and the optimizer state. `init()` should be called before `get_function()`.

        Returns
        -------
        func : Function
            The optimizer function.
        """
        self._check_init()
        plist = self.param_list
        len_param = len(plist)
        dtype = self.dtype

        # input variables
        param_var = Var("params", TupleType([p.ty for p in plist]))
        grad_var = Var("gradients", TupleType([p.ty for p in plist]))
        state_var = Var(
            "optim_states",
            TupleType(
                [
                    TensorType((), "int64"),
                    TensorType((), dtype),
                    TensorType((), dtype),
                    *(p.ty for p in plist),
                    *(p.ty for p in plist),
                ]
            ),
        )

        # constants
        lr = const(self.lr, dtype)
        beta1 = const(self.beta1, dtype)
        beta2 = const(self.beta2, dtype)
        beta1_inv = const(_high_precision_subtract(1, self.beta1), dtype)
        beta2_inv = const(_high_precision_subtract(1, self.beta2), dtype)
        eps = const(self.eps, dtype)
        weight_decay = const(self.weight_decay, dtype)
        one_int = const(1, "int64")
        one_float = const(1, dtype)

        builder = BlockBuilder()
        with builder.function(self.name, [param_var, grad_var, state_var]):
            with builder.dataflow():
                param_list_new = []
                state_list_new = [None] * (len_param * 2 + 3)  # type: List[Optional[Var]]

                # handle num_steps
                num_steps = builder.emit(TupleGetItem(state_var, 0), "num_steps")
                num_steps_new = builder.emit(add(num_steps, one_int), "num_steps_new")
                state_list_new[0] = num_steps_new
                beta1_prod = builder.emit(multiply(TupleGetItem(state_var, 1), beta1), "beta1_prod")
                beta2_prod = builder.emit(multiply(TupleGetItem(state_var, 2), beta2), "beta2_prod")
                state_list_new[1] = beta1_prod
                state_list_new[2] = beta2_prod

                # computation logics
                for i in range(len_param):
                    name = self.param_list[i].name_hint
                    p = builder.emit(TupleGetItem(param_var, i), name)
                    g = builder.emit(TupleGetItem(grad_var, i), name + "_grad")
                    m = builder.emit(TupleGetItem(state_var, i + 3), name + "_m")
                    v = builder.emit(TupleGetItem(state_var, i + 3 + len_param), name + "_v")
                    if self.weight_decay:
                        g = builder.emit(add(multiply(weight_decay, p), g), name + "_grad_new")
                    m_new = builder.emit(
                        add(multiply(beta1, m), multiply(beta1_inv, g)), name + "_m_new"
                    )
                    v_new = builder.emit(
                        add(multiply(beta2, v), multiply(beta2_inv, multiply(g, g))),
                        name + "_v_new",
                    )
                    m_hat = builder.emit(
                        divide(m_new, subtract(one_float, state_list_new[1])), name + "_m_hat"
                    )
                    v_hat = builder.emit(
                        divide(v_new, subtract(one_float, state_list_new[2])), name + "_v_hat"
                    )
                    p_new = builder.emit(
                        subtract(p, multiply(lr, divide(m_hat, add(sqrt(v_hat), eps)))),
                        name + "_new",
                    )
                    param_list_new.append(p_new)
                    state_list_new[i + 3] = m_new
                    state_list_new[i + 3 + len_param] = v_new

                # handle return values
                params_new = builder.emit_output(RxTuple(param_list_new), "params_new")
                optim_states_new = builder.emit_output(RxTuple(state_list_new), "optim_states_new")
            builder.emit_func_output((params_new, optim_states_new))
        return builder.get()[self.name]
