# SPDX-License-Identifier: MIT
# Copyright (C) 2024-2026, Advanced Micro Devices, Inc. All rights reserved.

import os
import shutil
import subprocess
import sys

from setuptools import Distribution, setup
from setuptools.command.build_ext import build_ext

this_dir = os.path.dirname(os.path.abspath(__file__))
OPT_COMPILER_CONFIG = os.path.join(this_dir, "aiter", "jit", "optCompilerConfig.json")
PACKAGE_NAME = "amd-aiter"

FLYDSL_VERSION = "flydsl==0.1.4"

BUILD_TARGET = os.environ.get("BUILD_TARGET", "auto")
PREBUILD_KERNELS = int(os.environ.get("PREBUILD_KERNELS", 0))
PRETUNE_MODULES = os.environ.get("PRETUNE_MODULES", "")
ENABLE_CK = int(os.environ.get("ENABLE_CK", "1"))
IS_WINDOWS = sys.platform == "win32"
if IS_WINDOWS:
    ENABLE_CK = False
    PREBUILD_KERNELS = False


def getMaxJobs():
    # calculate the maximum allowed NUM_JOBS based on cores
    max_num_jobs_cores = max(1, os.cpu_count() * 0.8)

    try:
        import psutil

        # calculate the maximum allowed NUM_JOBS based on free memory
        free_memory_gb = psutil.virtual_memory().available / (1024**3)
        max_num_jobs_memory = int(free_memory_gb / 0.5)  # assuming 0.5 GB per job
    except ImportError:
        # psutil may not be available during metadata extraction
        max_num_jobs_memory = max_num_jobs_cores

    # pick lower value of jobs based on cores vs memory metric to minimize oom and swap usage during compilation
    max_jobs = int(max(1, min(max_num_jobs_cores, max_num_jobs_memory)))
    return max_jobs


def is_develop_mode():
    for arg in sys.argv:
        if arg == "develop":
            return True
        # pip install -e
        elif "editable" in arg:
            return True
    return False


if not IS_WINDOWS and is_develop_mode():
    try:
        from importlib.metadata import version as pkg_version

        if pkg_version("flydsl") != FLYDSL_VERSION.split("==")[1]:
            raise ImportError("version mismatch")
    except Exception:
        subprocess.check_call(
            [
                sys.executable,
                "-m",
                "pip",
                "install",
                FLYDSL_VERSION,
            ]
        )


def write_install_mode():
    """Write install_mode so core.py uses aiter_meta/ (install) vs repo root (develop).

    Called here so the file exists when setuptools resolves package_data,
    and again in build_ext.run() to ensure it's written for develop mode too.
    """
    mode = "develop" if is_develop_mode() else "install"
    with open("./aiter/install_mode", "w") as f:
        f.write(mode)


def prepare_packaging():
    """Copy source directories and create package metadata for non-editable installs."""
    if os.path.exists("aiter_meta") and os.path.isdir("aiter_meta"):
        shutil.rmtree("aiter_meta")
    if ENABLE_CK:
        shutil.copytree("3rdparty", "aiter_meta/3rdparty")
    else:
        os.makedirs("aiter_meta/3rdparty", exist_ok=True)
    if not IS_WINDOWS:
        shutil.copytree("hsa", "aiter_meta/hsa")
    else:
        os.makedirs("aiter_meta/hsa", exist_ok=True)
    shutil.copytree("gradlib", "aiter_meta/gradlib")
    shutil.copytree("csrc", "aiter_meta/csrc")
    open("aiter_meta/__init__.py", "w").close()
    write_install_mode()


if is_develop_mode():
    packages = ["aiter"]
    write_install_mode()
else:
    prepare_packaging()
    packages = ["aiter_meta", "aiter"]


def _is_metadata_only():
    _skip = frozenset(
        {
            "egg_info",
            "dist_info",
            "clean",
            "--version",
            "--name",
            "--fullname",
            "--author",
            "--author-email",
            "--url",
            "--license",
            "--classifiers",
        }
    )
    return len(sys.argv) < 2 or sys.argv[1] in _skip


