#     Copyright 2026, Kay Hayen, mailto:kay.hayen@gmail.com find license text at end of file


"""This module deals with finding and information about shared libraries."""

import os
import re
import sys

from nuitka.__past__ import WindowsError  # pylint: disable=I0021,redefined-builtin
from nuitka.__past__ import unicode
from nuitka.containers.OrderedDicts import OrderedDict
from nuitka.containers.OrderedSets import OrderedSet
from nuitka.options.Options import getMacOSTargetArch, isShowInclusion
from nuitka.PythonVersions import python_version
from nuitka.Tracing import inclusion_logger, postprocessing_logger

from .Execution import executeToolChecked, filterOutputByLine
from .FileOperations import (
    addFileExecutablePermission,
    copyFile,
    getFileList,
    makeContainingPath,
    withMadeWritableFileMode,
)
from .Importing import importFromInlineCopy
from .Utils import (
    getOS,
    isAlpineLinux,
    isAndroidBasedLinux,
    isBSD,
    isCoffUsingPlatform,
    isDebianBasedLinux,
    isElfUsingPlatform,
    isFedoraBasedLinux,
    isLinux,
    isMacOS,
    isSuseBasedLinux,
    isWin32Windows,
    raiseWindowsError,
)
from .WindowsResources import (
    RT_MANIFEST,
    VsFixedFileInfoStructure,
    deleteWindowsResources,
    getResourcesFromDLL,
)


def locateDLLFromFilesystem(name, paths):
    for path in paths:
        for root, _dirs, files in os.walk(path):
            if name in files:
                return os.path.join(root, name)


_ldconfig_usage = "The 'ldconfig' is used to analyze dependencies on ELF using systems and required to be found."


def locateDLL(dll_name):
    # This function is a case driven by returns, pylint: disable=too-many-return-statements
    import ctypes.util

    dll_name = ctypes.util.find_library(dll_name)

    if dll_name is None:
        return None

    # This happens on macOS.
    if isMacOS() and not os.path.exists(dll_name):
        return None

    if isWin32Windows() or isMacOS():
        return os.path.abspath(dll_name)

    if os.path.sep in dll_name:
        # Use this from ctypes instead of rolling our own.
        # pylint: disable=protected-access

        so_name = ctypes.util._get_soname(dll_name)

        if so_name is not None:
            return os.path.join(os.path.dirname(dll_name), so_name)
        else:
            return dll_name

    if isAlpineLinux():
        return locateDLLFromFilesystem(
            name=dll_name, paths=["/lib", "/usr/lib", "/usr/local/lib"]
        )

    # TODO: Could and probably should cache "ldconfig -p" output to avoid forks
    output = executeToolChecked(
        logger=postprocessing_logger,
        command=("/sbin/ldconfig", "-p"),
        absence_message=_ldconfig_usage,
    )

    dll_map = {}

    for line in output.splitlines()[1:]:
        if line.startswith(b"Cache generated by:"):
            continue

        assert line.count(b"=>") == 1, line
        left, right = line.strip().split(b" => ")
        assert b" (" in left, line
        left = left[: left.rfind(b" (")]

        if python_version >= 0x300:
            # spell-checker: ignore getfilesystemencoding

            left = left.decode(sys.getfilesystemencoding())
            right = right.decode(sys.getfilesystemencoding())

        if left not in dll_map:
            dll_map[left] = right

    return dll_map[dll_name]


def getSxsFromDLL(filename, with_data=False):
    """List the SxS manifests of a Windows DLL.

    Args:
        filename: Filename of DLL to investigate

    Returns:
        List of resource names that are manifests.

    """

    return getResourcesFromDLL(
        filename=filename, resource_kinds=(RT_MANIFEST,), with_data=with_data
    )


def _removeSxsFromDLL(filename):
    """Remove the Windows DLL SxS manifest.

    Args:
        filename: Filename to remove SxS manifests from
    """
    # There may be more files that need this treatment, these are from scans
    # with the "find_sxs_modules" tool. spell-checker: ignore winxpgui
    if os.path.normcase(os.path.basename(filename)) not in (
        "sip.pyd",
        "win32ui.pyd",
        "winxpgui.pyd",
    ):
        return

    res_names = getSxsFromDLL(filename)

    if res_names:
        deleteWindowsResources(filename, RT_MANIFEST, res_names)


