# 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, f-string-without-interpolation, consider-using-from-import
# ruff: noqa: E501
"""Tools/compilers/linkers for Hexagon"""

import io
import itertools
import os
import pathlib
import re
import subprocess
import sys
import tarfile

import numpy
from tvm_ffi import register_global_func

import tvm
import tvm.contrib.cc as cc

# Linking Hexagon shared libraries.
#
#   link_shared(name-of-shared-library, list-of-objects, kw-args)
#
# To use a custom linker, define a function that returns the path to the
# linker, and pass it to 'register_linker':
#
#   def custom_linker_path():
#       return '/path/to/hexagon/linker'
#
#   register_linker(custom_linker_path)
#
# Subsequent calls to 'link_shared' will use the newly registered linker.

HEXAGON_TOOLCHAIN = os.environ.get("HEXAGON_TOOLCHAIN", default="")  # pylint: disable=invalid-name
HEXAGON_SDK_ROOT = os.environ.get("HEXAGON_SDK_ROOT", default="")  # pylint: disable=invalid-name
HEXAGON_SDK_DOCKER_IMAGE = os.environ.get("HEXAGON_SDK_DOCKER_IMAGE", default="")  # pylint: disable=invalid-name
HEXAGON_LINK_MAIN = pathlib.Path(HEXAGON_TOOLCHAIN) / "bin" / "hexagon-link"  # pylint: disable=invalid-name
HEXAGON_CLANG_PLUS = pathlib.Path(HEXAGON_TOOLCHAIN) / "bin" / "hexagon-clang++"  # pylint: disable=invalid-name
HEXAGON_SDK_INCLUDE_DIRS = [  # pylint: disable=invalid-name
    pathlib.Path(HEXAGON_SDK_ROOT) / "incs",
    pathlib.Path(HEXAGON_SDK_ROOT) / "incs" / "stddef",
]

HEXAGON_SIMULATOR_NAME = "simulator"


def register_linker(f):
    """Register a function that will return the path to the Hexagon linker."""
    return register_global_func("tvm.contrib.hexagon.hexagon_link", f, True)


@register_global_func("tvm.contrib.hexagon.hexagon_link")
def hexagon_link() -> str:
    """Return path to the Hexagon linker."""
    return str(HEXAGON_LINK_MAIN)


def hexagon_clang_plus() -> str:
    """Return path to the Hexagon clang++."""
    return str(HEXAGON_CLANG_PLUS)


def toolchain_version(toolchain=None) -> list[int]:
    """Return the version of the Hexagon toolchain.

    Parameters
    ----------
    toolchain: str, optional
        Path to the Hexagon toolchain. If not provided, the environment
        variable HEXAGON_TOOLCHAIN is used.

    Returns
    -------
    version: List[int]
        List of numerical components of the version number. E.g. for version
        "8.5.06" it will be [8, 5, 6].
    """

    if toolchain is None:
        toolchain = HEXAGON_TOOLCHAIN
    assert toolchain is not None, "Please specify toolchain, or set HEXAGON_TOOLCHAIN variable"
    result = subprocess.run(
        [f"{toolchain}/bin/hexagon-clang", "-v"], capture_output=True, check=True
    )
    output = result.stderr.decode()
    for line in output.splitlines():
        m = re.match(r".* [Cc]lang version ([0-9\.]+)", line)
        if m:
            assert len(m.groups()) == 1
            return [int(v) for v in m.group(1).split(".")]
    raise RuntimeError("Cannot establish toolchain version")


@register_global_func("tvm.contrib.hexagon.link_shared")
def link_shared(so_name, objs, extra_args=None):
    """Link shared library on Hexagon using the registered Hexagon linker.

    Parameters
    ----------
    so_name : str
        Name of the shared library file.
    objs : list[str, tvm.tirx.StringImm]
    extra_args : dict (str->str) or Map<String,String>
        Additional arguments:
            'hex_arch' - Hexagon architecture, e.g. v68
            'verbose'  - Print additional information if the key is present

    Returns
    -------
    ret_val : int
        This function returns 0 at the moment.
    """

    # The list of object files can be passed as built-in Python strings,
    # or as tvm.tirx.StringImm's.
    def to_str(s):
        if isinstance(s, tvm.tirx.StringImm):
            return s.value
        assert isinstance(s, str), 'argument "' + str(s) + '" should be a string or StrImm'
        return s

    objs = [to_str(s) for s in objs]

    if not extra_args:
        extra_args = {}
    hex_arch = extra_args.get("hex_arch") or "v68"
    linker = tvm.get_global_func("tvm.contrib.hexagon.hexagon_link")()
    if extra_args.get("verbose"):
        print("tvm.contrib.hexagon.link_shared:")
        print("  Using linker:", linker)
        print("  Library name:", so_name)
        print("  Object files:", objs)
        print("  Architecture:", hex_arch)
    if not os.access(linker, os.X_OK):
        message = 'The linker "' + linker + '" does not exist or is not executable.'
        if not os.environ.get("HEXAGON_TOOLCHAIN"):
            message += (
                " The environment variable HEXAGON_TOOLCHAIN is unset. Please export "
                + "HEXAGON_TOOLCHAIN in your environment, so that ${HEXAGON_TOOLCHAIN}/bin/"
                + "hexagon-link exists."
            )
        else:
            message += (
                " Please verify the value of the HEXAGON_LINKER environment variable "
                + '(currently set to "'
                + HEXAGON_TOOLCHAIN
                + '").'
            )
        raise Exception(message)

    libpath = os.path.join(HEXAGON_TOOLCHAIN, "target", "hexagon", "lib", hex_arch, "G0")
    cc.create_shared(
        so_name,
        objs,
        # pylint: disable=bad-whitespace
        options=[
            "-Bdynamic",
            "-shared",
            "-export-dynamic",
            os.path.join(libpath, "pic", "libgcc.so"),
        ],
        cc=linker,
    )
    return 0