# Defer heavy imports until build time
if not _is_metadata_only() and not IS_WINDOWS:
    import json
    from concurrent.futures import ThreadPoolExecutor

    sys.path.insert(0, f"{this_dir}/aiter/")
    from jit import core
    from jit.utils.cpp_extension import IS_HIP_EXTENSION

    # Determine build target
    if BUILD_TARGET == "auto":
        IS_ROCM = IS_HIP_EXTENSION
    elif BUILD_TARGET == "rocm":
        IS_ROCM = True
    else:
        IS_ROCM = False

    if not IS_ROCM:
        raise NotImplementedError("Only ROCM is supported")

    ck_dir = os.environ.get("CK_DIR", f"{this_dir}/3rdparty/composable_kernel")
    if ENABLE_CK:
        assert os.path.exists(ck_dir), (
            "CK is needed by aiter, please make sure clone by "
            '"git clone --recursive https://github.com/ROCm/aiter.git" or '
            '"git submodule sync ; git submodule update --init --recursive"'
        )


def _load_modules_from_config():
    cfg_path = OPT_COMPILER_CONFIG
    try:
        with open(cfg_path, "r", encoding="utf-8") as f:
            data = json.load(f)
    except Exception:
        return []
    if isinstance(data, dict):
        return list(data.keys())
    return []


def get_exclude_ops():
    all_modules = _load_modules_from_config()
    exclude_ops = []

    # When CK is disabled, exclude all CK-dependent modules
    if not ENABLE_CK:
        exclude_ops.extend(sorted(core._get_ck_exclude_modules()))
        return exclude_ops

    for module in all_modules:
        if PREBUILD_KERNELS == 1:
            # Exclude tune modules; for MHA keep only fmha_v3 fwd variants
            if "_tune" in module:
                exclude_ops.append(module)
            if "mha" in module and module not in [
                "module_fmha_v3_fwd",
                "module_fmha_v3_varlen_fwd",
            ]:
                exclude_ops.append(module)
        elif PREBUILD_KERNELS == 2:
            # Exclude _bwd and _tune
            if "_bwd" in module or "_tune" in module:
                exclude_ops.append(module)
        elif PREBUILD_KERNELS == 3:
            # Keep only module_fmha_v3*
            if not module.startswith("module_fmha_v3"):
                exclude_ops.append(module)
        else:
            # Default behavior: exclude tunes
            if "_tune" in module:
                exclude_ops.append(module)

    return exclude_ops


