# 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=invalid-name, redefined-builtin, no-else-return, consider-using-dict-items
# ruff: noqa: RUF005
"""The Relax virtual machine."""

from collections.abc import Callable
from enum import IntEnum
from numbers import Integral, Number
from typing import Any

import numpy as np  # type: ignore
from tvm_ffi import Function, register_global_func

import tvm
from tvm.runtime import Device, Object

from ..rpc.base import RPC_SESS_MASK


class VMInstrumentReturnKind(IntEnum):
    NO_OP = 0
    # skip the following call, only valid in before
    SKIP_RUN = 1


class VirtualMachine:
    """Relax VM runtime."""

    NAIVE_ALLOCATOR = 1
    POOLED_ALLOCATOR = 2

    def __init__(
        self,
        rt_mod: tvm.runtime.Module | tvm.runtime.Executable,
        device: Device | list[Device],
        memory_cfg: str | dict[Device, str] | None = None,
    ) -> None:
        """
        Construct a VirtualMachine wrapper object.

        Parameters
        ----------
        rt_mod: Union[tvm.runtime.Module, tvm.runtime.Executable]
            Runtime module exported by the result of build.

        device : Union[Device, List[Device]]
            The device to deploy the module.

        memory_cfg : Optional[Union[str, Dict[Device, str]]]
            Config the type of memory allocator. The allocator type can be ["naive",
            "pooled"]. If memory_cfg is None, all devices will use pooled allocator
            by default. If memory_cfg is string, all devices will use the specified
            allocator type. If memory_cfg is a dict, each device uses the allocator
            type specified in the dict, or pooled allocator if not specified in the
            dict.
        """
        if not isinstance(rt_mod, tvm.runtime.Module):
            if isinstance(rt_mod, tvm.runtime.Executable):
                rt_mod = rt_mod.jit()
            else:
                raise ValueError("Expect the rt_mod to be an runtime.Module")

        self.module = rt_mod["vm_load_executable"]()
        self._invoke_closure = self.module["invoke_closure"]
        self._save_function = self.module["save_function"]
        self._set_input = self.module["set_input"]
        self._invoke_stateful = self.module["invoke_stateful"]
        self._get_output = self.module["get_output"]
        self._get_output_arity = self.module["get_output_arity"]
        self._get_function_arity = self.module["get_function_arity"]
        self._get_function_param_name = self.module["get_function_param_name"]
        self._set_instrument = self.module["set_instrument"]
        self._setup_device(device, memory_cfg)

    def _setup_device(self, dev: Device, memory_cfg: str | dict[Device, str]) -> None:
        """init devices and allocators."""
        devs = dev
        if not isinstance(dev, list | tuple):
            if not isinstance(dev, tvm.runtime.Device):
                raise TypeError("dev is expected to be Device or List[Device]")
            devs = [dev]

        # CPU is required for executing shape functions
        if devs[-1].dlpack_device_type() % RPC_SESS_MASK != tvm.cpu().dlpack_device_type():
            devs.append(tvm.cpu())

        default_alloc_type = VirtualMachine.POOLED_ALLOCATOR
        if memory_cfg is None:
            memory_cfg = {}
        elif isinstance(memory_cfg, str):
            assert memory_cfg in ["naive", "pooled"]
            if memory_cfg == "naive":
                default_alloc_type = VirtualMachine.NAIVE_ALLOCATOR
            memory_cfg = {}
        elif not isinstance(memory_cfg, dict):
            raise TypeError(
                "memory_cfg is expected be string or dictionary, "
                + f"but received {type(memory_cfg)}"
            )
        init_args = []
        for device in devs:
            init_args.append(device.dlpack_device_type() % RPC_SESS_MASK)
            init_args.append(device.index)
            alloc_type = memory_cfg[device] if device in memory_cfg else default_alloc_type
            init_args.append(alloc_type)
        self.module["vm_initialization"](*init_args)

    def __getitem__(self, key: str) -> Function:
        return self.module[key]

    def invoke_closure(self, closure: Object, *args: Any) -> Object:
        """Invoke a closure.

        Parameters
        ----------
        closure : Object
            The VMClosure Object.

        args : list[tvm.runtime.Tensor] or list[np.ndarray]
            The arguments to the closure.

        Returns
        -------
        result : Object
            The output.
        """
        return self._invoke_closure(closure, *args)

    def save_function(
        self,
        func_name: str,
        saved_name: str,
        *args: list[Any],
        include_return: bool = True,
        **kwargs: dict[str, Any],
    ) -> None:
        """
        Convenience function. Takes a function from the module and saves
        a `Function` that, when called, will invoke the function with the given arguments.
        The `Function` can be accessed from the module using `saved_name`.
        This is included to facilitate timing trials:
        Invoking the returned `Function` will have less overhead from dictionary lookups
        than normally running through the VM.

        If the saved name is taken, it can be overridden, though it cannot override
        the name of a function defined in the Relax source.

        This is really creating a closure, but the function has a different name
        to avoid confusion with `invoke_closure` (they are not meant to be used together).

        Parameters
        ----------
        func_name : str
            The function that should be packaged up.

        saved_name : str
            The name that the resulting closure should be saved under.

        include_return : bool
            Whether the saved Function should return its output.
            If timing over RPC, it may not be desirable to send output
            between machines.

        args : List[Any]
            The arguments to package up with the function.

        kwargs : Dict[str, Any]
            Any named arguments to package up with the function
        """
        cargs: list[Any] = []
        if kwargs:
            args = self._convert_func_named_args(func_name, args, **kwargs)
        for arg in args:
            self._convert(arg, cargs)
        self._save_function(func_name, saved_name, int(include_return), *cargs)

    def _convert(self, arg: Any, cargs: list) -> None:
        """helper function to convert arguments to vm function."""

        def _gettype(arg):
            if isinstance(arg, np.float16):
                return "float16"
            elif isinstance(arg, Integral | bool):
                return "int32"
            else:
                return "float32"

        if isinstance(arg, Object):
            cargs.append(arg)
        elif isinstance(arg, np.ndarray):
            nd_arr = tvm.runtime.tensor(arg, device=tvm.cpu(0))
            cargs.append(nd_arr)
        elif isinstance(arg, tvm.runtime.Tensor):
            cargs.append(arg)
        elif isinstance(arg, tuple | list):
            field_args: list[Any] = []
            for field in arg:
                self._convert(field, field_args)
            cargs.append(tuple(field_args))
        elif isinstance(arg, Number | bool):
            dtype = _gettype(arg)
            value = tvm.runtime.tensor(np.array(arg, dtype=dtype), device=tvm.cpu(0))
            cargs.append(value)
        elif isinstance(arg, str):
            cargs.append(arg)
        else:
            raise TypeError(f"Unsupported type: {type(arg)}")

    def _convert_func_named_args(self, func_name: str, args: Any, **kwargs: Any) -> Any:
        """
        Takes named function parameters and returns a list of those needed,
        in the order they should appear
        """
        # kwargs can be a super set of the required function parameters.
        # We only find the ones that are needed.
        func_arity = self._get_function_arity(func_name)
        func_params = [self._get_function_param_name(func_name, i) for i in range(func_arity)]
        new_args = [None] * len(func_params)
        cnt = 0
        for k in kwargs:
            if k in func_params:
                idx = func_params.index(k)
                new_args[idx] = kwargs[k]
                cnt += 1
            else:
                print(f'Warning: Keyword argument "{k}" is unused in {func_name}')
        assert len(args) + cnt == len(func_params)
        idx = 0
        for i, arg in enumerate(new_args):
            if arg is None:
                new_args[i] = args[idx]
                idx += 1
        return new_args

    def set_input(self, func_name: str, *args: Any, **kwargs: Any) -> None:
        """Set the inputs to a function.
        This interface works when using VM over RPC by internally converting Tensor in
        the arguments to DLTensor, which is supported in RPC where remote could only
        have a minimal C runtime.

        Note: If `set_input` is used, the function *must* be called using `invoke_stateful`
        and the results must be obtained using `get_outputs`.

        Parameters
        ----------
        func_name : str
            The name of the function.
        args: List[tvm.runtime.Tensor] or List[np.ndarray]
            The arguments to the function.
        kwargs: dict of str to tvm.runtime.Tensor or np.ndarray
            Named arguments to the function.
        """
        cargs: list[Any] = []

        if kwargs:
            args = self._convert_func_named_args(func_name, args, **kwargs)

        for arg in args:
            self._convert(arg, cargs)

        self._set_input(func_name, *cargs)

    def invoke_stateful(self, func_name: str) -> None:
        """
        Call the named function from the VM module using the arguments set using `set_input`.
        It is an error to call `invoke_stateful` without using `set_input` first
        (even if it's to set 0 inputs); conversely, if `set_input` has been called,
        it is an error to call the function without using `invoke_stateful`.

        The results of the call can be obtained by calling `get_outputs`.

        Parameters
        ----------
        func_name: str
            The name of the function to call.
        """
        self._invoke_stateful(func_name)

    def get_outputs(self, func_name: str) -> tvm.Object | tuple[Any]:
        """
        Get the value output by the function by the given name
        after a call of `invoke_stateful`.

        It is an error to call this function without first calling `invoke_stateful`.

        Parameters
        ----------
        func_name: str
            The name of the function whose output should be fetched.

        Returns
        -------
        ret: Union[tvm.Object, Tuple[Any]]
            The result of the earlier call to the function via `invoke_stateful`.
            If the result is a tuple, it returns a list of the fields.
            The fields are potentially also tuples, so these can be arbitrily nested.
        """

        # to deal with potentially nested tuples, we need to query for arity recursively
        def get_output_rec(func_name, *idx):
            arity = self._get_output_arity(func_name, *idx)
            if arity == -1:
                return self._get_output(func_name, *idx)
            # otherwise we need to specify more indices
            idx_list = list(idx)
            return tuple(get_output_rec(func_name, *(idx_list + [i])) for i in range(arity))

        return get_output_rec(func_name)

    def set_instrument(self, instrument: Function) -> None:
        """Set an instrumentation function.

        If instrument is present, the function will be called
        before/after each Call instruction. The function have
        the following signature:

        .. code:: python

            def instrument(
                func: Union[VMClosure, Function],
                func_symbol: str,
                before_run: bool,
                ret_value: any,
                *args) -> bool:
                pass

        The instrument takes the following parameters:
        - func: function object to be called.
        - func_symbol: the symbol name of the function.
        - before_run: whether it is before or after call.
        - ret_value: the return value of the call, only valid after run.
        - args: the arguments being passed to call.

        The instrument function can choose an integer,
        which corresponds to action direction for the
        following run. See VMInstrumentReturnKind for
        more details.

        Parameters
        ----------
        instrument: tvm_ffi.Function
            A instrumentation function that get invoked every VM call instr.

        See Also
        --------
        VMInstrumentReturnKind: the possible return values in VM.
        """
        self._set_instrument(instrument)

    def time_evaluator(
        self,
        func_name: str,
        dev: Device,
        number: int = 10,
        repeat: int = 1,
        min_repeat_ms: int = 0,
        cooldown_interval_ms: int = 0,
        repeats_to_cooldown: int = 1,
        f_preproc: str = "",
    ) -> Callable[..., tvm.runtime.module.BenchmarkResult]:
        """
        Returns an evaluator that times a function in the module.
        This follows the same convention as time_evaluator in tvm.runtime.module.
        This can be used in combination with save_function() so that the
        timings avoid extra dictionary lookups.

        Parameters
        ----------
        func_name: str
            The name of the function in the module.

        dev: Device
            The device we should run this function on.

        number: int
            The number of times to run this function for taking average.
            We call these runs as one `repeat` of measurement.

        repeat: int, optional
            The number of times to repeat the measurement.
            In total, the function will be invoked (1 + number x repeat) times,
            where the first one is warm up and will be discarded.
            The returned result contains `repeat` costs,
            each of which is an average of `number` costs.

        min_repeat_ms: int, optional
            The minimum duration of one `repeat` in milliseconds.
            By default, one `repeat` contains `number` runs. If this parameter is set,
            the parameters `number` will be dynamically adjusted to meet the
            minimum duration requirement of one `repeat`.
            i.e., When the run time of one `repeat` falls below this time, the `number` parameter
            will be automatically increased.

        cooldown_interval_ms: int, optional
            The cooldown interval in milliseconds between the number of repeats defined by
            `repeats_to_cooldown`.

        repeats_to_cooldown: int, optional
            The number of repeats before the cooldown is activated.

        f_preproc: str, optional
            The preprocess function name we want to execute before executing the time evaluator.

        Note
        ----
        The function will be invoked  (1 + number x repeat) times,
        with the first call discarded in case there is lazy initialization.

        Example
        -------
        Normal use with a VM function (may not work over RPC if the function returns a tuple):

        .. code-block:: python

            target = tvm.target.Target("llvm", host="llvm")
            ex = tvm.compile(TestTimeEvaluator, target)
            vm = relax.VirtualMachine(mod, tvm.cpu())
            timing_res = vm.time_evaluator("func_name", tvm.cpu())(arg0, arg1, ..., argn)

        Use with the stateful API:

        .. code-block:: python

            target = tvm.target.Target("llvm", host="llvm")
            ex = tvm.compile(TestTimeEvaluator, target)
            vm = relax.VirtualMachine(mod, tvm.cpu())
            vm.set_input("func_name", arg0, arg1, ..., argn)
            timing_res = vm.time_evaluator("invoke_stateful", tvm.cpu())("func_name")

        With saved closures via `save_function` (this results in
        fewer dictionary lookups in the timed portion):

        .. code-block:: python

            target = tvm.target.Target("llvm", host="llvm")
            ex = tvm.compile(TestTimeEvaluator, target)
            vm = relax.VirtualMachine(mod, tvm.cpu())
            vm.save_function("func_name", "func_name_saved", arg0, arg1, ..., argn)
            timing_res = vm.time_evaluator("func_name_saved", tvm.cpu())()

        Returns
        -------
        ftimer : function
            The function that takes same argument as func and returns a BenchmarkResult.
            The ProfileResult reports `repeat` time costs in seconds.

        """
        return self.module.time_evaluator(
            func_name,
            dev,
            number=number,
            repeat=repeat,
            min_repeat_ms=min_repeat_ms,
            cooldown_interval_ms=cooldown_interval_ms,
            repeats_to_cooldown=repeats_to_cooldown,
            f_preproc=f_preproc,
        )


@register_global_func("vm.builtin.debug_print")
def _print(lineo: str, array) -> None:
    print(f"{lineo}: shape = {array.shape}, dtype = {array.dtype}, data =\n{array}")
