# 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.
"""Thin capability probes for test gating.

This module exposes small ``has_*`` predicates that report whether the
current environment can run a given feature.  They are meant to be used
with plain pytest markers and ``skipif``::

    import pytest
    import tvm.testing

    @pytest.mark.gpu
    @pytest.mark.skipif(not tvm.testing.env.has_cuda(), reason="need cuda")
    def test_my_cuda_kernel():
        ...

Each probe's expensive query (device lookup, ``nvcc`` subprocess, libinfo)
is memoized with :func:`functools.cache`, so it runs at most once per
process even though ``skipif`` evaluates the predicate at import time for
every decorated test.  Probes never raise: when support is absent they
return ``False`` (or a zero version tuple) rather than propagating an
error out of collection.

Three kinds of probe live here:

* **runtime device** probes (``has_cuda``, ``has_gpu`` …) ask whether a
  usable device of a given kind is present;
* **build-support** probes (``has_cudnn`` …, ``build_flag_enabled`` …) ask whether
  an optional library was compiled into the runtime;
* **version / capability** probes (``has_cuda_compute``,
  ``has_nvcc_version`` …) ask about a finer capability of a present device
  or toolchain.
"""

import functools
import os

import tvm

__all__ = [
    "build_flag_enabled",
    "has_adreno_opencl",
    # cpu features
    "has_cpu_feature",
    "has_cublas",
    # runtime device
    "has_cuda",
    # version / capability
    "has_cuda_compute",
    "has_cudagraph",
    # build support
    "has_cudnn",
    "has_gpu",
    # toolchain / environment
    "has_hexagon",
    "has_hexagon_toolchain",
    "has_hipblas",
    "has_llvm",
    "has_llvm_min_version",
    "has_matrixcore",
    "has_metal",
    "has_multi_gpu",
    "has_nccl",
    "has_nvcc_version",
    "has_nvptx",
    "has_nvshmem",
    "has_opencl",
    "has_rocm",
    "has_vulkan",
    "has_x86_avx512",
    "has_x86_vnni",
]


@functools.cache
def _device_exists(kind: str, index: int = 0) -> bool:
    """Return whether ``tvm.device(kind, index)`` is present and usable."""
    try:
        return bool(tvm.device(kind, index).exist)
    except Exception:  # pylint: disable=broad-except
        # A missing backend / driver must skip the test, not crash collection.
        return False


@functools.cache
def build_flag_enabled(flag: str) -> bool:
    """Return whether an optional build flag (e.g. ``USE_CUTLASS``) is on.

    A flag counts as enabled unless it is explicitly disabled, so library
    flags carrying a path (rather than a boolean) still register as present.
    Callers gate via ``@pytest.mark.skipif(not env.build_flag_enabled("USE_X"), ...)``.
    """
    try:
        value = tvm.support.libinfo().get(flag, "OFF")
        return str(value).lower() not in ("off", "false", "0")
    except Exception:  # pylint: disable=broad-except
        return False


@functools.cache
def _target_enabled(kind: str) -> bool:
    """True if ``kind`` is selected by ``TVM_TEST_TARGETS`` (or the default set).

    Honors the ``TVM_TEST_TARGETS`` opt-out, so CI can exclude a flaky
    backend (e.g. opencl) via ``TVM_TEST_TARGETS`` and have its tests skip
    even when a device is physically present.
    """
    try:
        from tvm.testing.utils import _tvm_test_targets  # pylint: disable=import-outside-toplevel

        for target in _tvm_test_targets():
            k = target["kind"] if isinstance(target, dict) else str(target).split()[0]
            if k == kind:
                return True
        return False
    except Exception:  # pylint: disable=broad-except
        return True  # fail open: the device check still gates


@functools.cache
def _runtime_enabled(kind: str) -> bool:
    """True if the runtime was built with support for target ``kind``.

    Used for kinds whose device existence does not imply the backend was
    compiled in -- notably ``llvm``, which maps to the always-present CPU
    device, so ``tvm.device("llvm").exist`` is True even on ``USE_LLVM=OFF``.
    """
    try:
        return bool(tvm.runtime.enabled(kind))
    except Exception:  # pylint: disable=broad-except
        return False


def _device_usable(kind: str) -> bool:
    """True if ``kind`` is enabled for this run and a ``kind`` device exists.

    The TVM_TEST_TARGETS opt-out is checked first so that an excluded backend
    never probes a (possibly crashy) device.
    """
    return _target_enabled(kind) and _device_exists(kind)