def _getDLLVersionWindows(filename):
    """Return DLL version information from a file.

    If not present, it will be (0, 0, 0, 0), otherwise it will be
    a tuple of 4 numbers.
    """
    # Get size needed for buffer (0 if no info)
    import ctypes.wintypes

    if type(filename) is unicode:
        GetFileVersionInfoSizeW = ctypes.windll.version.GetFileVersionInfoSizeW
        GetFileVersionInfoSizeW.argtypes = (
            ctypes.wintypes.LPCWSTR,
            ctypes.wintypes.LPDWORD,
        )
        GetFileVersionInfoSizeW.restype = ctypes.wintypes.HANDLE
        size = GetFileVersionInfoSizeW(filename, None)
    else:
        size = ctypes.windll.version.GetFileVersionInfoSizeA(filename, None)

    if not size:
        return (0, 0, 0, 0)

    # Create buffer
    res = ctypes.create_string_buffer(size)
    # Load file information into buffer res

    if type(filename) is unicode:
        # Python3 needs our help here.
        GetFileVersionInfo = ctypes.windll.version.GetFileVersionInfoW
        GetFileVersionInfo.argtypes = (
            ctypes.wintypes.LPCWSTR,
            ctypes.wintypes.DWORD,
            ctypes.wintypes.DWORD,
            ctypes.wintypes.LPVOID,
        )
        GetFileVersionInfo.restype = ctypes.wintypes.BOOL

    else:
        # Python2 just works.
        GetFileVersionInfo = ctypes.windll.version.GetFileVersionInfoA

    success = GetFileVersionInfo(filename, 0, size, res)
    # This cannot really fail anymore.
    assert success

    # Look for code pages
    VerQueryValueA = ctypes.windll.version.VerQueryValueA
    VerQueryValueA.argtypes = (
        ctypes.wintypes.LPCVOID,
        ctypes.wintypes.LPCSTR,
        ctypes.wintypes.LPVOID,
        ctypes.POINTER(ctypes.c_uint32),
    )
    VerQueryValueA.restype = ctypes.wintypes.BOOL

    file_info = ctypes.POINTER(VsFixedFileInfoStructure)()
    uLen = ctypes.c_uint32(ctypes.sizeof(file_info))

    b = VerQueryValueA(res, b"\\\\", ctypes.byref(file_info), ctypes.byref(uLen))
    if not b:
        return (0, 0, 0, 0)

    if file_info.contents.dwSignature != 0xFEEF04BD:
        return (0, 0, 0, 0)

    ms = file_info.contents.dwFileVersionMS
    ls = file_info.contents.dwFileVersionLS

    return (ms >> 16) & 0xFFFF, ms & 0xFFFF, (ls >> 16) & 0xFFFF, ls & 0xFFFF


# spell-checker: ignore readelf
_readelf_usage = "The 'readelf' is used to analyse dependencies on ELF using systems and required to be found."


def _getSharedLibraryRPATHsElf(filename):
    rpaths = []
    output = executeToolChecked(
        logger=postprocessing_logger,
        command=("readelf", "-d", filename),
        absence_message=_readelf_usage,
    )

    for line in output.split(b"\n"):
        # spell-checker: ignore RUNPATH
        if b"RPATH" in line or b"RUNPATH" in line:
            result = line[line.find(b"[") + 1 : line.rfind(b"]")]

            if str is not bytes:
                result = result.decode("utf8")

            rpaths.append(result)
        elif b"NEEDED" in line:
            # If the Python binary has a library dependency like
            # $ORIGIN/../lib/libpython.so, then treat it like it has an rpath of
            # $ORIGIN/../lib. This is needed for python-build-standalone (used
            # by UV-Python).
            result = line[line.find(b"[") + 1 : line.rfind(b"]")]

            if str is not bytes:
                result = result.decode("utf8")

            path_part = os.path.dirname(result)
            if path_part:
                rpaths.append(path_part)

    return rpaths


_dump_usage = "The 'dump' is used to analyse dependencies on COFF using systems and required to be found."


_ar_usage = "The 'ar' is used to list archive members on COFF using systems."


def _getCoffDumpOutput(filename):
    """Get 'dump -H' output for a COFF file, avoiding .imp members.

    On AIX an archive like libunwind.a contains both shr.o members and
    .imp import files. 'dump -H -X 32_64 archive.a' probes every member
    and fails with 0654-105 for the .imp file, even though stdout for the
    .o members is valid.  Instead list members with 'ar -X 32_64 t' and
    dump each object member individually with 'dump -H -X 32_64 -n member'.
    """
    if filename.endswith(".a"):
        ar_output = executeToolChecked(
            logger=postprocessing_logger,
            command=("ar", "-X", "32_64", "t", filename),
            absence_message=_ar_usage,
            decoding=True,
        )

        members = [m.strip() for m in ar_output.splitlines() if m.strip()]
        # Keep only object members, skip import files like *.imp
        obj_members = [m for m in members if not m.endswith(".imp")]

        if obj_members:
            parts = []
            for member in obj_members:
                out = executeToolChecked(
                    logger=postprocessing_logger,
                    command=("dump", "-H", "-X", "32_64", "-n", member, filename),
                    absence_message=_dump_usage,
                    decoding=True,
                )
                parts.append(out)
            if parts:
                return "\n".join(parts)

    return executeToolChecked(
        logger=postprocessing_logger,
        command=("dump", "-H", "-X", "32_64", filename),
        absence_message=_dump_usage,
        decoding=True,
    )