def link_shared_macos(so_name, objs, extra_args=None):
    """Link Hexagon shared library using docker container with proper tooling.

    Parameters
    ----------
    so_name : str
        Name of the shared library file.
    objs : list[str, tvm.tirx.StringImm]
    extra_args : dict (str->str) or Map<String,String>
        Additional arguments:
            'hex_arch' - Hexagon architecture, e.g. v68

    Returns
    -------
    ret_val : int
        This function returns 0 at the moment.
    """

    # The list of object files can be passed as built-in Python strings,
    # or as tvm.tirx.StringImm's.
    def to_str(s):
        if isinstance(s, tvm.tirx.StringImm):
            return s.value
        assert isinstance(s, str), 'argument "' + str(s) + '" should be a string or StrImm'
        return s

    objs = [to_str(s) for s in objs]

    if not extra_args:
        extra_args = {}
    hex_arch = extra_args.get("hex_arch") or "v68"

    ses = ContainerSession(HEXAGON_SDK_DOCKER_IMAGE)

    hexagon_sdk_tools_path = ses.get_env("HEXAGON_TOOLCHAIN")
    libpath = os.path.join(hexagon_sdk_tools_path, "target", "hexagon", "lib", hex_arch, "G0")
    linker = os.path.join(hexagon_sdk_tools_path, "bin", "hexagon-link")

    # Copy input data to docker container
    docker_objs = [ses.copy_to(obj) for obj in objs]
    docker_so_name = ses.tmp_dir + "/" + os.path.basename(so_name)

    link_cmd = [linker, "-shared", "-fPIC", "-o", docker_so_name]
    link_cmd += docker_objs
    link_cmd += [
        "-Bdynamic",
        "-export-dynamic",
        "-L" + os.path.join(libpath, "pic"),
        "-lgcc",
    ]
    ses.exec(link_cmd)

    # Copy result back to host
    ses.copy_from(docker_so_name, so_name)
    return 0


if sys.platform == "darwin":

    def __create_shared_mac(so_name, objs, **kwargs):
        return link_shared_macos(so_name, objs, kwargs)

    create_shared = __create_shared_mac
    register_global_func("tvm.contrib.hexagon.link_shared", f=link_shared_macos, override=True)
else:  # Linux and Win32
    create_shared = cc.create_shared
    register_global_func("tvm.contrib.hexagon.link_shared", f=link_shared, override=True)


def create_aot_shared(so_name: str | pathlib.Path, files, hexagon_arch: str, options=None):
    """Export Hexagon AOT module."""
    options = options or []
    if not os.access(str(HEXAGON_CLANG_PLUS), os.X_OK):
        raise Exception(
            'The Clang++ "' + str(HEXAGON_CLANG_PLUS) + '" does not exist or is not executable.'
        )
    if not HEXAGON_TOOLCHAIN:
        raise Exception(
            " The environment variable HEXAGON_TOOLCHAIN is unset. Please export "
            + "HEXAGON_TOOLCHAIN in your environment."
        )
    if not HEXAGON_SDK_ROOT:
        raise Exception(
            " The environment variable HEXAGON_SDK_ROOT is unset. Please export "
            + "HEXAGON_SDK_ROOT in your environment."
        )

    # The AOT C codegen uses TVM runtime functions
    # (e.g. TVMBackendAllocWorkspace) directly. On Hexagon these calls
    # should be made using functions pointers provided as __TVM*
    # variables in the provided context.  This workaround allows the
    # the TVM runtime symbols to be visible to the compiled shared
    # library.
    #
    # This workaround can be removed when AOT codegen can be done with
    # LLVM codegen.
    workaround_link_flags = os.environ.get("HEXAGON_SHARED_LINK_FLAGS")
    if workaround_link_flags:
        options.extend(workaround_link_flags.split())

    tvm_dir = pathlib.Path(os.path.dirname(os.path.realpath(__file__))) / ".." / ".." / ".." / ".."
    compute_arch = f"compute{hexagon_arch}"
    compile_options = [
        "-O3",
        f"-I{tvm_dir / 'include'}",
        f"-I{tvm_dir / '3rdparty' / 'dlpack' / 'include'}",
        f"-I{pathlib.Path(HEXAGON_SDK_ROOT) / 'rtos' / 'qurt' / compute_arch / 'include' / 'posix'}",
        f"-I{pathlib.Path(HEXAGON_SDK_ROOT) / 'rtos' / 'qurt' / compute_arch / 'include' / 'qurt'}",
        "-D_MACH_I32=int",
    ]

    # For debugging
    for path in HEXAGON_SDK_INCLUDE_DIRS:
        compile_options.append(f"-I{path!s}")

    cross_compile = cc.cross_compiler(compile_func=hexagon_clang_plus())
    cross_compile.output_format = "o"
    c_files = [str(file) for file in files]
    cross_compile(str(so_name), c_files, options=compile_options + options)