if PREBUILD_KERNELS != 0:
    has_torch = True
    try:
        import torch as _
    except Exception:
        has_torch = False

    if not has_torch:
        print(
            "[aiter] PREBUILD_KERNELS set but torch not installed, "
            "skip precompilation in this environment"
        )
    else:
        from jit.utils.mha_recipes import (
            get_mha_varlen_prebuild_variants_by_names,
        )
        from jit.utils.moe_recipes import get_moe_ck2stages_prebuild_variants
        import glob

        exclude_ops = get_exclude_ops()
        all_opts_args_build, _ = core.get_args_of_build("all", exclude=exclude_ops)

        moe_base_args = None
        filtered_opts_args_build = []
        for one_opt_args in all_opts_args_build:
            if one_opt_args["md_name"] == "module_moe_ck2stages":
                moe_base_args = one_opt_args
                continue
            filtered_opts_args_build.append(one_opt_args)
        all_opts_args_build = filtered_opts_args_build

        if ENABLE_CK and moe_base_args is not None:
            moe_variants = get_moe_ck2stages_prebuild_variants(core.AITER_CSRC_DIR)
            for v in moe_variants:
                all_opts_args_build.append(
                    {
                        "md_name": v["md_name"],
                        "srcs": moe_base_args["srcs"],
                        "flags_extra_cc": moe_base_args["flags_extra_cc"],
                        "flags_extra_hip": moe_base_args["flags_extra_hip"],
                        "extra_include": moe_base_args["extra_include"],
                        "blob_gen_cmd": v["blob_gen_cmd"],
                        "third_party": moe_base_args["third_party"],
                    }
                )

        if PREBUILD_KERNELS == 1 and ENABLE_CK:
            extra_args_build = []

            req_md_names = [
                "mha_varlen_fwd_bf16_nlogits_nbias_mask_nlse_ndropout_nskip_nqscale",
                "mha_varlen_fwd_bf16_nlogits_nbias_nmask_lse_ndropout_nskip_nqscale",
            ]
            variants = get_mha_varlen_prebuild_variants_by_names(req_md_names, ck_dir)
            base_args = core.get_args_of_build("module_mha_varlen_fwd")
            for v in variants:
                if not isinstance(base_args, dict) or not base_args.get("srcs"):
                    continue
                extra_args_build.append(
                    {
                        "md_name": v["md_name"],
                        "srcs": base_args["srcs"],
                        "flags_extra_cc": base_args["flags_extra_cc"],
                        "flags_extra_hip": base_args["flags_extra_hip"],
                        "extra_include": base_args["extra_include"],
                        "blob_gen_cmd": v["blob_gen_cmd"],
                        "third_party": base_args["third_party"],
                    }
                )
            all_opts_args_build.extend(extra_args_build)

        bd = f"{core.get_user_jit_dir()}/build"

        shutil.rmtree(bd, ignore_errors=True)
        for f in glob.glob(f"{core.get_user_jit_dir()}/*.so"):
            try:
                os.remove(f)
            except Exception:
                pass

        def build_one_module(one_opt_args):
            flags_cc = list(one_opt_args["flags_extra_cc"]) + [
                f"-DPREBUILD_KERNELS={PREBUILD_KERNELS}"
            ]
            flags_hip = list(one_opt_args["flags_extra_hip"]) + [
                f"-DPREBUILD_KERNELS={PREBUILD_KERNELS}"
            ]

            core.build_module(
                md_name=one_opt_args["md_name"],
                srcs=one_opt_args["srcs"],
                flags_extra_cc=flags_cc,
                flags_extra_hip=flags_hip,
                blob_gen_cmd=one_opt_args["blob_gen_cmd"],
                extra_include=one_opt_args["extra_include"],
                extra_ldflags=None,
                verbose=False,
                is_python_module=True,
                is_standalone=False,
                torch_exclude=False,
                third_party=one_opt_args["third_party"],
            )

        prebuid_thread_num = 5
        max_jobs = os.environ.get("MAX_JOBS")
        if max_jobs is not None and max_jobs.isdigit() and int(max_jobs) > 0:
            prebuid_thread_num = min(prebuid_thread_num, int(max_jobs))
        else:
            prebuid_thread_num = min(prebuid_thread_num, getMaxJobs())
        os.environ["PREBUILD_THREAD_NUM"] = str(prebuid_thread_num)

        with ThreadPoolExecutor(max_workers=prebuid_thread_num) as executor:
            list(executor.map(build_one_module, all_opts_args_build))

        # Retune GEMM shapes on the live GPU after the main build phase.
        # Each requested module's tune script benchmarks all CSV shapes and
        # writes results tagged with the live GPU's (gfx, cu_num) back to
        # the source CSV, then rebuilds the inference .so.
        if PRETUNE_MODULES:
            # Import directly from the file to avoid triggering aiter/__init__.py,
            # which would try to load module_aiter_core before it is registered.
            sys.path.insert(0, os.path.join(this_dir, "aiter", "utility"))
            from pretune import run_pretune_modules  # noqa: E402

            cfg_path = OPT_COMPILER_CONFIG
            with open(cfg_path, "r", encoding="utf-8") as _f:
                _cfg = json.load(_f)
            run_pretune_modules(
                PRETUNE_MODULES,
                _cfg,
                core,
                build_one_module,
                csrc_dir=f"{this_dir}/csrc",
                repo_dir=this_dir,
            )

        # --- FlyDSL AOT pre-compilation ---
        try:
            flydsl_cache_dir = os.path.join(this_dir, "aiter", "jit", "flydsl_cache")
            os.makedirs(flydsl_cache_dir, exist_ok=True)
            os.environ["FLYDSL_RUNTIME_CACHE_DIR"] = flydsl_cache_dir

            # setup.py loads `jit.core` via sys.path (line 134-135).
            # Map those modules into the `aiter.*` namespace so that
            # `import aiter.jit.core` reuses the same instances.
            for _name in list(sys.modules):
                if _name == "jit" or _name.startswith("jit."):
                    _pkg = f"aiter.{_name}"
                    if _pkg not in sys.modules:
                        sys.modules[_pkg] = sys.modules[_name]

            from aiter.aot.flydsl.common import collect_aot_jobs

            def _run_flydsl_aot(label, default_csvs, parse_csv, compile_one_config):
                jobs = collect_aot_jobs(
                    default_csvs,
                    parse_csv,
                    on_missing_csv=lambda csv_path: print(
                        f"[aiter] {label}: CSV not found: {csv_path}"
                    ),
                )
                if jobs:
                    print(
                        f"[aiter] {label}: {len(jobs)} kernels to compile "
                        f"(cache: {flydsl_cache_dir})"
                    )
                    results = []
                    for job in jobs:
                        results.append(compile_one_config(**job))
                    ok = sum(
                        1 for result in results if result["compile_time"] is not None
                    )
                    fail = len(results) - ok
                    print(f"[aiter] {label}: compiled {ok} ok, {fail} failed")

            from aiter.aot.flydsl.moe import (
                DEFAULT_CSVS as MOE_DEFAULT_CSVS,
                compile_one_config as compile_moe_one_config,
                parse_csv as parse_moe_csv,
            )

            _run_flydsl_aot(
                "FlyDSL MoE AOT",
                MOE_DEFAULT_CSVS,
                parse_moe_csv,
                compile_moe_one_config,
            )

            from aiter.aot.flydsl.gemm import (
                DEFAULT_CSVS as GEMM_DEFAULT_CSVS,
                compile_one_config as compile_gemm_one_config,
                parse_csv as parse_gemm_csv,
            )

            _run_flydsl_aot(
                "FlyDSL GEMM AOT",
                GEMM_DEFAULT_CSVS,
                parse_gemm_csv,
                compile_gemm_one_config,
            )
        except Exception as e:
            import traceback

            traceback.print_exc()
            print(f"[aiter] FlyDSL AOT skipped: {e}")