# --- runtime device probes -------------------------------------------------


def has_cuda() -> bool:
    """True if a CUDA device is present and enabled in TVM_TEST_TARGETS."""
    return _device_usable("cuda")


def has_rocm() -> bool:
    """True if a ROCm device is present and enabled in TVM_TEST_TARGETS."""
    return _device_usable("rocm")


def has_vulkan() -> bool:
    """True if a Vulkan device is present and enabled in TVM_TEST_TARGETS."""
    return _device_usable("vulkan")


def has_metal() -> bool:
    """True if a Metal device is present and enabled in TVM_TEST_TARGETS."""
    return _device_usable("metal")


def has_opencl() -> bool:
    """True if an OpenCL device is present and enabled in TVM_TEST_TARGETS."""
    return _device_usable("opencl")


def has_nvptx() -> bool:
    """True if NVPTX is usable: a (CUDA) device, plus the LLVM backend it needs."""
    return _device_usable("nvptx") and has_llvm()


def has_llvm() -> bool:
    """True if the LLVM backend was built in and enabled in TVM_TEST_TARGETS.

    Uses ``tvm.runtime.enabled`` rather than device existence: ``llvm`` maps to
    the CPU device, which exists even on a ``USE_LLVM=OFF`` build.
    """
    return _target_enabled("llvm") and _runtime_enabled("llvm")


def has_gpu() -> bool:
    """True if any GPU backend (cuda/rocm/opencl/metal/vulkan) is present."""
    return (
        _device_exists("cuda")
        or _device_exists("rocm")
        or _device_exists("opencl")
        or _device_exists("metal")
        or _device_exists("vulkan")
    )


@functools.cache
def has_multi_gpu(count: int = 2) -> bool:
    """True if at least ``count`` devices of a single GPU backend exist."""
    for kind in ("cuda", "rocm", "opencl", "metal", "vulkan"):
        if all(_device_exists(kind, index) for index in range(count)):
            return True
    return False


# --- build-support probes --------------------------------------------------
#
# These wrap the optional-library build flags.  Features that extend CUDA /
# ROCm additionally require the parent device to be present.


def has_cudnn() -> bool:
    """True if cuDNN was built in and a CUDA device is present."""
    return has_cuda() and build_flag_enabled("USE_CUDNN")


def has_cublas() -> bool:
    """True if cuBLAS was built in and a CUDA device is present."""
    return has_cuda() and build_flag_enabled("USE_CUBLAS")


def has_nccl() -> bool:
    """True if NCCL was built in and a CUDA device is present."""
    return has_cuda() and build_flag_enabled("USE_NCCL")


def has_hipblas() -> bool:
    """True if hipBLAS was built in and a ROCm device is present."""
    return has_rocm() and build_flag_enabled("USE_HIPBLAS")


@functools.cache
def has_nvshmem() -> bool:
    """True if the disco NVSHMEM runtime is available (requires CUDA).

    Probes the runtime global function rather than the ``USE_NVSHMEM`` build
    flag, since the flag can be set in builds that do not ship the runtime.
    """
    try:
        return has_cuda() and (
            tvm.get_global_func("runtime.disco.nvshmem.init_nvshmem_uid", allow_missing=True)
            is not None
        )
    except Exception:  # pylint: disable=broad-except
        return False


# --- version / capability probes -------------------------------------------


@functools.cache
def _cuda_compute_version() -> tuple:
    """Return the (major, minor) CUDA compute version, or (0, 0) if unknown."""
    try:
        from tvm.support import nvcc  # pylint: disable=import-outside-toplevel

        arch = nvcc.get_target_compute_version()
        return nvcc.parse_compute_version(arch)
    except Exception:  # pylint: disable=broad-except
        return (0, 0)


def has_cuda_compute(major: int, minor: int = 0, exact: bool = False) -> bool:
    """True if the CUDA compute capability satisfies ``(major, minor)``.

    When ``exact`` is False (default) the check is ``compute >= (major,
    minor)``; when True it requires an exact match.  Returns False when no
    CUDA device is present, so it implies :func:`has_cuda`.
    """
    if not has_cuda():
        return False
    compute = _cuda_compute_version()
    want = (major, minor)
    if exact:
        return compute == want
    return compute >= want


@functools.cache
def _nvcc_version() -> tuple:
    """Return the (major, minor, release) nvcc version, or (0, 0, 0)."""
    try:
        from tvm.support import nvcc  # pylint: disable=import-outside-toplevel

        return nvcc.get_cuda_version()
    except Exception:  # pylint: disable=broad-except
        return (0, 0, 0)


