# 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.
"""Utilities to locate tvm_ffi libraries, headers, and helper include paths.

This module also provides helpers to locate and load platform-specific shared
libraries by a target_name (e.g., ``tvm_ffi`` -> ``libtvm_ffi.so`` on Linux).
"""

from __future__ import annotations

import ctypes
import importlib.metadata as im
import os
import sys
from pathlib import Path
from typing import TYPE_CHECKING, Callable

if TYPE_CHECKING:
    from .module import Module


def find_libtvm_ffi() -> str:
    """Find libtvm_ffi.

    Returns
    -------
    path
        The full path to the located library.

    """
    candidate = _find_library_by_basename("apache-tvm-ffi", "tvm_ffi")
    if ret := _resolve_and_validate([candidate], cond=lambda _: True):
        return ret
    raise RuntimeError("Cannot find libtvm_ffi")


def find_windows_implib() -> str:
    """Find and return the Windows import library path for tvm_ffi.lib."""
    # implib = _find_library_by_basename("apache-tvm-ffi", "tvm_ffi").parent / "tvm_ffi.lib"
    # ret = _resolve_to_str(implib)
    candidate = _find_library_by_basename("apache-tvm-ffi", "tvm_ffi").parent / "tvm_ffi.lib"
    if ret := _resolve_and_validate([candidate], cond=lambda _: True):
        return ret
    raise RuntimeError("Cannot find implib tvm_ffi.lib")


def find_source_path() -> str:
    """Find packaged source home path."""
    if ret := _resolve_and_validate(
        paths=[
            _rel_top_directory(),
            _dev_top_directory(),
        ],
        cond=lambda p: (p / "src").is_dir(),
    ):
        return ret
    raise RuntimeError("Cannot find home path.")


def find_cmake_path() -> str:
    """Find the preferred cmake path."""
    if ret := _resolve_and_validate(
        paths=[
            _rel_top_directory() / "share" / "cmake" / "tvm_ffi",  # Standard install
            _dev_top_directory() / "cmake",  # Development mode
        ],
        cond=lambda p: p.is_dir(),
    ):
        return ret
    raise RuntimeError("Cannot find cmake path.")


def find_include_path() -> str:
    """Find header files for C compilation."""
    if ret := _resolve_and_validate(
        paths=[
            _rel_top_directory() / "include",
            _dev_top_directory() / "include",
        ],
        cond=lambda p: p.is_dir(),
    ):
        return ret
    raise RuntimeError("Cannot find include path.")


def find_dlpack_include_path() -> str:
    """Find dlpack header files for C compilation."""
    if ret := _resolve_and_validate(
        paths=[
            _rel_top_directory() / "include",
            _dev_top_directory() / "3rdparty" / "dlpack" / "include",
        ],
        cond=lambda p: (p / "dlpack").is_dir(),
    ):
        return ret
    raise RuntimeError("Cannot find dlpack include path.")


def find_cython_lib() -> str:
    """Find the path to tvm cython."""
    from tvm_ffi import core  # noqa: PLC0415

    try:
        return str(Path(core.__file__).resolve())
    except OSError:
        pass
    raise RuntimeError("Cannot find tvm cython path.")


def find_python_helper_include_path() -> str:
    """Find header files for C compilation."""
    if ret := _resolve_and_validate(
        paths=[
            _rel_top_directory() / "include",
            _dev_top_directory() / "python" / "tvm_ffi" / "cython",
        ],
        cond=lambda p: (p / "tvm_ffi_python_helpers.h").is_file(),
    ):
        return ret
    raise RuntimeError("Cannot find python helper include path.")


def include_paths() -> list[str]:
    """Find all include paths needed for FFI related compilation."""
    return sorted(
        {
            find_include_path(),
            find_dlpack_include_path(),
            find_python_helper_include_path(),
        }
    )