class NinjaBuildExtension(build_ext):
    """Custom build_ext that defers expensive operations until run() is called."""

    def run(self):
        # Set MAX_JOBS for ninja
        max_jobs_env = os.environ.get("MAX_JOBS")
        if max_jobs_env is None:
            max_jobs = getMaxJobs()
            os.environ["MAX_JOBS"] = str(max_jobs)
        else:
            try:
                if int(max_jobs_env) <= 0:
                    raise ValueError("MAX_JOBS must be a positive integer")
            except ValueError:
                max_jobs = getMaxJobs()
                os.environ["MAX_JOBS"] = str(max_jobs)

        # Run the actual build
        super().run()


setup_requires = [
    "packaging",
    "psutil",
    "ninja",
    "setuptools_scm",
    "vcs_versioning",  # transitive dep of setuptools_scm>=10
]
if PREBUILD_KERNELS != 0:
    setup_requires.append("pandas")


class ForcePlatlibDistribution(Distribution):
    def has_ext_modules(self):
        return True


if IS_WINDOWS:
    install_requires = ["einops", "packaging", "psutil"]
else:
    install_requires = [
        "pybind11>=3.0.1",
        "ninja",
        "pandas",
        "einops",
        "psutil",
        "packaging",
        FLYDSL_VERSION,
    ]

setup(
    name=PACKAGE_NAME,
    use_scm_version=True,
    packages=packages,
    include_package_data=True,
    package_data={
        "": ["*"],
    },
    classifiers=[
        "Programming Language :: Python :: 3",
        "License :: OSI Approved :: BSD License",
        "Operating System :: Unix",
    ],
    cmdclass={"build_ext": NinjaBuildExtension},
    python_requires=">=3.8",
    install_requires=install_requires,
    extras_require={
        # Triton-based communication using Iris
        # Note: Iris is not available on PyPI and must be installed separately
        # Install with: pip install -r requirements-triton-comms.txt
        # (See requirements-triton-comms.txt for pinned Iris version)
        "triton_comms": [],
        # Install all optional dependencies
        "all": [],
    },
    setup_requires=setup_requires,
    distclass=ForcePlatlibDistribution,
)

if os.path.exists("aiter_meta") and os.path.isdir("aiter_meta"):
    shutil.rmtree("aiter_meta")
