# 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.
# pylint: disable=not-callable, unused-argument
"""Setup Trainer Pass."""

import tvm
from tvm.ir.module import IRModule
from tvm.tirx.expr import IntImm

from ..analysis import check_well_formed
from ..expr import Tuple
from ..training.utils import AppendLoss
from ..transform import DecomposeOpsForInference, DecomposeOpsForTraining, Gradient, LegalizeOps
from ..type import TensorType
from .loss import Loss
from .optimizer import Optimizer


@tvm.transform.module_pass(opt_level=0, name="SetupTrainer")
class SetupTrainer:
    """Transform a backbone module to a complete, legalized trainer module.

    The provided backbone module should contain at least a function named `backbone`, and has two
    int attributes `param_num` and `state_num`, as follows:

    .. code-block:: python

        @I.ir_module
        class Backbone:
            I.module_attrs({"param_num": 1, "state_num": 1})
            @R.function
            def backbone(input_instances, parameters, states):
                # Predicts the result
                # Should contain only one DataflowBlock
                ...
                return backbone_result, updated_states

    Here each of input_instances, parameters, states, backbone_result and updated_states can
    denote a number of parameters. The length of parameters and the length of states is specified
    by param_num and state_num respectively.

    `states` denote the states that we need to maintain as the training process proceeds, such as
    the running mean and the running var of the batch norm operator. The updated states is returned
    in `updated_states`. States can be empty if there is no state that needs to be updated.

    The transformed module will at least contain the functions and attributes listed below:

    .. code-block:: python

        @I.ir_module
        class Module:
            I.module_attrs({"input_num": 1, "param_num": 1, "state_num": 1, "optim_states": ...})

            @R.function
            def backbone(input_instances, parameters, states):
                # Predicts the result. It is provided in the input module.
                ...
                return backbone_result, updated_states

            @R.function
            def backbone_loss(input_instances, parameters, states, targets):
                # Runs like backbone and then computes the loss between the result and targets.
                ...
                return loss, updated_states

            @R.function
            def backbone_loss_adjoint(input_instances, parameters, states, targets):
                # Runs like backbone_loss and then calculates the gradient of parameters.
                ...
                return (loss, updated_states), gradient_of_params

            @R.function
            def optimizer(params, gradients, optim_states):
                # Update parameters and optimizer states with the gradient computed
                ...
                return (updated_params, updated_optim_states)

    The transformed module contains an attribute `optim_states` as the initial optimizer states.

    Then the transformed module will be legalized by `relax.transform.LegalizeOps()` to lower
    relax operators into TIR functions.

    Parameters
    ----------
    loss : Loss
        The loss function. It will be appended to the backbone function using
        relax.transform.AppendLoss.

    optimizer : Optimizer
        The optimizer. It will be put as the `optimizer` function of the transformed module.

    loss_args : List[TensorType]
        The arguments to call the loss function.

    legalize : bool
        Whether to legalize the module. Default: True.
    """

    BACKBONE_FUNC: str = "backbone"
    BACKBONE_LOSS_FUNC: str = "backbone_loss"
    ADJOINT_FUNC: str = "backbone_loss_adjoint"
    OPTIMIZER_FUNC: str = "optimizer"

    PARAM_NUM_ATTR_KEY: str = "param_num"
    STATE_NUM_ATTR_KEY: str = "state_num"

    def __init__(
        self, loss: Loss, optimizer: Optimizer, loss_args: list[TensorType], legalize=True
    ):
        self._loss = loss
        self._optimizer = optimizer
        self._loss_args = loss_args
        self._legalize = legalize

    def _check_well_formed(self, mod: IRModule):
        if not check_well_formed(mod):
            raise ValueError("SetupTrainer: The backbone module is not well formed.")
        try:
            func = mod[self.BACKBONE_FUNC]
        except (KeyError, ValueError) as exc:
            raise ValueError(
                f"SetupTrainer: The backbone module does not contain a function named "
                f"{self.BACKBONE_FUNC}"
            ) from exc

        # Check function attrs
        if self.PARAM_NUM_ATTR_KEY not in mod.attrs or not isinstance(
            mod.attrs[self.PARAM_NUM_ATTR_KEY], IntImm | int
        ):
            raise ValueError(
                f"SetupTrainer: The backbone module should has an integer attribute named "
                f"{self.PARAM_NUM_ATTR_KEY}"
            )
        if self.STATE_NUM_ATTR_KEY not in mod.attrs or not isinstance(
            mod.attrs[self.STATE_NUM_ATTR_KEY], IntImm | int
        ):
            raise ValueError(
                f"SetupTrainer: The backbone module should has an integer attribute named "
                f"{self.STATE_NUM_ATTR_KEY}"
            )

        nparam = int(mod.attrs[self.PARAM_NUM_ATTR_KEY])
        nstate = int(mod.attrs[self.STATE_NUM_ATTR_KEY])

        # Check parameters and return values
        if len(func.params) < nparam + nstate:
            raise ValueError(
                "SetupTrainer: The number of parameters of the predict function should be no less "
                "than the number of parameters and states"
            )

        if nstate > 0:
            if not isinstance(func.body.body, Tuple) or len(func.body.body) <= nstate:
                raise ValueError(
                    "SetupTrainer: When model state exists, the predict function should return a "
                    "tuple of length more than the number of states"
                )

    def transform_module(self, mod: IRModule, ctx: tvm.transform.PassContext) -> IRModule:
        """Transform the backbone module into a trainer module."""
        self._check_well_formed(mod)

        mod = AppendLoss(
            self.BACKBONE_FUNC,
            self._loss(*self._loss_args),  # type: ignore
            self._loss.num_backbone_outputs,
            self.BACKBONE_LOSS_FUNC,
        )(mod)

        # Decompose batch_norm operator, which behaves differently in inference and training stages
        mod = DecomposeOpsForInference(self.BACKBONE_FUNC)(mod)
        mod = DecomposeOpsForTraining(self.BACKBONE_LOSS_FUNC)(mod)

        # Gradient pass.
        param_num = int(mod.attrs[self.PARAM_NUM_ATTR_KEY])
        state_num = int(mod.attrs[self.STATE_NUM_ATTR_KEY])
        input_num = len(mod[self.BACKBONE_FUNC].params) - param_num - state_num
        params = mod[self.BACKBONE_LOSS_FUNC].params[input_num : input_num + param_num]
        mod = Gradient(self.BACKBONE_LOSS_FUNC, require_grads=params, target_index=0)(mod)

        # Add optimizer function.
        self._optimizer.init(params)
        # Need the global symbol to match the function's name
        mod[self.OPTIMIZER_FUNC] = self._optimizer.get_function().with_attr(
            "global_symbol", self.OPTIMIZER_FUNC
        )

        # Module attrs
        mod = mod.with_attrs(
            {
                "input_num": input_num,
                "optim_state": self._optimizer.state,
            }
        )

        if self._legalize:
            mod = LegalizeOps()(mod)

        return mod