def _parseCoffDumpImportFileStrings(output):
    """Parse the Import File Strings section of 'dump -H' output.

    Args:
        output: The stdout of 'dump -H <filename>' as a string.

    Returns:
        Tuple of (import_paths, imported_libraries) where
        import_paths is a list of library search path strings and
        imported_libraries is a list of (base, member) tuples.

    Notes:
        The AIX 'dump -H' command outputs the loader header which includes
        an Import File Strings section. INDEX 0 contains the library search
        path (LIBPATH), and subsequent indices contain (archive, member) or
        (.so) entries for imported shared libraries.

        The columns are fixed-width: PATH (where to find the library),
        BASE (the library name), MEMBER (archive member name). When PATH
        is populated (e.g. '/' for '/unix'), the entry refers to an import
        with an explicit path prefix. These entries are skipped here since
        they refer to kernel or system-level imports that don't need to be
        bundled.
    """
    header = "INDEX  PATH                          BASE                MEMBER"
    assert header in output, output

    path_start = header.index("PATH")
    base_start = header.index("BASE")
    member_start = header.index("MEMBER")

    after_header = output.split(header, 1)[1]

    import_paths = []
    imported_libraries = []

    for line in after_header.split("\n"):
        line_stripped = line.strip()
        if not line_stripped:
            continue
        if line_stripped[0] not in "0123456789":
            continue

        index_str = line_stripped.split(None, 1)[0]

        try:
            index = int(index_str)
        except ValueError:
            # Hex values or other non-decimal content means we have
            # left the Import File Strings section.
            continue

        if index == 0:
            path_col = line[path_start:].strip()
            if path_col:
                import_paths.append(path_col)
        else:
            base = line[base_start:member_start].strip()
            member = line[member_start:].strip()

            if not base:
                continue

            path_col = line[path_start:base_start].strip()
            if path_col:
                # Entries with explicit PATH (e.g. '/unix') are
                # kernel or system imports, skip them.
                continue

            imported_libraries.append((base, member))

    return import_paths, imported_libraries


def _getSharedLibraryRPATHsCoff(filename):
    rpaths = []

    output = _getCoffDumpOutput(filename)

    import_paths, _imported_libraries = _parseCoffDumpImportFileStrings(output)

    for import_path in import_paths:
        for part in import_path.split(":"):
            part = part.strip()
            if part:
                rpaths.append(part)

    return rpaths


def getCoffImportedLibraries(filename):
    """Get the list of shared libraries imported by a COFF (AIX) binary.

    Args:
        filename: Path to the COFF binary or shared object.

    Returns:
        List of (base, member) tuples for imported libraries.
    """
    output = _getCoffDumpOutput(filename)

    _import_paths, imported_libraries = _parseCoffDumpImportFileStrings(output)

    return imported_libraries


def getCoffLibrarySearchPaths(filename):
    """Get the library search paths embedded in a COFF (AIX) binary.

    Args:
        filename: Path to the COFF binary or shared object.

    Returns:
        List of library search path strings from INDEX 0 of dump -H output.
    """
    output = _getCoffDumpOutput(filename)

    import_paths, _imported_libraries = _parseCoffDumpImportFileStrings(output)

    result = []
    for import_path in import_paths:
        for part in import_path.split(":"):
            part = part.strip()
            if part and part not in result:
                result.append(part)

    return result


_coff_dependency_cache = {}


def _resolveCoffLibraryPath(base, member, search_paths):
    """Resolve a COFF imported library to a full path on the filesystem.

    Args:
        base: The archive name or .so filename (e.g. 'libc.a', 'libpython3.13.so').
        member: The member name within the archive (e.g. 'shr_64.o'), or empty.
        search_paths: List of directory paths to search.

    Returns:
        Absolute path to the found library, or None if not found.
    """
    for search_path in search_paths:
        search_path = search_path.strip()
        if not search_path or not os.path.isdir(search_path):
            continue

        if member:
            archive_path = os.path.join(search_path, base)
            if os.path.isfile(archive_path):
                return archive_path

        candidate = os.path.join(search_path, base)
        if os.path.isfile(candidate):
            return candidate

        if member:
            candidate = os.path.join(search_path, member)
            if os.path.isfile(candidate):
                return candidate

        candidate = os.path.join(search_path, os.path.basename(base))
        if os.path.isfile(candidate):
            return candidate

    return None


def _getCoffLibrarySearchPaths(filename):
    """Assemble library search paths for COFF binary dependency resolution.

    Args:
        filename: Path to the COFF binary being analyzed.

    Returns:
        List of directory paths to search for shared libraries.
    """
    search_paths = []

    libpath = os.environ.get("LIBPATH", "")
    if libpath:
        search_paths.extend(libpath.split(":"))

    try:
        binary_search_paths = getCoffLibrarySearchPaths(filename)
    except Exception:  # pylint: disable=broad-except
        binary_search_paths = []

    search_paths.extend(binary_search_paths)

    binary_dir = os.path.dirname(os.path.abspath(filename))
    search_paths.append(binary_dir)

    return search_paths


def _coffDependencyError(
    filename, imported_libraries, search_paths, base, found_path, imported_by
):
    """Report a COFF dependency resolution failure with full context.

    Args:
        filename: The file that was analyzed.
        imported_libraries: Parsed import entries from dump -H.
        search_paths: Library search paths used for resolution.
        base: The library base name that failed resolution.
        found_path: The resolved path if found but invalid, else None.
        imported_by: The parent file that pulled in this dependency, if any.
    """
    if imported_by:
        context = " (imported by '%s')" % imported_by
    else:
        context = ""

    if found_path is None:
        return postprocessing_logger.sysexit(
            "Error, dump of '%s'%s reported dependency on '%s',"
            " but it was not found in search paths: %s.\n"
            "Full import list parsed: %s"
            % (filename, context, base, tuple(search_paths), str(imported_libraries))
        )
    elif not os.path.isabs(found_path):
        return postprocessing_logger.sysexit(
            "Error, dump of '%s'%s reported dependency on '%s',"
            " resolved to non-absolute path '%s'.\n"
            "Full import list parsed: %s"
            % (filename, context, base, found_path, str(imported_libraries))
        )
    else:
        return postprocessing_logger.sysexit(
            "Error, dump of '%s'%s reported dependency on '%s',"
            " resolved to '%s' which does not exist.\n"
            "Full import list parsed: %s"
            % (filename, context, base, found_path, str(imported_libraries))
        )


