# Part of the LLVM Project, under the Apache License v2.0 with LLVM Exceptions.
# See https://llvm.org/LICENSE.txt for license information.
# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
# Also available under a BSD-style license. See LICENSE.

from typing import Optional, Sequence, Union, List, Dict, Tuple, Callable, Iterable
from enum import Enum

import sys
from io import StringIO
import tempfile

from torch._functorch.compile_utils import strip_overloads
import torch
import torch.fx
from torch_mlir.dynamo import _get_decomposition_table
from torch.fx.experimental.proxy_tensor import make_fx

from .compiler_utils import run_pipeline_with_repro_report
from torch_mlir.dialects.torch.importer.jit_ir import ClassAnnotator, ImportOptions, ModuleBuilder
from torch_mlir.dialects.torch.importer.jit_ir.build_tools.library_generator import generate_library


class OutputType(Enum):
    """The kind of output that `torch_mlir.compile` can produce.

    In MLIR terminology, this describes the mix of dialects that will be
    produced by the conversion process.

    In user-facing API's, this type can always be passed interchangeably with an
    appropriate string specifying the output type. The allowed strings are
    the set of enum vales, allowed to be case insensitive and with `-` allowed
    in place of `_`. The `OutputType.get` static method can be used to convert
    from a string to an `OutputType` instance.
    """

    # This output type consists of `torch` dialect ops that have been converted
    # maximally to value semantics, decomposed, and shapes have been inferred.
    TORCH = "torch"

    # The output type contains a mix of `linalg`-on-tensors ops, `scf`, and
    # `arith` ops (and also `math` and `tm_tensor`). It can be thought of
    # as taking the `TORCH` output type and lowering it so that tensor
    # computations are done with `linalg`-on-tensors ops.
    LINALG_ON_TENSORS = "linalg-on-tensors"

    # This output type consists of `tosa` dialect ops. It can be thought of
    # as taking the `TORCH` output type and lowering it to TOSA.
    TOSA = "tosa"

    # This output type consists of `stablehlo` dialect ops. It can be thought of
    # as taking the `TORCH` output type and lowering it to StableHLO.
    STABLEHLO = "stablehlo"

    # Raw output of the JIT IR importer. This is not expected to be useful
    # for end-users, but can be convenient for development or reporting bugs.
    RAW = "raw"

    @staticmethod
    def get(spec: Union[str, "OutputType"]) -> "OutputType":
        """Gets an OutputType from allowed way to specify one.

        Args:
          spec: An OutputType instance or the case-insensitive name of one of the
            enum values.
        Returns:
          An OutputType instance.
        """
        if isinstance(spec, OutputType):
            return spec
        spec = spec.upper().replace("-", "_")
        if spec not in OutputType.__members__:
            raise ValueError(f"For output_type= argument, expected one of: "
                             f"{', '.join(OutputType.__members__.keys())}")
        return OutputType[spec]


class TensorPlaceholder:
    """A class that represents a formal parameter of a given shape and dtype.

    This class can be constructed explicitly from a shape and dtype:
    ```python
    placeholder = TensorPlaceholder([3, 4], torch.float32)
    ```

    This class can also be constructed from a `torch.Tensor` which is already
    known to be a valid input to the function. In this case, a set of
    dynamic axes are allowed to be specified.
    ```python
    placeholder = TensorPlaceholder.like(torch.ones(3, 4), dynamic_axes=[1])
    # Equivalent to `TensorPlaceholder([3, -1], torch.float32)`
    ```
    """

    def __init__(self, shape: List[int], dtype: torch.dtype):
        """Create a tensor with shape `shape` and dtype `dtype`.

        Args:
            shape: The shape of the tensor. A size of `-1` indicates that the
            dimension has an unknown size.
            dtype: The dtype of the tensor.
        """
        self.shape = shape
        self.dtype = dtype

    @staticmethod
    def like(tensor: torch.Tensor, dynamic_axes: List[int] = None):
        """Create a tensor placeholder that is like the given tensor.

        Args:
            tensor: The tensor to create a placeholder for.
            dynamic_axes: A list of dynamic axes. If specified, the compiled
            module will allow those axes to be any size at runtime.
        """
        if dynamic_axes is None:
            dynamic_axes = []
        shape = []
        for i, dim in enumerate(tensor.shape):
            if i in dynamic_axes:
                shape.append(-1)
            else:
                shape.append(dim)
        return TensorPlaceholder(shape, tensor.dtype)