def pack_imports(
    module: tvm.runtime.Module,
    is_system_lib: bool,  # pylint: disable=unused-argument
    c_symbol_prefix: str,
    workspace_dir: str,
):
    """Create an ELF object file that contains the binary data for the modules
    imported in `module`. This is a callback function for use as `fpack_imports`
    in `export_library`.

    Parameters
    ----------
    module: tvm.runtime.Module
        Module whose imported modules need to be serialized.
    is_system_lib: bool
        Flag whether the exported module will be used as a system library.
    c_symbol_prefix: str
        Prefix to prepend to the blob symbol.
    workspace_dir: str
        Location for created files.

    Returns
    -------
    file_name: str
        The name of the created object file.
    """

    path_bin = os.path.join(workspace_dir, "imports.bin")
    pack_to_bin_f_name = "runtime.ModulePackImportsToTensor"
    fpack_to_bin = tvm.get_global_func(pack_to_bin_f_name)
    assert fpack_to_bin, f"Expecting {pack_to_bin_f_name} in registry"

    fpack_to_bin(module).numpy().tofile(path_bin)

    mblob_symbol = c_symbol_prefix + tvm.get_global_func("runtime.ModuleImportsBlobName")()

    binary_size = os.path.getsize(path_bin)
    hexagon_toolchain = os.environ.get("HEXAGON_TOOLCHAIN")
    assert hexagon_toolchain, "Please set HEXAGON_TOOLCHAIN variable"
    version = toolchain_version(hexagon_toolchain)
    assert version[0] == 8 and version[1] >= 5, (
        "Please use Hexagon toolchain version 8.5.x or later"
    )
    if version[1] <= 6:
        path_o = os.path.join(workspace_dir, f"{c_symbol_prefix}devc.o")
        subprocess.run(
            [
                f"{hexagon_toolchain}/bin/hexagon-clang",
                "-x",
                "c",
                "-c",
                "/dev/null",
                "-o",
                path_o,
            ],
            check=True,
        )
        subprocess.run(
            [
                f"{hexagon_toolchain}/bin/hexagon-llvm-objcopy",
                path_o,
                "--add-section",
                f".rodata={path_bin}",
                "--add-symbol",
                f"{mblob_symbol}=.rodata:0,object",
            ],
            check=True,
        )
        return path_o

    else:  # 8.6.07+
        path_c = os.path.join(workspace_dir, f"{c_symbol_prefix}devc.c")
        path_o = os.path.join(workspace_dir, f"{c_symbol_prefix}devc.o")
        with open(path_c, "w") as f:
            f.write(
                f"const unsigned char {mblob_symbol}[{binary_size}] "
                f'__attribute__((section(".rodata"))) = {{0x1}};'
            )
        subprocess.run(
            [f"{hexagon_toolchain}/bin/hexagon-clang", "-c", path_c, "-o", path_o], check=True
        )
        subprocess.run(
            [
                f"{hexagon_toolchain}/bin/hexagon-llvm-objcopy",
                path_o,
                "--update-section",
                f".rodata={path_bin}",
            ],
            check=True,
        )
        return path_o


def export_module(module, out_dir, binary_name="test_binary.so"):
    """Export Hexagon shared object to a file."""
    binary_path = pathlib.Path(out_dir) / binary_name
    module.write_to_file(str(binary_path))
    return binary_path