def _detectBinaryPathDLLsCoff2(filename, package_name, imported_by):
    """Detect the shared libraries needed by a COFF (AIX) binary.

    Uses 'dump -H' to determine which shared libraries are imported, and
    'dump -Tv' to resolve symbol-level dependencies.

    Args:
        filename: Path to the COFF binary or shared object.
        package_name: Name of the package being processed.
        imported_by: The parent file that pulled in this dependency, or None.

    Returns:
        OrderedSet of absolute paths to required shared libraries.

    Notes:
        This is the COFF/XCOFF equivalent of 'detectBinaryPathDLLsPosix'
        which uses 'ldd' on ELF platforms. AIX does not have ldd, so we
        use the 'dump' command instead to inspect the loader section and
        symbol table.
    """
    if filename in _coff_dependency_cache:
        return _coff_dependency_cache[filename]

    result_set = OrderedSet()

    imported_libraries = getCoffImportedLibraries(filename)

    search_paths = _getCoffLibrarySearchPaths(filename)

    for base, member in imported_libraries:
        if not base or base == ".":
            continue

        found_path = _resolveCoffLibraryPath(base, member, search_paths)

        if not found_path:
            return _coffDependencyError(
                filename=filename,
                imported_libraries=imported_libraries,
                search_paths=search_paths,
                base=base,
                found_path=None,
                imported_by=imported_by,
            )
        elif not os.path.isabs(found_path):
            return _coffDependencyError(
                filename=filename,
                imported_libraries=imported_libraries,
                search_paths=search_paths,
                base=base,
                found_path=found_path,
                imported_by=imported_by,
            )
        elif not os.path.isfile(found_path):
            return _coffDependencyError(
                filename=filename,
                imported_libraries=imported_libraries,
                search_paths=search_paths,
                base=base,
                found_path=found_path,
                imported_by=imported_by,
            )
        else:
            if imported_by:
                postprocessing_logger.info(
                    "Resolved dependency of '%s': '%s' -> '%s'"
                    % (imported_by, filename, found_path)
                )
            result_set.add(found_path)

    _coff_dependency_cache[filename] = result_set

    complete_result = OrderedSet(result_set)
    for sub_dll_filename in result_set:
        complete_result.update(
            _detectBinaryPathDLLsCoff2(
                filename=sub_dll_filename,
                package_name=package_name,
                imported_by=filename,
            )
        )

    return complete_result


def detectBinaryPathDLLsCoff(filename, package_name):
    """Detect the shared libraries needed by a COFF (AIX) binary.

    Uses 'dump -H' to determine which shared libraries are imported, and
    'dump -Tv' to resolve symbol-level dependencies.

    Args:
        filename: Path to the COFF binary or shared object.
        package_name: Name of the package being processed.

    Returns:
        OrderedSet of absolute paths to required shared libraries.

    Notes:
        This is the COFF/XCOFF equivalent of 'detectBinaryPathDLLsPosix'
        which uses 'ldd' on ELF platforms. AIX does not have ldd, so we
        use the 'dump' command instead to inspect the loader section and
        symbol table.
    """
    return _detectBinaryPathDLLsCoff2(
        filename=filename,
        package_name=package_name,
        imported_by=None,
    )


_otool_output_cache = {}


def clearOtoolOutputCache(filename):
    filename = os.path.abspath(filename)

    for cache_key in list(_otool_output_cache):
        if type(cache_key[0]) in (tuple, list):
            command = cache_key[0]
        else:
            command = cache_key

        if command[-1] == filename:
            del _otool_output_cache[cache_key]


def _getMacOSArchOption():
    return ("-arch", getMacOSTargetArch())


def _filterOtoolErrorOutput(stderr):
    def isNonErrorExit(line):
        if b"missing from root that overrides" in line:
            return True

        return False

    return filterOutputByLine(stderr, isNonErrorExit)


def _getOToolCommandOutput(otool_option, filename, cached):
    filename = os.path.abspath(filename)

    command = ("otool",) + _getMacOSArchOption() + (otool_option, filename)

    if otool_option == "-L":
        cache_key = command, os.getenv("DYLD_LIBRARY_PATH")
    else:
        cache_key = command

    if not cached or cache_key not in _otool_output_cache:
        _otool_output_cache[cache_key] = executeToolChecked(
            logger=postprocessing_logger,
            command=command,
            stderr_filter=_filterOtoolErrorOutput,
            absence_message="The 'otool' is used to analyze dependencies on macOS and required to be found.",
        )

    return _otool_output_cache[cache_key]


def getOtoolListing(filename, cached):
    return _getOToolCommandOutput("-l", filename, cached=cached)


def getOtoolDependencyOutput(filename):
    return _getOToolCommandOutput("-L", filename, cached=True)


def parseOtoolListingOutput(output):
    paths = OrderedSet()

    for line in output.split(b"\n")[1:]:
        if str is not bytes:
            line = line.decode("utf8")

        if not line:
            continue

        filename = line.split(" (", 1)[0].strip()

        paths.add(filename)

    return paths