def load_lib_ctypes(
    package: str,
    target_name: str,
    mode: str,
    extra_lib_paths: list[Path] | None = None,
) -> ctypes.CDLL:
    """Load the tvm_ffi shared library by searching likely paths.

    Parameters
    ----------
    package
        The package name where the library is expected to be found. For example,
        ``"apache-tvm-ffi"`` is the package name of `tvm-ffi`.
    target_name
        Name of the CMake target, e.g., ``"tvm_ffi"``. It is used to derive the platform-specific
        shared library name, e.g., ``"libtvm_ffi.so"`` on Linux, ``"tvm_ffi.dll"`` on Windows.
    mode
        The mode to load the shared library. See `ctypes.${MODE}` for details.
        Usually it is either ``"RTLD_LOCAL"`` or ``"RTLD_GLOBAL"``.
    extra_lib_paths
        Optional list of additional directories to search for the shared library
        before falling back to the built-in dev candidates and PATH-derived dirs.
        Useful when the caller's package does not ship a wheel-style ``RECORD``
        but knows where its build artifacts land (e.g. ``<worktree>/build/lib``).
        Every element must be a :class:`pathlib.Path`.

    Returns
    -------
    The loaded shared library.

    """
    lib_path: Path = _find_library_by_basename(package, target_name, extra_lib_paths)
    # The dll search path need to be added explicitly in windows
    if sys.platform.startswith("win32"):
        os.add_dll_directory(str(lib_path.parent))
    return ctypes.CDLL(str(lib_path), getattr(ctypes, mode))


def load_lib_module(
    package: str,
    target_name: str,
    keep_module_alive: bool = True,
    extra_lib_paths: list[Path] | None = None,
) -> Module:
    """Load the tvm_ffi shared library by searching likely paths.

    Parameters
    ----------
    package
        The package name where the library is expected to be found. For example,
        ``"apache-tvm-ffi"`` is the package name of `tvm-ffi`.

    target_name
        Name of the CMake target, e.g., ``"tvm_ffi"``. It is used to derive the platform-specific
        shared library name, e.g., ``"libtvm_ffi.so"`` on Linux, ``"tvm_ffi.dll"`` on Windows.

    keep_module_alive
        Whether to keep the loaded module alive to prevent it from being unloaded.

    extra_lib_paths
        Optional list of additional directories to search for the shared library
        before falling back to the built-in dev candidates and PATH-derived dirs.
        Useful when the caller's package does not ship a wheel-style ``RECORD``
        but knows where its build artifacts land (e.g. ``<worktree>/build/lib``).
        Every element must be a :class:`pathlib.Path`.

    Returns
    -------
    The loaded shared library.

    """
    from .module import load_module  # noqa: PLC0415

    lib_path: Path = _find_library_by_basename(package, target_name, extra_lib_paths)
    # The dll search path need to be added explicitly in windows
    if sys.platform.startswith("win32"):
        os.add_dll_directory(str(lib_path.parent))
    return load_module(lib_path, keep_module_alive=keep_module_alive)


