# 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=too-many-lines,invalid-name,protected-access
"""nn.Module mixin for subroutine dispatch"""

import collections
import contextlib
import functools
import inspect
import re
import typing

import tvm_ffi

from tvm import ir, relax
from tvm.relax.frontend import nn


def _camel_to_snake(name):
    """Convert from CamelCase to snake_case"""

    # Adapted from https://stackoverflow.com/a/1176023
    name = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", name)
    name = re.sub("([a-z0-9])([A-Z])", r"\1_\2", name)
    name = name.lower()
    return name


def _normalize_expr(block_builder, arg, as_relax_expr=False):
    """Ensure that an argument is a relax.Expr with type"""
    if isinstance(arg, tuple):
        arg = relax.Tuple([_normalize_expr(block_builder, element) for element in arg])

    if isinstance(arg, relax.Expr) and arg.ty.is_missing():
        arg = block_builder.emit(arg)

    if isinstance(arg, nn.Tensor) and as_relax_expr:
        arg = arg._expr

    return arg


def _get_ty(arg):
    if isinstance(arg, relax.Expr):
        return arg.ty
    elif isinstance(arg, nn.Tensor):
        return arg._expr.ty
    elif isinstance(arg, tuple | list | tvm_ffi.Array):
        return relax.TupleType([_get_ty(field) for field in arg])
    else:
        raise TypeError(f"Cannot find type for {arg} of type {type(arg)}")


class SubroutineMixin:
    """A mixin that generates a

    Contains common logic for `tvm.relax.frontend.nn.Module` and
    `tvm.relax.testing.nn.Module`.
    """

    define_subroutine: bool = False

    def __init_subclass__(cls):
        """Update the cls.forward of subclasses"""
        if hasattr(cls, "forward"):
            is_wrapped = getattr(cls.forward, "_is_subroutine_mixin", False)
            if not is_wrapped:
                cls.forward = cls._subroutine_dispatch(cls.forward)

    @classmethod
    def _subroutine_dispatch(cls, old_forward):
        @functools.wraps(old_forward)
        def new_forward(self, *args, **kwargs):
            if not self.define_subroutine:
                return old_forward(self, *args, **kwargs)

            block_builder = relax.BlockBuilder.current()
            assert block_builder is not None, (
                f"Class {type(self)} has cls.define_subroutines = True, "
                "but is called outsdie of a block_builder environment.  "
                "relax.BlockBuilder.current() is required "
                "to determine where to generate the subroutine."
            )

            func_args = self._normalize_subroutine_args(block_builder, *args, **kwargs)
            subroutine, is_nn_tensor_output = self._get_subroutine(
                block_builder, old_forward, func_args
            )
            subroutine_args = [
                arg._expr if isinstance(arg, nn.Tensor) else arg
                for arg in [*func_args.values(), *self.parameters()]
            ]

            out = subroutine(*subroutine_args)

            if is_nn_tensor_output:
                if out.ty.is_missing():
                    out = block_builder.emit(out, name_hint=f"{subroutine.name_hint}_output")
                out = nn.Tensor(_expr=out)
            return out

        new_forward._is_subroutine_mixin = True

        return new_forward

    def _normalize_subroutine_args(
        self, block_builder, *args, **kwargs
    ) -> typing.OrderedDict[str, relax.Expr]:
        signature = inspect.signature(self.forward)
        bindings = signature.bind(*args, **kwargs)
        func_args = collections.OrderedDict(
            (name, _normalize_expr(block_builder, arg)) for name, arg in bindings.arguments.items()
        )
        return func_args

    def _get_subroutine(
        self,
        block_builder,
        old_forward: typing.Callable,
        func_args: typing.OrderedDict[str, relax.Expr],
    ) -> (ir.GlobalVar, bool):
        cls = type(self)
        if not hasattr(cls, "_gvar"):
            cls._gvar = {}

        model_params = [
            param._expr if isinstance(param, nn.Tensor) else param for param in self.parameters()
        ]

        arg_ty = _get_ty([*func_args.values(), *model_params])
        is_dataflow = block_builder.current_block_is_dataflow()
        lookup_key = (
            old_forward,
            tvm_ffi.structural_hash(arg_ty, map_free_vars=True),
            is_dataflow,
        )

        for cached_ty, cached_result in cls._gvar.get(lookup_key, []):
            if tvm_ffi.structural_equal(cached_ty, arg_ty, map_free_vars=True):
                return cached_result

        func_name = _camel_to_snake(cls.__name__)
        func_params = [relax.Var(name, ty) for name, ty in zip(func_args, arg_ty.fields)]
        old_forward_args = [
            nn.Tensor(_expr=param) if isinstance(old_arg, nn.Tensor) else param
            for param, old_arg in zip(func_params, func_args.values())
        ]

        with block_builder.function(func_name, [*func_params, *model_params], private=True):
            with contextlib.ExitStack() as stack:
                if is_dataflow:
                    stack.enter_context(block_builder.dataflow())

                out = old_forward(self, *old_forward_args)
                is_nn_tensor_output = isinstance(out, nn.Tensor)
                if is_nn_tensor_output:
                    out = out._expr

                if is_dataflow:
                    out = block_builder.emit_output(out)
            gvar = block_builder.emit_func_output(out)

        # The relax.Var instances in model_params, along with any
        # tirx.Var instances in the type, appear in both the
        # calling scope and as parameters for the subroutine.  To
        # maintain SSA, replace all relax and TIR variables in the
        # subroutine.
        mod = block_builder.get()
        mod.update_func(gvar, relax.utils.copy_with_new_vars(mod[gvar]))

        result = (gvar, is_nn_tensor_output)
        bucket = cls._gvar.setdefault(lookup_key, [])
        bucket.append((arg_ty, result))
        return result