def _getDLLVersionMacOS(filename):
    output = _getOToolCommandOutput("-D", filename, cached=True).splitlines()

    if len(output) < 2:
        return None

    dll_id = output[1].strip()

    if str is not bytes:
        dll_id = dll_id.decode("utf8")

    output = _getOToolCommandOutput("-L", filename, cached=True).splitlines()
    for line in output:
        if str is not bytes:
            line = line.decode("utf8")

        if dll_id in line and "version" in line:
            version_string = re.search(r"current version (.*)\)", line).group(1)
            return tuple(int(x) for x in version_string.split("."))

    return None


def _getSharedLibraryRPATHsDarwin(filename, cached):
    rpaths = []
    output = getOtoolListing(filename, cached=cached)

    cmd = b""
    last_was_load_command = False

    for line in output.split(b"\n"):
        line = line.strip()

        if cmd == b"LC_RPATH":
            if line.startswith(b"path "):
                result = line[5 : line.rfind(b"(") - 1]

                if str is not bytes:
                    result = result.decode("utf8")

                rpaths.append(result)

        if last_was_load_command and line.startswith(b"cmd "):
            cmd = line.split()[1]

        last_was_load_command = line.startswith(b"Load command")

    return rpaths


def getSharedLibraryRPATHs(filename, elements=False, cached=True):
    if isMacOS():
        result = _getSharedLibraryRPATHsDarwin(filename=filename, cached=cached)
    elif isElfUsingPlatform():
        result = _getSharedLibraryRPATHsElf(filename=filename)
    elif isCoffUsingPlatform():
        result = _getSharedLibraryRPATHsCoff(filename=filename)
    else:
        assert False, getOS()

    if elements:
        result = sum([r.split(":") for r in result], [])

    return result


def _filterPatchelfErrorOutput(stderr):
    non_errors = []

    def isNonErrorExit(line):
        if b"cannot find section '.dynamic'" in line:
            non_errors.append(line)

            return True

        return False

    stderr = b"\n".join(
        line
        for line in stderr.splitlines()
        if line
        if b"warning: working around" not in line
        if not isNonErrorExit(line)
    )

    return (0 if non_errors else None), stderr


_patchelf_usage = """\
Error, needs 'patchelf' on your system, to modify 'RPATH' settings that \
need to be updated."""

_patch_elf_version = None


def _getPatchElfVersionOutput(logger):
    return executeToolChecked(
        logger=logger,
        command=("patchelf", "--version"),
        absence_message="""\
Error, standalone mode on %s requires 'patchelf' to be \
installed. Use 'apt/dnf/yum install patchelf' first.""" % getOS(),
    )


def getPatchElfVersion(logger):
    # Singleton, pylint: disable=global-statement
    global _patch_elf_version

    result = _patch_elf_version

    if result is None:
        output = _getPatchElfVersionOutput(logger)

        version = output.split()[1]

        if str is not bytes:
            version = version.decode("utf8")

        result = version, tuple(
            int("".join(d for d in part if d.isdigit())) for part in version.split(".")
        )

        _patch_elf_version = result

    return result


def checkPatchElfPresenceAndUsability(logger):
    """Checks if patchelf is present and usable."""

    version, version_tuple = getPatchElfVersion(logger)

    # These have backports or a safe enough package build by now.
    if (
        version_tuple == (0, 18, 0)
        and not isDebianBasedLinux()
        and not isAndroidBasedLinux()
        and not isFedoraBasedLinux()
        and not isSuseBasedLinux()
    ):
        return logger.sysexit(
            "Error, patchelf version %s is a known buggy release and cannot be used. Please upgrade or downgrade it."
            % version
        )


def _setSharedLibraryRPATHElf(filename, rpath):
    executeToolChecked(
        logger=postprocessing_logger,
        command=("patchelf", "--force-rpath", "--set-rpath", rpath, filename),
        stderr_filter=_filterPatchelfErrorOutput,
        absence_message=_patchelf_usage,
    )


def _filterInstallNameToolErrorOutput(stderr):
    stderr = b"\n".join(
        line
        for line in stderr.splitlines()
        if line
        if b"invalidate the code signature" not in line
        if b"generating fake signature" not in line
    )

    return None, stderr


_install_name_tool_usage = "The 'install_name_tool' is used to make binaries portable on macOS and required to be found."


def _removeSharedLibraryRPATHDarwin(filename):
    for rpath in getSharedLibraryRPATHs(filename):
        executeToolChecked(
            logger=postprocessing_logger,
            command=("install_name_tool", "-delete_rpath", rpath, filename),
            absence_message=_install_name_tool_usage,
            stderr_filter=_filterInstallNameToolErrorOutput,
        )


def _setSharedLibraryRPATHDarwin(filename, rpath):
    _removeSharedLibraryRPATHDarwin(filename)

    executeToolChecked(
        logger=postprocessing_logger,
        command=("install_name_tool", "-add_rpath", rpath, filename),
        absence_message=_install_name_tool_usage,
        stderr_filter=_filterInstallNameToolErrorOutput,
    )