def _find_library_by_basename(  # noqa: PLR0912
    package: str,
    target_name: str,
    extra_lib_paths: list[Path] | None = None,
) -> Path:
    """Find a shared library by target_name name across known directories.

    Parameters
    ----------
    package
        The package name where the library is expected to be found.
    target_name
        Base name (e.g., ``"tvm_ffi"`` or ``"tvm_ffi_testing"``).
    extra_lib_paths
        Optional list of additional directories to search **before** the
        built-in self-anchored fallback dirs and the ``PATH``/
        ``LD_LIBRARY_PATH``/``DYLD_LIBRARY_PATH``-derived dirs. Caller-supplied
        directories take precedence over the built-ins, so a foreign caller
        (e.g. ``package="tvm"``) can point at its own build tree without
        relying on ``tvm_ffi``'s file location. Every element must be a
        :class:`pathlib.Path`.

    Returns
    -------
    path
        The full path to the located library.

    Raises
    ------
    TypeError
        If any element of ``extra_lib_paths`` is not a :class:`pathlib.Path`.
    RuntimeError
        If the library cannot be found in any of the candidate directories.
        The error message lists every directory searched.

    """
    if extra_lib_paths is not None:
        for i, p in enumerate(extra_lib_paths):
            if not isinstance(p, Path):
                raise TypeError(
                    f"extra_lib_paths[{i}] must be a pathlib.Path, got {type(p).__name__}: {p!r}"
                )

    if sys.platform.startswith("win32"):
        lib_dll_names = (f"{target_name}.dll",)
    elif sys.platform.startswith("darwin"):
        # Prefer dylib, also allow .so for some toolchains
        lib_dll_names = (f"lib{target_name}.dylib", f"lib{target_name}.so")
    else:  # Linux, FreeBSD, etc
        lib_dll_names = (f"lib{target_name}.so",)

    # Use `importlib.metadata` is the most reliable way to find package data files
    try:
        dist: im.PathDistribution = im.distribution(package)  # ty: ignore[invalid-assignment]
        record = dist.read_text("RECORD") or ""
        for line in record.splitlines():
            partial_path, *_ = line.split(",")
            if partial_path.endswith(lib_dll_names):
                try:
                    path = (dist._path.parent / partial_path).resolve()
                except OSError:
                    continue
                if path.name in lib_dll_names:
                    return path
    except (im.PackageNotFoundError, OSError):
        # Distribution metadata may be missing when the caller's package is on
        # PYTHONPATH without a wheel install. Fall through to the dev fallback.
        pass

    # **Fallback**. it's possible that the library is not built as part of Python ecosystem,
    # e.g. Use PYTHONPATH to point to dev package, and CMake + Makefiles to build the shared library.
    dll_paths: list[Path] = []

    # Case 1. Caller-supplied directories take precedence — these let a foreign
    # caller (e.g. ``package="tvm"``) point at its own build tree without
    # relying on ``tvm_ffi``'s file location.
    if extra_lib_paths is not None:
        dll_paths.extend(extra_lib_paths)

    # Case 2. Built-in self-anchored fallback (back-compat for the self-call).
    dll_paths.extend(
        [
            _rel_top_directory() / "build" / "lib",
            _rel_top_directory() / "lib",
            _dev_top_directory() / "build" / "lib",
            _dev_top_directory() / "lib",
        ]
    )

    # Case 3. PATH-related environment variables.
    if sys.platform.startswith("win32"):
        dll_paths.extend(Path(p) for p in _split_env_var("PATH", ";"))
    elif sys.platform.startswith("darwin"):
        dll_paths.extend(Path(p) for p in _split_env_var("DYLD_LIBRARY_PATH", ":"))
        dll_paths.extend(Path(p) for p in _split_env_var("PATH", ":"))
    else:
        dll_paths.extend(Path(p) for p in _split_env_var("LD_LIBRARY_PATH", ":"))
        dll_paths.extend(Path(p) for p in _split_env_var("PATH", ":"))

    # Search for the library in candidate directories
    for dll_dir in dll_paths:
        for lib_dll_name in lib_dll_names:
            try:
                path = (dll_dir / lib_dll_name).resolve()
                if path.is_file():
                    return path
            except OSError:
                continue
    raise RuntimeError(
        f"Cannot find library {', '.join(lib_dll_names)}; "
        f"searched directories:\n  " + "\n  ".join(str(p) for p in dll_paths)
    )


def _split_env_var(env_var: str, split: str) -> list[str]:
    """Split an environment variable string.

    Parameters
    ----------
    env_var
        Name of environment variable.

    split
        String to split env_var on.

    Returns
    -------
    splits
        If env_var exists, split env_var. Otherwise, empty list.

    """
    if os.environ.get(env_var, None):
        return [p.strip() for p in os.environ[env_var].split(split)]
    return []


def _rel_top_directory() -> Path:
    """Get the current directory of this file."""
    return Path(__file__).parent


def _dev_top_directory() -> Path:
    """Get the top-level development directory."""
    return _rel_top_directory() / ".." / ".."


def _resolve_and_validate(
    paths: list[Path],
    cond: Callable[[Path], bool | Path],
) -> str | None:
    """For all paths that resolve properly, find the 1st one that meets the specified condition.

    M. B. This code path gracefully handles broken paths, symlinks, or permission issues,
    and is required for robust library discovery in all public APIs in this file.
    """
    for path in paths:
        try:
            resolved = path.resolve()
            ret = cond(resolved)
        except (OSError, AssertionError):
            continue
        if isinstance(ret, Path):
            return str(ret)
        elif ret is True:
            return str(resolved)
    return None