def has_nvcc_version(major: int, minor: int = 0, release: int = 0) -> bool:
    """True if a CUDA device is present and nvcc is at least ``(major, minor, release)``.

    Returns False when no CUDA device is present, so it implies :func:`has_cuda`.
    Gate a test with ``@pytest.mark.skipif(not tvm.testing.env.has_nvcc_version(11, 4),
    reason="need nvcc >= 11.4")`` (add ``@pytest.mark.gpu`` for GPU selection).
    """
    return has_cuda() and _nvcc_version() >= (major, minor, release)


@functools.cache
def _llvm_version_major() -> int:
    """Return the major LLVM version, or 0 if LLVM is unavailable."""
    try:
        return int(tvm.target.codegen.llvm_version_major())
    except Exception:  # pylint: disable=broad-except
        return 0


def has_llvm_min_version(major: int) -> bool:
    """True if LLVM is available and its major version is at least ``major``."""
    return has_llvm() and _llvm_version_major() >= major


@functools.cache
def has_matrixcore() -> bool:
    """True if a ROCm device with Matrix Core support (compute >= 8) exists."""
    try:
        from tvm.support import rocm  # pylint: disable=import-outside-toplevel

        return has_rocm() and bool(rocm.have_matrixcore(tvm.rocm().compute_version))
    except Exception:  # pylint: disable=broad-except
        return False


@functools.cache
def has_cudagraph() -> bool:
    """True if a CUDA device is present and the toolkit supports CUDA Graphs.

    Implies :func:`has_cuda`: ``nvcc.have_cudagraph()`` only checks the
    toolkit version, so the device guard must be explicit.  Gate a test with
    ``@pytest.mark.skipif(not tvm.testing.env.has_cudagraph(), reason=...)``
    (add ``@pytest.mark.gpu`` for CI selection).
    """
    try:
        from tvm.support import nvcc  # pylint: disable=import-outside-toplevel

        return has_cuda() and bool(nvcc.have_cudagraph())
    except Exception:  # pylint: disable=broad-except
        return False


# --- toolchain / environment probes ----------------------------------------


@functools.cache
def has_hexagon_toolchain() -> bool:
    """True if the Hexagon toolchain is available for compilation."""
    try:
        from tvm.contrib.hexagon import (  # pylint: disable=import-outside-toplevel
            _ci_env_check,
        )

        return build_flag_enabled("USE_HEXAGON") and _ci_env_check._compile_time_check() is True
    except Exception:  # pylint: disable=broad-except
        return False


@functools.cache
def has_hexagon() -> bool:
    """True if Hexagon can both compile and run (toolchain + attached device)."""
    try:
        from tvm.contrib.hexagon import (  # pylint: disable=import-outside-toplevel
            _ci_env_check,
        )

        return has_hexagon_toolchain() and _ci_env_check._run_time_check() is True
    except Exception:  # pylint: disable=broad-except
        return False


@functools.cache
def has_adreno_opencl() -> bool:
    """True if remote Adreno OpenCL testing is configured (RPC_TARGET set)."""
    return build_flag_enabled("USE_OPENCL") and os.environ.get("RPC_TARGET") is not None


# --- cpu feature probes ----------------------------------------------------


@functools.cache
def _has_cpu_feature(features) -> bool:
    """True if the host CPU advertises the given LLVM target ``features``."""
    try:
        codegen = tvm.target.codegen
        cpu = codegen.llvm_get_system_cpu()
        triple = codegen.llvm_get_system_triple()
        target = tvm.target.Target({"kind": "llvm", "mtriple": triple, "mcpu": cpu})
        return bool(codegen.target_has_features(features, target))
    except Exception:  # pylint: disable=broad-except
        return False


def has_cpu_feature(features) -> bool:
    """True if the host CPU supports ``features`` (a name or list of names)."""
    if isinstance(features, list):
        features = tuple(features)
    return _has_cpu_feature(features)


def has_x86_vnni() -> bool:
    """True if the host CPU supports x86 VNNI (AVX512-VNNI or AVX-VNNI)."""
    return has_cpu_feature("avx512vnni") or has_cpu_feature("avxvnni")


def has_x86_avx512() -> bool:
    """True if the host CPU supports the x86 AVX512 extensions."""
    return has_cpu_feature(["avx512bw", "avx512cd", "avx512dq", "avx512vl", "avx512f"])