_example_arg = Union[TensorPlaceholder, torch.Tensor]
_example_args_for_one_method = Union[_example_arg, Sequence[_example_arg]]
_example_args = Union[_example_args_for_one_method, "ExampleArgs"]


class ExampleArgs:
    """A class representing the example arguments to an nn.Module.

    In general, an nn.Module may have multiple methods that need to be compiled.
    This requires example args for each method. This class is a lightweight
    wrapper around a dictionary that maps method names to example arguments.

    In user-facing API's, this type can always be passed interchangeably with a
    single arg or list of args, which normalizes to an ExampleArgs for just
    the `forward` method via the `ExampleArgs.get` static method.
    """

    def __init__(self):
        self._example_args = {}

    def add_method(self, method_name: str, example_args: _example_args_for_one_method):
        """Adds example args for a method.

        Args:
            method_name: The name of the method. Must have not already been
                added previously as a method.
            example_args: The example args for the method.
        Returns:
            self, for chaining.
        """
        assert method_name not in self._example_args
        self._example_args[method_name] = ExampleArgs._canonicalize_args(
            example_args)
        return self

    @staticmethod
    def get(example_args: _example_args) -> "ExampleArgs":
        """Gets an ExampleArgs from one of the permissible ways to specify one.

        Args:
          example_args: An ExampleArgs instance or a single arg or list of args.
        Returns:
          An ExampleArgs instance.
        """
        if isinstance(example_args, ExampleArgs):
            return example_args
        return ExampleArgs().add_method("forward", example_args)

    @staticmethod
    def _canonicalize_args(example_args: _example_args_for_one_method):
        """Canonicalize the args for one method into a tuple."""
        if not isinstance(example_args, Sequence):
            example_args = [example_args]
        for arg in example_args:
            if not isinstance(arg, (TensorPlaceholder, torch.Tensor)):
                raise Exception(f"Only Tensor's, TensorPlaceholder's, or sequences of "
                                f"Tensor's and TensorPlaceholder's are supported as "
                                f"example args for method inputs. "
                                f"Got '{arg}'.")
        return tuple(example_args)

    def _get_methods(self):
        return self._example_args.keys()

    def _get_for_annotation(self):
        result = {}
        for method_name, example_args in self._example_args.items():
            placeholders = []
            for arg in example_args:
                if isinstance(arg, TensorPlaceholder):
                    placeholders.append(arg)
                else:
                    assert isinstance(arg, torch.Tensor)
                    placeholders.append(TensorPlaceholder.like(arg))
            result[method_name] = placeholders
        return result

    def _get_for_tracing(
        self,
        use_tracing: bool,
        ignore_traced_shapes: bool,
    ) -> Dict[str, Tuple[_example_arg, ...]]:
        result = {}
        for method_name, example_args in self._example_args.items():
            # If we are tracing, then we need to convert any placeholders into
            # concrete values.
            if use_tracing:
                example_args_for_trace = []
                for arg in example_args:
                    if isinstance(arg, TensorPlaceholder):
                        if not ignore_traced_shapes:
                            # To avoid accidental footguns, we require
                            # `ignore_traced_shapes` to be true if we're using
                            # TensorPlaceholder's, as it falls into the same
                            # "hopefully the trace works for different inputs"
                            # bucket of concerns.
                            raise Exception(
                                "TensorPlaceholder can only be used with tracing when `ignore_traced_shapes=True`")
                        # For any dynamic dimensions, replace them with "7"
                        # arbitrarily. If a user is using dynamic dimensions with
                        # tracing, they are walking on thin ice already -- assume
                        # they know what they are doing and that their trace is
                        # correct for any specific concrete size.
                        shape = [s if s != -1 else 7 for s in arg.shape]
                        if len(shape) == 0:
                            example_args_for_trace.append(torch.tensor(1))
                        else:
                            example_args_for_trace.append(
                                torch.ones(*shape, dtype=arg.dtype))
                    else:
                        assert isinstance(arg, torch.Tensor)
                        example_args_for_trace.append(arg)
                example_args = tuple(example_args_for_trace)
            result[method_name] = example_args
        return result