def setSharedLibraryRPATH(filename, rpath):
    if isShowInclusion():
        inclusion_logger.info(
            "Setting 'RPATH' value '%s' for '%s'." % (rpath, filename)
        )

    with withMadeWritableFileMode(filename):
        if isMacOS():
            _setSharedLibraryRPATHDarwin(filename, rpath)
            clearOtoolOutputCache(filename)
        else:
            _setSharedLibraryRPATHElf(filename, rpath)

            if isAndroidBasedLinux():
                cleanupHeaderForAndroid(filename)

    updated_rpaths = getSharedLibraryRPATHs(filename, elements=False, cached=False)
    expected_rpaths = [rpath]

    if updated_rpaths != expected_rpaths and isStaticallyLinked(filename) is not True:
        return postprocessing_logger.sysexit(
            "Error, failed to update rpath for '%s' (%r != %r). Please report the bug."
            % (filename, updated_rpaths, expected_rpaths)
        )


def callInstallNameTool(filename, mapping, id_path, rpath):
    """Update the macOS shared library information for a binary or shared library.

    Adds the rpath path name `rpath` in the specified `filename` Mach-O
    binary or shared library. If the Mach-O binary already contains the new
    `rpath` path name, it is an error.

    Args:
        filename - The file to be modified.
        mapping  - old_path, new_path pairs of values that should be changed
        id_path  - Use this value for library id
        rpath    - Set this as an rpath if not None, delete all if False

    Returns:
        None

    Notes:
        This is obviously macOS specific.
    """
    command = ["install_name_tool"]

    needs_call = False
    for old_path, new_path in mapping:
        if old_path != new_path:
            command += ("-change", old_path, new_path)
            needs_call = True

    if rpath is False:
        for existing_rpath in getSharedLibraryRPATHs(filename):
            command += ("-delete_rpath", existing_rpath)
            needs_call = True
    elif rpath is not None:
        command += ("-add_rpath", os.path.join(rpath, "."))
        needs_call = True

    if id_path is not None:
        command += ("-id", id_path)
        needs_call = True

    command.append(filename)

    if needs_call:
        with withMadeWritableFileMode(filename):
            executeToolChecked(
                logger=postprocessing_logger,
                command=command,
                absence_message=_install_name_tool_usage,
                stderr_filter=_filterInstallNameToolErrorOutput,
            )
            clearOtoolOutputCache(filename)


def getPyWin32Dir():
    """Find the pywin32 DLL directory

    Args:
        None

    Returns:
        path to the pywin32 DLL directory or None

    Notes:
        This is needed for standalone mode only.
    """
    # spell-checker: ignore pywin32

    for path_element in sys.path:
        if not path_element:
            continue

        candidate = os.path.join(path_element, "pywin32_system32")

        if os.path.isdir(candidate):
            return candidate


def detectBinaryMinMacOS(binary_filename):
    """Detect the minimum required macOS version of a binary.

    Args:
        binary_filename - path of the binary to check

    Returns:
        str - minimum OS version that the binary will run on

    """

    minos_version = None

    # This is cached, so we don't have to care about that.
    stdout = getOtoolListing(binary_filename, cached=True)

    lines = stdout.split(b"\n")

    for i, line in enumerate(lines):
        # Form one, used by CPython builds.
        if line.endswith(b"cmd LC_VERSION_MIN_MACOSX"):
            line = lines[i + 2]
            if str is not bytes:
                line = line.decode("utf8")

            minos_version = line.split("version ", 1)[1]
            break

        # Form two, used by Apple Python builds.
        if line.strip().startswith(b"minos"):
            if str is not bytes:
                line = line.decode("utf8")

            minos_version = line.split("minos ", 1)[1]
            break

    return minos_version


_re_dll_filename = re.compile(r"^.*(\.(?:dll|so(?:\..*)|dylib))$", re.IGNORECASE)


def locateDLLsInDirectory(directory):
    """Locate all DLLs in a folder

    Returns:
        list of (filename, filename_relative, dll_extension)
    """

    # This needs to be done a bit more manually, because DLLs on Linux can have no
    # defined suffix, cannot use e.g. only_suffixes for this.
    result = []

    for filename in getFileList(path=directory):
        filename_relative = os.path.relpath(filename, start=directory)

        # TODO: Might want to be OS specific on what to match.
        match = _re_dll_filename.match(filename_relative)

        if match:
            result.append((filename, filename_relative, match.group(1)))

    return result


_file_usage = "The 'file' tool is used to detect macOS file architectures."

_file_output_cache = {}


def _getFileCommandOutput(filename):
    """Cached file output."""

    if filename not in _file_output_cache:
        file_output = executeToolChecked(
            logger=postprocessing_logger,
            command=("file", filename),
            absence_message=_file_usage,
            decoding=str is not bytes,
        )

        assert file_output.startswith(filename + ":")
        file_output = file_output[len(filename) + 1 :].splitlines()[0].strip()

        _file_output_cache[filename] = file_output

    return _file_output_cache[filename]


def isStaticallyLinked(filename):
    if isLinux() or isBSD():
        file_output = _getFileCommandOutput(filename)

        return "statically linked" in file_output

    if isMacOS():
        file_output = _getFileCommandOutput(filename)

        # Telling statically linked is not as easy for macOS, but this will be good
        # enough for some things.
        if "dynamically linked" in file_output:
            return False

    # Don't know is currently an option too, this one will be hard to get right
    # on all platforms.
    return None


