# 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, F821
"""BasePyModule: Base class for IRModules with Python function support."""

import inspect
import os
from typing import Any, Optional, Union

import numpy as np
from tvm_ffi import Function

import tvm
from tvm import relax, tirx
from tvm.ir import IRModule
from tvm.runtime import Device, Tensor
from tvm.target import Target

try:
    from torch.utils.dlpack import to_dlpack as to_dlpack_legacy
except ImportError:
    to_dlpack_legacy = None

try:
    from tvm_ffi._optional_torch_c_dlpack import load_torch_c_dlpack_extension

    _FASTER_DLPACK_EXTENSION = load_torch_c_dlpack_extension()
except ImportError:
    _FASTER_DLPACK_EXTENSION = None


class BasePyModule:
    """Base class that allows Python functions in IRModule with DLPack conversion.

    This class provides the infrastructure for:
    1. JIT compilation of TIR and Relax functions.
    2. DLPack-based conversion between PyTorch tensors and TVM Tensors.
    3. Wrapping Relax functions for easy Python calling.
    4. Cross-function calls between Python, TIR, and Relax functions.

    Only IRModules that inherit from this class are allowed to contain Python functions.
    """

    def __del__(self):
        """Clean up registered Python functions on module destruction."""
        try:
            clear_func = tvm.get_global_func("vm.builtin.clear_py_func_registry")
            clear_func()
        except (ValueError, AttributeError):
            pass

    def __init__(
        self,
        ir_mod: IRModule,
        device: Device,
        target: Target | None = None,
    ):
        """Initialize BasePyModule with JIT compilation and DLPack conversion."""
        self.device = device
        self.ir_mod = ir_mod

        # Delegate IRModule operations
        self.functions = ir_mod.functions
        self.attrs = ir_mod.attrs
        self.global_infos = ir_mod.global_infos
        self.__getitem__ = ir_mod.__getitem__
        self.__setitem__ = ir_mod.__setitem__
        self.functions_items = ir_mod.functions_items
        self.with_attr = ir_mod.with_attr
        self.get_attr = ir_mod.get_attr
        self.update_global_info = ir_mod.update_global_info

        def _getattr_python_function(name: str) -> Any:
            """Support direct attribute access to funcs and IRModule methods."""
            if name in self.pyfuncs:
                return self.pyfuncs[name]
            if name in self.compiled_tir_funcs:
                return self.compiled_tir_funcs[name]
            if self.relax_vm and name in self.relax_func_names:
                try:
                    return self.relax_vm[name]
                except AttributeError:  # More specific exception
                    return None
            if hasattr(self.ir_mod, name):
                return getattr(self.ir_mod, name)
            raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'")

        self.__getattr__ = _getattr_python_function

        self.compiled_tir_funcs: dict[str, Function] = {}
        self.extern_funcs: dict[str, Function] = {}
        self.tir_func_names: list[str] = []
        self.relax_func_names: list[str] = []
        self.relax_vm: relax.VirtualMachine | None = None
        self.pyfuncs: dict[str, Any] = {}

        if target is None:
            target = Target.from_device(device)
        elif isinstance(target, str):
            target = Target(target)
        self.target = target

        self._collect_function_names()
        self._compile_functions()
        self._wrap_tir_functions()
        self._wrap_relax_functions()
        self._register_python_functions()

    def _collect_function_names(self):
        """Collect names of TIR and Relax functions from IRModule."""
        for global_var, func in self.ir_mod.functions_items():
            if isinstance(func, tirx.PrimFunc):
                self.tir_func_names.append(global_var.name_hint)
            elif isinstance(func, relax.Function):
                self.relax_func_names.append(global_var.name_hint)

    def _compile_functions(self):
        """Compile TIR and Relax functions using JIT compilation."""
        # Compile TIR functions first
        tir_mod = tvm.IRModule(
            {
                gv: func
                for gv, func in self.ir_mod.functions_items()
                if isinstance(func, tirx.PrimFunc)
            }
        )
        if tir_mod:
            try:
                tir_exec_mod = tvm.compile(tir_mod, target=self.target)
                for func_name in self.tir_func_names:
                    self.compiled_tir_funcs[func_name] = tir_exec_mod[func_name]
            # pylint: disable=broad-exception-caught
            except Exception as error:
                print(f"Warning: Failed to compile one or more TIR functions: {error}")

        if self.relax_func_names:
            try:
                exec_mod = tvm.compile(self.ir_mod, target=self.target)
                self.relax_vm = relax.VirtualMachine(exec_mod, self.device)
            # pylint: disable=broad-exception-caught
            except Exception as error:
                print(f"Warning: Failed to compile Relax VM: {error}")
                self.relax_vm = None

    def _wrap_tir_functions(self):
        """Wrap TIR functions to make them accessible as instance attributes."""
        for func_name, func in self.compiled_tir_funcs.items():
            setattr(self, func_name, func)

    def _wrap_relax_functions(self):
        """Wrap Relax functions to be callable from Python with auto conversion."""
        for func_name in self.relax_func_names:

            def _create_relax_wrapper(name):
                def wrapper(*args, **kwargs):
                    """Wrapper for Relax function with automatic tensor conversion."""
                    if hasattr(self.ir_mod, "pyfuncs") and name in self.ir_mod.pyfuncs:
                        return self.ir_mod.pyfuncs[name](*args, **kwargs)

                    if self.relax_vm is not None:
                        converted_args = self._convert_pytorch_to_tvm(list(args))
                        converted_kwargs = {
                            k: self._convert_pytorch_to_tvm(v) for k, v in kwargs.items()
                        }
                        result = self.relax_vm[name](*converted_args, **converted_kwargs)
                        return self._convert_tvm_to_pytorch(result)

                    raise RuntimeError(
                        f"Neither converted Python function nor Relax VM available for {name}"
                    )

                wrapper.__name__ = name
                wrapper.__doc__ = f"Wrapped Relax function: {name}"
                return wrapper

            setattr(self, func_name, _create_relax_wrapper(func_name))

    def _register_python_functions(self):
        """Register Python functions with the VM runtime for call_py_func support."""
        if not hasattr(self.ir_mod, "pyfuncs") or not self.ir_mod.pyfuncs:
            return

        try:
            register_py_func = tvm.get_global_func("vm.builtin.register_py_func")
        except ValueError:
            return

        for func_name, py_func in self.ir_mod.pyfuncs.items():

            def create_py_func_wrapper(name, original_func):
                def wrapper(*args, **kwargs):
                    converted_args = [self._convert_tvm_to_pytorch(arg) for arg in args]
                    converted_kwargs = {
                        k: self._convert_tvm_to_pytorch(v) for k, v in kwargs.items()
                    }

                    result = original_func(self, *converted_args, **converted_kwargs)

                    return self._convert_pytorch_to_tvm(result)

                wrapper.__name__ = name
                return wrapper

            wrapped_func = create_py_func_wrapper(func_name, py_func)
            register_py_func(func_name, wrapped_func)

    def call_tir(self, tir_func, args, out_ty):
        """Call a TIR function with PyTorch tensors."""
        # Try to get function name from different sources
        if isinstance(tir_func, str):
            func_name = tir_func
        elif hasattr(tir_func, "name"):
            func_name = tir_func.name
        elif hasattr(tir_func, "__name__"):
            func_name = tir_func.__name__
        else:
            # Try to find by function object reference
            for name, func in self.compiled_tir_funcs.items():
                if func == tir_func:
                    func_name = name
                    break
            else:
                func_name = None

        if not func_name or func_name not in self.compiled_tir_funcs:
            available_funcs = list(self.compiled_tir_funcs.keys())
            raise ValueError(
                f"Could not resolve or find compiled TIR function: {tir_func}. "
                f"Available functions: {available_funcs}"
            )
        func = self.compiled_tir_funcs[func_name]

        out = self._create_output_tensors(out_ty, args)
        tvm_args = self._convert_pytorch_to_tvm(args)
        tvm_out = self._convert_pytorch_to_tvm(out)

        func(*tvm_args, *tvm_out)

        result = self._convert_tvm_to_pytorch(tvm_out)
        return result[0] if len(result) == 1 else result

    def call_dps_packed(self, func_name: str, args, out_ty):
        """Call a packed function with PyTorch tensors, converting TVM Tensors via DLPack."""
        if hasattr(self, func_name) and callable(getattr(self, func_name)):
            return getattr(self, func_name)(*args)

        if func_name not in self.extern_funcs:
            try:
                self.extern_funcs[func_name] = tvm.get_global_func(func_name)
            except ValueError as error:
                raise ValueError(
                    f"Function '{func_name}' not found as a global function. "
                    f"Please implement it as a method or register it."
                ) from error
        func = self.extern_funcs[func_name]

        out = self._create_output_tensors(out_ty, args)
        tvm_args = self._convert_pytorch_to_tvm(args)
        tvm_out = self._convert_pytorch_to_tvm(out)
        func(*tvm_args, *tvm_out)
        return out[0] if len(out) == 1 else out

    def call_py_func(self, func_name: str, args):
        """Call a Python function stored in the module's pyfuncs."""
        if func_name not in self.pyfuncs:
            raise ValueError(f"Python function '{func_name}' not found in module pyfuncs")
        py_func = self.pyfuncs[func_name]
        return py_func(self, *args)

    def _create_output_tensors(self, out_ty, in_args=None):
        # pylint: disable=import-outside-toplevel
        import torch

        ty_list = out_ty if isinstance(out_ty, list) else [out_ty]
        out_tensors = []
        for ty in ty_list:
            if isinstance(ty, tuple | list) and all(isinstance(x, int | np.integer) for x in ty):
                out_tensors.append(torch.zeros(list(map(int, ty)), dtype=torch.float32))
                continue

            if hasattr(ty, "shape") and hasattr(ty, "dtype"):
                concrete_shape = self._infer_concrete_shape_from_args(ty.shape, in_args)
                torch_dtype = self._convert_tvm_dtype_to_torch(ty.dtype)
                out_tensors.append(torch.zeros(concrete_shape, dtype=torch_dtype))
                continue

            out_tensors.append(torch.zeros((1,), dtype=torch.float32))
        return out_tensors

    def _infer_concrete_shape_from_args(self, shape, in_args):
        concrete = []
        symbolic_positions = []
        for idx, dim in enumerate(shape):
            if isinstance(dim, int | np.integer):
                concrete.append(int(dim))
            elif isinstance(dim, tirx.IntImm):
                concrete.append(int(dim.value))
            else:
                concrete.append(None)
                symbolic_positions.append(idx)

        if not symbolic_positions:
            return concrete

        candidates = []
        if in_args is not None:
            if not isinstance(in_args, list | tuple):
                in_args = [in_args]
            for obj in in_args:
                if hasattr(obj, "shape") and isinstance(obj.shape, tuple | list):
                    try:
                        candidates.append(tuple(int(x) for x in obj.shape))
                        continue
                    except (ValueError, TypeError):
                        # Skip objects with invalid shapes
                        pass

        target_ndim = len(shape)
        for cand in candidates:
            if len(cand) == target_ndim:
                for pos in symbolic_positions:
                    concrete[pos] = cand[pos]
                if all(x is not None for x in concrete):
                    return concrete

        raise ValueError(
            "Cannot infer concrete output shape from symbolic shape and inputs. "
            "Please provide a concrete `out_ty` (e.g., a tuple/list of ints) "
            "or ensure input tensors carry shapes that determine output extents."
        )

    def _convert_tvm_dtype_to_torch(self, tvm_dtype: str) -> "torch.dtype":
        """Convert TVM dtype string to PyTorch dtype."""
        # pylint: disable=import-outside-toplevel
        import torch

        dtype_mapping = {
            "float32": torch.float32,
            "float64": torch.float64,
            "int32": torch.int32,
            "int64": torch.int64,
            "bool": torch.bool,
        }
        return dtype_mapping.get(str(tvm_dtype), torch.float32)

    def _convert_pytorch_to_tvm(
        self, tensors: Any | list[Any] | tuple[Any, ...]
    ) -> Tensor | list[Tensor]:
        """Convert PyTorch tensors to TVM Tensors using DLPack."""
        # pylint: disable=import-outside-toplevel
        import torch

        if isinstance(tensors, list | tuple):
            return [self._convert_single_pytorch_to_tvm(t) for t in tensors]
        return self._convert_single_pytorch_to_tvm(tensors)

    def _convert_single_pytorch_to_tvm(self, tensor: Any) -> Tensor:
        """Convert a single PyTorch tensor to TVM Tensor with faster DLPack converter."""
        # pylint: disable=import-outside-toplevel
        import torch

        if isinstance(tensor, Tensor):
            return tensor
        if isinstance(tensor, torch.Tensor):
            # 1. Try faster C++ DLPack converter
            if _FASTER_DLPACK_EXTENSION is not None:
                try:
                    dlpack = torch.to_dlpack(tensor)
                    return tvm.runtime.from_dlpack(dlpack)
                except (AttributeError, ValueError):
                    pass  # Fall through to the next method

            # 2. Try modern `torch.to_dlpack` (preferred for PyTorch >= 1.7)
            try:
                dlpack = torch.to_dlpack(tensor)
                return tvm.runtime.from_dlpack(dlpack)
            except (AttributeError, ValueError):
                pass  # Fall through to the next method

            # 3. Try legacy `torch.utils.dlpack.to_dlpack`
            if to_dlpack_legacy:
                try:
                    dlpack = to_dlpack_legacy(tensor)
                    return tvm.runtime.from_dlpack(dlpack)
                except (AttributeError, ValueError) as error_legacy:
                    print(
                        f"Warning: Legacy DLPack conversion failed ({error_legacy}), "
                        f"using numpy fallback."
                    )

            # 4. If all DLPack methods fail, use numpy fallback
            numpy_array = tensor.detach().cpu().numpy()
            return tvm.runtime.tensor(numpy_array, device=self.device)

        # For other types (like scalars, lists), convert to numpy first
        try:
            numpy_array = np.array(tensor, dtype=np.float32)
            return tvm.runtime.tensor(numpy_array, device=self.device)
        except (TypeError, ValueError) as error:
            raise TypeError(
                f"Unsupported type for conversion to TVM Tensor: {type(tensor)}"
            ) from error

    def _convert_tvm_to_pytorch(
        self, tvm_tensors: Any | list[Any]
    ) -> Union["torch.Tensor", list["torch.Tensor"]]:
        """Convert TVM Tensors to PyTorch tensors using DLPack."""
        if isinstance(tvm_tensors, list | tuple):
            return [self._convert_single_tvm_to_pytorch(tensor) for tensor in tvm_tensors]
        return self._convert_single_tvm_to_pytorch(tvm_tensors)

    def _convert_single_tvm_to_pytorch(self, tvm_tensor: Any) -> "torch.Tensor":
        """Convert a single TVM Tensor to PyTorch tensor using faster DLPack converter."""
        # pylint: disable=import-outside-toplevel
        import torch

        if isinstance(tvm_tensor, torch.Tensor):
            return tvm_tensor
        if not isinstance(tvm_tensor, Tensor):
            return torch.tensor(tvm_tensor)

        # 1. Try faster C++ DLPack converter
        if _FASTER_DLPACK_EXTENSION is not None:
            try:
                return torch.from_dlpack(tvm_tensor)
            except (AttributeError, ValueError):
                pass  # Fall through to the next method

        # 2. Try standard DLPack conversion
        try:
            return torch.from_dlpack(tvm_tensor)
        # pylint: disable=broad-exception-caught
        except Exception as error:
            print(f"Warning: DLPack conversion from TVM failed ({error}), using numpy fallback")
            numpy_array = tvm_tensor.numpy()
            return torch.from_numpy(numpy_array)

    def get_function(self, name: str) -> Function | None:
        """Get a compiled function by name."""
        if name in self.compiled_tir_funcs:
            return self.compiled_tir_funcs[name]
        if name in self.extern_funcs:
            return self.extern_funcs[name]
        if self.relax_vm and name in self.relax_func_names:
            try:
                if hasattr(self, name):
                    return getattr(self, name)
                return self.relax_vm[name]
            except AttributeError as error:
                print(f"Warning: Failed to get Relax function '{name}': {error}")
        return None

    def list_functions(self) -> dict[str, list[str]]:
        """List all available functions."""
        return {
            "tirx": self.tir_func_names,
            "relax": self.relax_func_names,
            "extern": list(self.extern_funcs.keys()),
        }

    def add_python_function(self, name: str, func: callable):
        """Add a Python function to the module."""
        self.pyfuncs[name] = func

        # Create a wrapper that handles both instance methods and static functions
        # pylint: disable=import-outside-toplevel
        import functools

        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            sig = inspect.signature(func)
            params = list(sig.parameters.keys())

            if params and params[0] == "self":
                return func(self, *args, **kwargs)
            else:
                return func(*args, **kwargs)

        # Set the wrapper as an instance attribute
        setattr(self, name, wrapper)

    def script(
        self,
        *,
        name: str | None = None,
        show_meta: bool = False,
        ir_prefix: str = "I",
        module_alias: str = "cls",
        int_dtype: str = "int32",
        float_dtype: str = "void",
        verbose_expr: bool = False,
        indent_spaces: int = 4,
        print_line_numbers: bool = False,
        num_context_lines: int = -1,
        syntax_sugar: bool = True,
        show_object_address: bool = False,
        show_all_ty: bool = True,
        extra_config: dict | None = None,
    ) -> str:
        """Print TVM IR into TVMScript text format with Python function support.

        This method extends the standard IRModule script() method to handle
        Python functions stored in the IRModule's pyfuncs attribute.
        """
        # First get the standard IRModule script
        base_script = self.ir_mod.script(
            name=name,
            show_meta=show_meta,
            ir_prefix=ir_prefix,
            module_alias=module_alias,
            int_dtype=int_dtype,
            float_dtype=float_dtype,
            verbose_expr=verbose_expr,
            indent_spaces=indent_spaces,
            print_line_numbers=print_line_numbers,
            num_context_lines=num_context_lines,
            syntax_sugar=syntax_sugar,
            show_object_address=show_object_address,
            show_all_ty=show_all_ty,
            extra_config=extra_config,
        )

        # If there are no Python functions, return the base script
        if not hasattr(self.ir_mod, "pyfuncs") or not self.ir_mod.pyfuncs:
            return base_script

        # Insert Python functions into the script
        return self._insert_python_functions(base_script, indent_spaces)

    def _insert_python_functions(self, base_script: str, indent_spaces: int) -> str:
        """Insert Python functions into the TVMScript output."""
        lines = base_script.split("\n")
        result_lines = []

        # Find the class definition line and insert Python functions after it
        class_found = False
        class_indent = 0

        for line in lines:
            result_lines.append(line)

            # Look for class definition
            if not class_found and line.strip().startswith("class "):
                class_found = True
                class_indent = len(line) - len(line.lstrip())

                # Insert Python functions after the class definition
                if hasattr(self.ir_mod, "pyfuncs") and self.ir_mod.pyfuncs:
                    for func_name, func in self.ir_mod.pyfuncs.items():
                        # Get the function source code
                        func_source = self._get_function_source(func)
                        if func_source:
                            # Format the function with proper indentation
                            formatted_func = self._format_python_function(
                                func_name, func_source, class_indent + indent_spaces
                            )
                            result_lines.append(formatted_func)
                            result_lines.append("")  # Add empty line for separation

        return "\n".join(result_lines)

    def _get_function_source(self, func: callable) -> str | None:
        """Get the source code of a Python function."""
        try:
            source = inspect.getsource(func)
            return source
        except (OSError, TypeError):
            # If we can't get the source, return None
            return None

    def _format_python_function(self, _func_name: str, func_source: str, indent: int) -> str:
        """Format a Python function with proper indentation for TVMScript."""
        lines = func_source.split("\n")
        formatted_lines = []

        for line in lines:
            # Skip the function definition line if it's already properly indented
            if line.strip().startswith("def ") or line.strip().startswith("@"):
                # Keep decorators and function definition as is
                formatted_lines.append(" " * indent + line.strip())
            else:
                # Add proper indentation for the function body
                formatted_lines.append(" " * indent + line.strip())

        return "\n".join(formatted_lines)

    def show(self, style: str | None = None, black_format: bool | None = None, **kwargs) -> None:
        """A sugar for print highlighted TVM script with Python function support.

        This method extends the standard IRModule show() method to handle
        Python functions stored in the IRModule's pyfuncs attribute.
        """
        from tvm.script.highlight import cprint  # pylint: disable=import-outside-toplevel

        if black_format is None:
            env = os.environ.get("TVM_BLACK_FORMAT")
            black_format = env and int(env)

        script_content = self.script(**kwargs)
        cprint(script_content, style=style, black_format=black_format)