# The set of ops that are considered legal for each backend.
# These are currently quite load-bearing, since different backends might be
# missing patterns for decomposed forms of certain ops.
# TODO: Tighten up the definition of these "conditionally legal for backends"
# ops in the backend contract, and move these lists somewhere deeper in the
# compiler where each backend can "own" its set of legal ops.
BACKEND_LEGAL_OPS = {
    OutputType.TOSA: ['aten.flatten.using_ints', 'aten.native_layer_norm', 'aten.linear'],
    OutputType.LINALG_ON_TENSORS: ['aten.flatten.using_ints', ],
    OutputType.STABLEHLO: [],
}


def _canon_extra_library(extra_library):
    extra_library_file_name = ""
    if len(extra_library) != 0:
        extra_library_dict = {}
        for library_func in extra_library:
            extra_library_dict[library_func.__name__] = library_func
        mlir_library = generate_library(extra_library_dict)

        extra_library_file_name = \
            tempfile.gettempdir() + "/custom_op_extra_library.mlir"
        with open(extra_library_file_name, "w") as f:
            f.write(mlir_library)
    return extra_library_file_name


def _lower_mlir_module(verbose, output_type, module):
    if verbose:
        print("\n====================")
        print("Torch Backend IR")
        print(module)

    if output_type == OutputType.TORCH:
        return module

    if output_type == OutputType.TOSA:
        run_pipeline_with_repro_report(
            module, "builtin.module(torch-backend-to-tosa-backend-pipeline)",
            "Lowering Torch Backend IR -> TOSA Backend IR")
        if verbose:
            print("\n====================")
            print("TOSA Backend IR")
            print(module)
        return module

    if output_type == OutputType.LINALG_ON_TENSORS:
        run_pipeline_with_repro_report(
            module,
            "builtin.module(torch-backend-to-linalg-on-tensors-backend-pipeline)",
            "Lowering Torch Backend IR -> Linalg-on-Tensors Backend IR")
        if verbose:
            print("\n====================")
            print("LINALG Backend IR")
            print(module)
        return module

    elif output_type == OutputType.STABLEHLO:
        run_pipeline_with_repro_report(
            module,
            "builtin.module(torch-backend-to-stablehlo-backend-pipeline)",
            "Lowering Torch Backend IR -> StableHLO Backend IR")
        if verbose:
            print("\n====================")
            print("StableHLO Backend IR")
            print(module)
        return module
    raise Exception(f"Unknown OutputType: {output_type}")