def allocate_hexagon_array(
    dev, tensor_shape=None, dtype=None, data=None, axis_separators=None, mem_scope=None
):
    """
    Allocate a hexagon array which could be a 2D array
    on physical memory defined by axis_separators
    """
    if tensor_shape is None:
        assert data is not None, "Must provide either tensor shape or numpy data array"
        tensor_shape = data.shape
    elif data is not None:
        assert tensor_shape == data.shape, (
            "Mismatch between provided tensor shape and numpy data array shape"
        )

    if dtype is None:
        assert data is not None, "Must provide either dtype or numpy data array"
        dtype = data.dtype.name
    elif data is not None:
        assert dtype == data.dtype, "Mismatch between provided dtype and numpy data array dtype"

    if axis_separators is None:
        axis_separators = []

    boundaries = [0, *axis_separators, len(tensor_shape)]
    physical_shape = [
        numpy.prod(tensor_shape[dim_i:dim_f]) for dim_i, dim_f in itertools.pairwise(boundaries)
    ]

    arr = tvm.runtime.empty(physical_shape, dtype=dtype, device=dev, mem_scope=mem_scope)

    if data is not None:
        arr.copyfrom(data.reshape(physical_shape))

    return arr._create_view(tensor_shape)


class ContainerSession:
    """Docker container session

    Parameters
    ----------
    base_image_name : str
        Docker image name to use. Empty string means to use default "tlcpack/ci-hexagon"
        base image.
    """

    def __init__(self, base_image_name: str = ""):
        self._client = None
        self._container = None
        self.tmp_dir = None

        self._client = ContainerSession._get_docker_client()

        if base_image_name == "":
            base_image_name = ContainerSession._get_latest_ci_image(self._client)

        self._container = ContainerSession._find_container_or_create(self._client, base_image_name)

        exit_code, tmp_dir_b = self._container.exec_run("mktemp -d -t tvm-toolbox-XXXXXXXXXX")
        assert exit_code == 0

        self.tmp_dir = tmp_dir_b.decode("utf-8").rstrip()

    def __del__(self):
        self.close()

    @staticmethod
    def _get_latest_ci_image(client) -> str:
        ci_images = client.images.list(name="tlcpack/ci-hexagon")
        ci_images.sort(reverse=True, key=lambda img: img.tags[0])
        return ci_images[0].tags[0]

    @staticmethod
    def _get_docker_client():
        try:
            from docker import from_env  # pylint: disable=import-outside-toplevel
            from docker.errors import DockerException
        except (ModuleNotFoundError, ImportError):
            raise Exception("Docker SDK module is not installed. Please install it.")

        try:
            client = from_env()
        except DockerException:
            raise Exception(
                "Docker server is not available. Please verify the docker is installed, "
                "launched and available via command line ('dokcer ps' should works)."
            )

        return client

    @staticmethod
    def _find_container_or_create(client, image_name: str):
        all_containers = client.containers.list(all=True)

        filtered_containers = []
        for container in all_containers:
            tags: list = container.image.tags
            img_name: str = tags[0]
            if img_name.startswith(image_name) and container.name.startswith("tvm-hex-toolbox"):
                filtered_containers.append(container)

        if len(filtered_containers) == 0:
            container = client.containers.run(
                image=image_name, detach=True, tty=True, name="tvm-hex-toolbox"
            )
        else:
            container = filtered_containers[0]

        if container.status != "running":
            container.start()

        return container

    def exec(self, cmd) -> str:
        """Execute command inside docker container"""
        exit_code, res = self._container.exec_run(cmd)
        assert exit_code == 0
        return res.decode("utf-8")

    def get_env(self, key: str) -> str:
        """Return env var value from docker container"""
        res: str = self.exec(f"bash -c 'echo \"${key}\"'")
        return res.rstrip(" \n")

    def copy_to(self, host_file_path: str) -> str:
        """Upload file to docker container"""
        file_name = os.path.basename(host_file_path)

        byte_stream = io.BytesIO()
        with tarfile.open(fileobj=byte_stream, mode="w:gz") as tar:
            tar.add(host_file_path, arcname=file_name)

        self._container.put_archive(path=self.tmp_dir, data=byte_stream.getvalue())

        return f"{self.tmp_dir}/{file_name}"

    def copy_from(self, container_file_path: str, host_file_path: str):
        """Download file from docker container"""
        tar_bytes_gen, _ = self._container.get_archive(container_file_path)

        # convert to bytes
        tar_bytes = b""
        for chunk in tar_bytes_gen:
            tar_bytes += chunk

        tar = tarfile.open(fileobj=io.BytesIO(initial_bytes=tar_bytes))
        assert len(tar.getmembers()) == 1
        tar_element_reader = tar.extractfile(tar.getmembers()[0])
        with open(host_file_path, "wb") as host_file:
            for chunk in tar_element_reader:
                host_file.write(chunk)

    def close(self):
        """Close docker container session"""
        if self.tmp_dir is not None:
            exit_code, _ = self._container.exec_run(f"rm -rf {self.tmp_dir}")
            assert exit_code == 0