def hasUniversalOrMatchingMacOSArchitecture(filename):
    assert isMacOS() and os.path.isfile(filename), filename

    file_output = _getFileCommandOutput(filename)

    return "universal" in file_output or getMacOSTargetArch() in file_output


# spell-checker: ignore lipo

_lipo_usage = (
    "The 'lipo' tool from XCode is used to manage universal binaries on macOS platform."
)


def makeMacOSThinBinary(dest_path, original_path):
    file_output = _getFileCommandOutput(dest_path)

    macos_target_arch = getMacOSTargetArch()

    if "universal" in file_output:
        executeToolChecked(
            logger=postprocessing_logger,
            command=(
                "lipo",
                "-thin",
                macos_target_arch,
                dest_path,
                "-o",
                dest_path + ".tmp",
            ),
            absence_message=_lipo_usage,
        )

        with withMadeWritableFileMode(dest_path):
            os.unlink(dest_path)
            os.rename(dest_path + ".tmp", dest_path)
    elif macos_target_arch not in file_output:
        return postprocessing_logger.sysexit(
            "Error, cannot use file '%s' (%s) to build arch '%s' result"
            % (original_path, file_output, macos_target_arch)
        )


def copyDllFile(source_path, dist_dir, dest_path, executable, other_entry_points):
    """Copy an extension/DLL file making some adjustments on the way."""

    target_filename = os.path.join(dist_dir, dest_path)
    makeContainingPath(target_filename)

    copyFile(source_path=source_path, dest_path=target_filename)

    if isWin32Windows() and python_version < 0x300:
        _removeSxsFromDLL(target_filename)

    if isMacOS():
        makeMacOSThinBinary(dest_path=target_filename, original_path=source_path)

    if isElfUsingPlatform():
        rpaths = getStandaloneEntryPointRPATHs(
            dest_path=dest_path,
            other_entry_points=other_entry_points,
            rpath_mode="parents",
        )

        # Make sure, sub-folders use by the original DLL are actually still
        # included.
        #
        # TODO: Actually it would be nice if these were trimmed to what actually
        # exists and is used, but that's pretty complex and maybe not worth the
        # effort. This would look like "fixupBinaryDLLPathsMacOS" somewhat, and
        # actually this code is indeed kind of misplaced here.
        rpaths.update(
            rpath
            for rpath in getSharedLibraryRPATHs(source_path, elements=True)
            if rpath.startswith("$ORIGIN")
        )

        setSharedLibraryRPATH(target_filename, ":".join(rpaths))

    if executable:
        addFileExecutablePermission(target_filename)


def getStandaloneEntryPointRPATHs(dest_path, other_entry_points, rpath_mode):
    rpaths = OrderedSet()
    rpaths.add("$ORIGIN")

    if rpath_mode == "parents":
        # Path must be normalized for this to be correct, but entry points
        # enforced that.
        count = dest_path.count(os.path.sep)

        # TODO: This ought to depend even more on actual presence of used DLLs
        # in middle paths and not just do it, but maybe there is not much harm
        # in it.
        for c in range(count, 0, -1):
            dest_path_candidate = os.path.normpath(
                os.path.join(dest_path, *([".."] * c))
            )

            if all(
                os.path.dirname(other_entry_point.dest_path) != dest_path_candidate
                for other_entry_point in other_entry_points
            ):
                continue

            rpaths.add(os.path.join("$ORIGIN", *([".."] * c)))
    elif rpath_mode == "dirs":
        current_dir = os.path.dirname(dest_path)

        if not current_dir:
            current_dir = "."

        for other_entry_point in other_entry_points:
            other_dir = os.path.dirname(other_entry_point.dest_path)

            if not other_dir:
                continue

            relative_dir = os.path.relpath(other_dir, current_dir)

            if relative_dir == ".":
                rpath = "$ORIGIN"
            else:
                rpath = os.path.normpath(os.path.join("$ORIGIN", relative_dir))

            rpaths.add(rpath)
    else:
        assert False, rpath_mode

    return rpaths


def getDLLVersion(filename):
    """Determine version of the DLL filename."""
    if isMacOS():
        return _getDLLVersionMacOS(filename)
    elif isWin32Windows():
        return _getDLLVersionWindows(filename)


def getWindowsRunningProcessModuleFilename(handle):
    """Run time lookup of filename of a module in the current Python process."""

    import ctypes.wintypes

    MAX_PATH = 4096
    buf = ctypes.create_unicode_buffer(MAX_PATH)

    GetModuleFileName = ctypes.windll.kernel32.GetModuleFileNameW
    GetModuleFileName.argtypes = (
        ctypes.wintypes.HANDLE,
        ctypes.wintypes.LPWSTR,
        ctypes.wintypes.DWORD,
    )
    GetModuleFileName.restype = ctypes.wintypes.DWORD

    res = GetModuleFileName(handle, buf, MAX_PATH)
    if res == 0:
        raiseWindowsError("getWindowsRunningProcessModuleFilename")

    return os.path.normcase(buf.value)