def compile(model: torch.nn.Module,
            example_args: _example_args,
            output_type: Union[str, "OutputType"] = OutputType.TORCH,
            use_tracing: bool = False,
            ignore_traced_shapes=False,
            backend_legal_ops: Optional[Sequence[str]] = None,
            extra_library: Iterable[Callable] = [],
            verbose: bool = False,
            use_make_fx: bool = False):
    """Convert a PyTorch model to MLIR.

    Args:
        model: The PyTorch model to convert.
        example_args: A list of example arguments to use when inferring the
            shapes of the arguments to `forward` method of the model.
            A single tensor is treated as a list of a single tensor.
            A TensorPlaceholder object is also allowed in the place of any
            Tensor. For models with multiple methods, an `ExampleArgs` object
            can be passed.
        output_type: The kind of output to produce. See `OutputType` for more
            details.
        use_tracing: If True, use `torch.jit.trace` to convert the model to
            JIT IR rather than `torch.jit.script`.
        ignore_traced_shapes: If True, ignore the shapes that were observed
            during tracing. This should only be used if one knows that the
            original traced program would result in the same trace (modulo
            shapes) for all shape combinations implied by any
            `TensorPlaceholder`'s used as `example_args`. Also,
            strictly-speaking, this option covers dtypes too, but we just say
            "shapes" to be succinct.
        backend_legal_ops: A list of ops that should be considered legal for
            the backend. An op that is considered legal will not be decomposed.
            This option is only valid with the `"torch"` output type.
        extra_library: List of abstract interpretation functions to splice
            into the abstract interpretation library. See
            `docs/adding_abstract_interpretation_functions.md` for more info
            on the format the functions should have.
        verbose: If true, print extra information about the conversion.

    Returns:
        An MLIR module that contains the converted model in the specified
        output type.
    """
    extra_library_file_name = _canon_extra_library(extra_library)
    output_type = OutputType.get(output_type)
    example_args = ExampleArgs.get(example_args)
    if ignore_traced_shapes and not use_tracing:
        raise Exception("`ignore_traced_shapes` requires `use_tracing`")

    # We only allow `backend_legal_ops` to be specified for the `"torch"`
    # output type because the other output types actually invoke their
    # respective backends (Linalg, TOSA, or STABLEHLO), and those backends have
    # very specific requirements about the ops which are legal.
    # See `BACKEND_LEGAL_OPS` for more details.
    if backend_legal_ops is not None:
        if output_type != OutputType.TORCH:
            raise Exception("`backend_legal_ops` is only valid with the "
                            "`torch` output type")
        backend_legal_ops = list(sorted(set(backend_legal_ops)))
    else:
        backend_legal_ops = BACKEND_LEGAL_OPS.get(output_type, [])

    if use_make_fx:
        args = example_args._get_for_tracing(use_tracing=True, ignore_traced_shapes=True)["forward"]
        model = make_fx(
           model,
           decomposition_table=_get_decomposition_table())(*args)


    # For FX-based models, automatically strip overloads.
    if isinstance(model, torch.fx.GraphModule):
        strip_overloads(model)

    # Get the model as JIT IR (TorchScript) for import.
    # TODO: Longer-term, we probably need to split `torch_mlir.compile`.
    # There should be an "acquisition" step that does
    # tracing/scripting/importing from FX/using torchdynamo.export/etc.
    # + any lowering to the backend contract. Then there should be a
    # "backend lowering" step that does the actual lowering to each
    # backend. This separation should be visible at the Python API level, and
    # we can implement a deliberately simplified API like `torch_mlir.compile`
    # on top of those building blocks.
    if isinstance(model, torch.jit.ScriptModule):
        # If the user already converted the model to JIT IR themselves, just
        # do some basic error checking, but take the model as-is.
        for method_name in example_args._get_methods():
            if not hasattr(model, method_name):
                raise Exception(
                    f"Model does not have exported method '{method_name}', "
                    f"requested in `example_args`. Consider adding "
                    f"`@torch.jit.export` to the method definition.")
        scripted = model
    elif use_tracing:
        scripted = torch.jit.trace_module(
            model,
            example_args._get_for_tracing(use_tracing, ignore_traced_shapes)
        )
    else:
        # Make sure that all the methods that the user requested get scripted.
        # By default, PyTorch only scripts the `forward` method and transitive
        # callees.
        for method_name in example_args._get_methods():
            torch.jit.export(getattr(model, method_name).__func__)
        scripted = torch.jit.script(model)
    class_annotator = ClassAnnotator()
    class_annotator.exportNone(scripted._c._type())
    for method_name, example_args in example_args._get_for_annotation().items():
        class_annotator.exportPath(scripted._c._type(), [method_name])
        annotation = [None]  # `None` is always the annotation for "self".
        for arg in example_args:
            annotation.append((arg.shape, arg.dtype, True))
        class_annotator.annotateArgs(
            scripted._c._type(), [method_name], annotation)

    mb = ModuleBuilder()
    import_options = ImportOptions()
    import_options.ignoreExistingTensorShapesAndDtypes = ignore_traced_shapes
    try:
        original_stderr = sys.stderr
        sys.stderr = StringIO()
        # Import the TorchScript module to MLIR
        mb.import_module(scripted._c, class_annotator, import_options)
    except Exception as e:
        raise Exception(f"""
PyTorch TorchScript module -> torch-mlir Object Graph IR import failed with:
### Importer C++ Exception:
{e}
### Importer Diagnostics:
{sys.stderr.getvalue()}
""") from None
    finally:
        sys.stderr = original_stderr
    if output_type == OutputType.RAW:
        return mb.module

    option_string = "{backend-legal-ops=" + ",".join(backend_legal_ops) + \
        " extra-library=" + extra_library_file_name + "}"
    run_pipeline_with_repro_report(
        mb.module,
        f"builtin.module(torchscript-module-to-torch-backend-pipeline{option_string})",
        "Lowering TorchScript IR -> Torch Backend IR",
    )

    return _lower_mlir_module(verbose, output_type, mb.module)