def _getWindowsRunningProcessModuleHandles():
    """Return list of process module handles for running process."""
    import ctypes.wintypes

    try:
        EnumProcessModulesProc = ctypes.windll.psapi.EnumProcessModules
    except AttributeError:
        EnumProcessModulesProc = ctypes.windll.kernel32.EnumProcessModules

    EnumProcessModulesProc.restype = ctypes.wintypes.BOOL
    EnumProcessModulesProc.argtypes = (
        ctypes.wintypes.HANDLE,
        ctypes.POINTER(ctypes.wintypes.HANDLE),
        ctypes.wintypes.LONG,
        ctypes.POINTER(ctypes.wintypes.ULONG),
    )

    # Very unlikely that this is not sufficient for CPython.
    handles = (ctypes.wintypes.HANDLE * 1024)()
    needed = ctypes.wintypes.ULONG()

    res = EnumProcessModulesProc(
        ctypes.windll.kernel32.GetCurrentProcess(),
        handles,
        ctypes.sizeof(handles),
        ctypes.byref(needed),
    )

    if not res:
        raiseWindowsError("getWindowsRunningProcessModuleHandles")

    return tuple(handle for handle in handles if handle is not None)


def getWindowsRunningProcessDLLPaths():
    result = OrderedDict()

    for handle in _getWindowsRunningProcessModuleHandles():
        try:
            filename = getWindowsRunningProcessModuleFilename(handle)
        except WindowsError:
            continue

        result[os.path.basename(filename)] = filename

    return result


# spell-checker: ignore termux DT_RUNPATH
_termux_elf_cleaner_usage = (
    "Needs 'termux-elf-cleaner' to clean up created files. Install it for best results."
)


def cleanupHeaderForAndroid(filename):
    """Change a DT_RPATH to DT_RUNPATH

    On Android this seems required, because the linker doesn't support the one
    created by default.
    """

    executeToolChecked(
        logger=postprocessing_logger,
        command=("patchelf", "--shrink-rpath", filename),
        stderr_filter=_filterPatchelfErrorOutput,
        absence_message=_patchelf_usage,
    )

    executeToolChecked(
        logger=postprocessing_logger,
        command=("termux-elf-cleaner", "--quiet", filename),
        absence_message=_termux_elf_cleaner_usage,
        optional=True,
    )


_nm_usage = """\
Error, needs 'nm' on your system, to detect exported DLL symbols."""


def _decodeWin32EntryPoint(entry_point_name):
    if str is bytes:
        return entry_point_name
    else:
        # Not sure about the actual encoding used, this will cover most cases.
        return entry_point_name.decode("utf8", "backslashreplace")


def getPEFileUsedDllNames(filename):
    """Return the used DLL PE file information of a Windows EXE or DLL

    Args:
        filename - The file to be investigated.

    Notes:
        Use of this is obviously only for Windows, although the module
        will exist on other platforms too.
    """

    pefile = importFromInlineCopy("pefile", must_exist=True)
    try:
        with pefile.PE(filename) as pe_info:
            pe_info.parse_data_directories(
                directories=(pefile.DIRECTORY_ENTRY["IMAGE_DIRECTORY_ENTRY_IMPORT"],),
                import_dllnames_only=True,  # spell-checker: ignore import_dllnames_only
            )
            pe_info.parse_data_directories(
                directories=(
                    pefile.DIRECTORY_ENTRY["IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT"],
                )
            )

            result = OrderedSet()

            for entry_name in (
                "DIRECTORY_ENTRY_IMPORT",
                "DIRECTORY_ENTRY_DELAY_IMPORT",
            ):
                for dll_entry in getattr(pe_info, entry_name, ()):
                    # TODO: The PE/COFF docs describe these names as ASCII, but
                    # they do not actually specify the encoding here.
                    result.add(dll_entry.dll.decode("utf8"))

            return result
    except pefile.PEFormatError:
        return None


def getDllExportedSymbols(logger, filename):
    if isWin32Windows():
        pefile = importFromInlineCopy("pefile", must_exist=True)

        try:
            with pefile.PE(filename) as pe_info:
                return tuple(
                    _decodeWin32EntryPoint(entry_point.name)
                    for entry_point in pe_info.DIRECTORY_ENTRY_EXPORT.symbols
                    if entry_point.name is not None
                )
        except pefile.PEFormatError:
            return None
    else:
        if isLinux() or isCoffUsingPlatform() or isBSD():
            command = ("nm", "-D", filename)
        elif isMacOS():
            command = ("nm", "-gU", filename) + _getMacOSArchOption()
        else:
            assert False

        output = executeToolChecked(
            logger=logger,
            command=command,
            absence_message=_nm_usage,
        )

        result = OrderedSet()
        for line in output.splitlines():
            try:
                _addr, marker, symbol_name = line.split()
            except ValueError:
                continue

            if marker == b"T":
                result.add(symbol_name.decode("utf8"))

        return result


def getDllSuffix():
    if isWin32Windows():
        return ".dll"
    elif isMacOS():
        return ".dylib"
    else:
        return ".so"


#     Part of "Nuitka", an optimizing Python compiler that is compatible and
#     integrates with CPython, but also works on its own.
#
#     Licensed under the GNU Affero General Public License, Version 3 (the "License");
#     you may not use this file except in compliance with the License.
#     You may obtain a copy of the License at
#
#        https://www.gnu.org/licenses/agpl-3.0.txt
#
#     See also: "Nuitka Runtime Library Exception, Version 1.0" in file
#     "LICENSE-RUNTIME.txt" for additional permissions granted under Section 7.
#
#     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.
