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


"""DLL dependency scan methods for macOS."""

import os
import re
import sys

from nuitka.containers.OrderedDicts import OrderedDict
from nuitka.containers.OrderedSets import OrderedSet
from nuitka.Errors import NuitkaForbiddenDLLEncounter
from nuitka.plugins.Hooks import isAcceptableMissingDLL
from nuitka.PythonFlavors import (
    getHomebrewInstallPath,
    getMacPortsInstallPath,
    getSystemPrefixPath,
    isAnacondaPython,
    isCPythonOfficialPackage,
    isHomebrewPython,
    isMacPortsPython,
    isMonolithPy,
    isPyenvHomebrewPython,
    isPythonBuildStandalonePython,
)
from nuitka.PythonVersions import python_version
from nuitka.Tracing import inclusion_logger
from nuitka.utils.FileOperations import (
    areSamePaths,
    changeFilenameExtension,
    getNormalizedPath,
    getReportPath,
    getSubDirectories,
    isFilenameBelowPath,
)
from nuitka.utils.Importing import getExtensionModuleSuffixes
from nuitka.utils.Json import loadJsonFromFilename
from nuitka.utils.SharedLibraries import (
    callInstallNameTool,
    getOtoolDependencyOutput,
    getOtoolListing,
    parseOtoolListingOutput,
)
from nuitka.utils.Utils import getArchitecture

from .DllDependenciesCommon import getLdLibraryPath
from .IncludedDataFiles import getIncludedFrameworkDistPathFromSourcePath

# Detected Python rpath is cached.
_detected_python_rpaths = None

# Cached DLL dependency scan results.
_detect_binary_path_dlls_cache = {}


def _clearBinaryPathDLLsMacOSCache(binary_filename):
    binary_filename = getNormalizedPath(binary_filename)

    for cache_key in list(_detect_binary_path_dlls_cache):
        if areSamePaths(cache_key[1], binary_filename):
            del _detect_binary_path_dlls_cache[cache_key]


def _detectPythonRpaths():
    result = []

    if isAnacondaPython() and "CONDA_PREFIX" in os.environ:
        result.append(os.path.normpath(os.path.join(os.environ["CONDA_PREFIX"], "lib")))

    if isAnacondaPython() and "CONDA_PYTHON_EXE" in os.environ:
        result.append(
            os.path.normpath(
                os.path.join(
                    os.path.dirname(os.environ["CONDA_PYTHON_EXE"]), "..", "lib"
                )
            )
        )

    if isCPythonOfficialPackage() or isPythonBuildStandalonePython():
        result.append(os.path.join(getSystemPrefixPath(), "lib"))

    if isMacPortsPython():
        mac_ports_install_path = getMacPortsInstallPath()

        # spell-checker: ignore libexec
        for candidate in (
            os.path.join(getSystemPrefixPath(), "lib"),
            os.path.join(mac_ports_install_path, "Library", "Frameworks"),
            os.path.join(mac_ports_install_path, "lib"),
            os.path.join(mac_ports_install_path, "libexec", "qt6", "lib"),
        ):
            result.append(candidate)

    if isHomebrewPython() or isPyenvHomebrewPython():
        result.extend(
            os.path.join(getHomebrewInstallPath(), directory)
            for directory in getSubDirectories(
                path=getHomebrewInstallPath(), ignore_dirs=("__pycache__",)
            )
        )

    return tuple(
        getNormalizedPath(candidate)
        for candidate in set(result)
        if os.path.isdir(candidate)
    )


def detectBinaryPathDLLsMacOS(
    original_dir,
    binary_filename,
    package_name,
    keep_unresolved,
    recursive_dlls,
    parent_rpaths,
):
    cache_key = (
        original_dir,
        binary_filename,
        package_name,
        tuple(parent_rpaths),
    )

    if cache_key in _detect_binary_path_dlls_cache:
        had_self, resolved_result_items, rpaths = _detect_binary_path_dlls_cache[
            cache_key
        ]
        resolved_result = OrderedDict(resolved_result_items)
    else:
        had_self, resolved_result, rpaths = _detectBinaryPathDLLsMacOS(
            original_dir=original_dir,
            binary_filename=binary_filename,
            package_name=package_name,
            parent_rpaths=parent_rpaths,
        )

        _detect_binary_path_dlls_cache[cache_key] = (
            had_self,
            tuple(resolved_result.items()),
            tuple(rpaths),
        )

    if recursive_dlls is not None:
        merged_result = OrderedDict(resolved_result)

        # For recursive DLL detection, cycle may exist, so we keep track of what
        # was seen so far.
        recursive_dlls.add(binary_filename)

        for sub_dll_filename in resolved_result:
            if sub_dll_filename in recursive_dlls:
                continue

            _, sub_result = detectBinaryPathDLLsMacOS(
                original_dir=os.path.dirname(sub_dll_filename),
                binary_filename=sub_dll_filename,
                package_name=package_name,
                keep_unresolved=True,
                recursive_dlls=recursive_dlls,
                parent_rpaths=rpaths,
            )

            merged_result.update(sub_result)

        resolved_result = merged_result

    if keep_unresolved:
        return had_self, resolved_result
    else:
        return OrderedSet(resolved_result)


def _detectBinaryPathDLLsMacOS(
    original_dir,
    binary_filename,
    package_name,
    parent_rpaths,
):
    assert os.path.exists(binary_filename), binary_filename

    # This is for Anaconda, which puts required libraries of packages in this folder.
    # do it only once, pylint: disable=global-statement
    global _detected_python_rpaths
    if _detected_python_rpaths is None:
        _detected_python_rpaths = _detectPythonRpaths()

    # This is recursive potentially and might add more and more.
    paths = _parseOtoolListingOutput(
        output=getOtoolDependencyOutput(binary_filename),
    )

    return _resolveBinaryPathDLLsMacOS(
        original_dir=original_dir,
        binary_filename=binary_filename,
        paths=paths,
        package_specific_dirs=getLdLibraryPath(
            package_name=package_name,
            python_rpaths=_detected_python_rpaths,
            original_dir=original_dir,
        ),
        package_name=package_name,
        parent_rpaths=parent_rpaths,
    )


def _parseOtoolListingOutput(output):
    result = OrderedSet()

    for filename in parseOtoolListingOutput(output):
        # Ignore dependency from system paths.
        if not isFilenameBelowPath(
            path=(
                "/usr/lib/",
                "/System/Library/Frameworks/",
                "/System/Library/PrivateFrameworks/",
            ),
            filename=filename,
        ):
            result.add(filename)

    return result


def _getNonVersionedDllFilenames2(filename):
    yield filename

    if filename.endswith(".dylib"):
        if getArchitecture() == "arm64":
            yield filename[:-6] + "_arm64.dylib"
        else:
            yield filename[:-6] + "_x86_64.dylib"

    match = re.match(r"^(.*?)(\.\d+)+\.dylib$", filename)

    if match:
        yield match.group(1) + ".dylib"

        # TODO: The versioned filename, might do the same, and so could the
        # "x86_64" specific DLL be a thing too, but we should have actual
        # examples to be sure they are covered with tests.
        if getArchitecture() == "arm64":
            yield match.group(1) + "_arm64.dylib"
        else:
            yield match.group(1) + "_x86_64.dylib"

    if filename.endswith(".so"):
        yield changeFilenameExtension(filename, ".dylib")


def _getNonVersionedDllFilenames(dll_filename, package_name):
    for filename in _getNonVersionedDllFilenames2(dll_filename):
        yield filename

    # Some build systems, internally prefix DLLs with package names, attempt
    # those removed as well.
    if package_name is not None:
        package_prefix = package_name.asString() + "."

        if os.path.basename(dll_filename).startswith(package_prefix):
            dll_filename = os.path.join(
                os.path.dirname(dll_filename),
                os.path.basename(dll_filename)[len(package_prefix) :],
            )

            for filename in _getNonVersionedDllFilenames2(dll_filename):
                yield filename


_package_rpaths = {}


def _resolveBinaryPathDLLsMacOS(
    original_dir,
    binary_filename,
    paths,
    package_specific_dirs,
    package_name,
    parent_rpaths,
):
    # Quite a few variations to consider
    # pylint: disable=too-many-branches,too-many-locals,too-many-statements

    had_self = False

    result = OrderedDict()

    rpaths = _detectBinaryRPathsMacOS(original_dir, binary_filename)
    rpaths.update(parent_rpaths)

    if package_name is not None:
        package_name_str = (
            package_name.asString()
            if hasattr(package_name, "asString")
            else str(package_name)
        )
        if package_name_str not in _package_rpaths:
            _package_rpaths[package_name_str] = OrderedSet()

        rpaths.update(_package_rpaths[package_name_str])
        _package_rpaths[package_name_str].update(rpaths)

    rpaths.update(package_specific_dirs)

    for path in paths:
        if path.startswith("@rpath/"):
            # Resolve rpath to just the ones given, first match.
            library_name = path[7:]

            for rpath in rpaths:
                if os.path.exists(os.path.join(rpath, library_name)):
                    resolved_path = os.path.normpath(os.path.join(rpath, path[7:]))
                    break
            else:
                # These have become virtual in later macOS, spell-checker: ignore libz
                if library_name in ("libc++.1.dylib", "libz.1.dylib"):
                    continue

                # This is only a guess, might be missing package specific directories.
                resolved_path = os.path.normpath(os.path.join(original_dir, path[7:]))
        elif path.startswith("@loader_path/"):
            resolved_path = os.path.normpath(os.path.join(original_dir, path[13:]))
        elif path.startswith("@executable_path/"):
            resolved_path = os.path.normpath(os.path.join(original_dir, path[17:]))
        elif os.path.basename(path) == os.path.basename(binary_filename):
            # We ignore the references to itself coming from the library id.
            continue
        elif os.path.isabs(path) and not os.path.exists(path):
            candidate = os.path.join(original_dir, os.path.basename(path))

            if os.path.exists(candidate):
                resolved_path = candidate
            else:
                resolved_path = path
        elif isMonolithPy() and not os.path.isabs(path) and not os.path.exists(path):
            # Although MonolithPy statically links all packages, some of them
            # have proprietary dependencies that cannot be statically built and
            # must instead be linked to the python executable. Due to how the
            # python executable is linked, we end up with relative paths to
            # dependencies, so we need to scan the Nuitka Python library
            # directories for a matching dll.
            link_data = loadJsonFromFilename(os.path.join(sys.prefix, "link.json"))
            for library_dir in link_data["library_dirs"]:
                candidate = os.path.join(library_dir, path)
                if os.path.exists(candidate):
                    resolved_path = os.path.normpath(candidate)
                    break
        else:
            resolved_path = path

        # Some extension modules seem to reference themselves a wrong name,
        # duplicating their module name into the filename, but that does
        # not exist.
        if not os.path.exists(resolved_path) and package_name is not None:
            parts = os.path.basename(resolved_path).split(".")

            if parts[0] == package_name.asString():
                resolved_path = os.path.join(
                    os.path.dirname(resolved_path), ".".join(parts[1:])
                )

        # Some extension modules seem to reference themselves by a different
        # extension module name, so use that if it exists, and some versioned
        # DLL dependencies do not matter.
        if python_version >= 0x300 and not os.path.exists(resolved_path):
            so_suffixes = getExtensionModuleSuffixes()[:-1]

            specific_suffix = so_suffixes[0]
            abi_suffix = so_suffixes[1]

            for resolved_path_candidate in _getNonVersionedDllFilenames(
                dll_filename=resolved_path, package_name=package_name
            ):
                if os.path.exists(resolved_path_candidate):
                    resolved_path = resolved_path_candidate
                    break

                if resolved_path_candidate.endswith(specific_suffix):
                    candidate = (
                        resolved_path_candidate[: -len(specific_suffix)] + abi_suffix
                    )
                elif resolved_path_candidate.endswith(abi_suffix):
                    candidate = (
                        resolved_path_candidate[: -len(specific_suffix)] + abi_suffix
                    )
                else:
                    candidate = None

                if candidate is not None and os.path.exists(candidate):
                    resolved_path = candidate
                    break

        # Some extension modules seem to reference themselves by a shorter
        # extension module name without the version, so use that if it matches
        # the binary name.
        if not os.path.exists(resolved_path):
            specific_suffix = getExtensionModuleSuffixes()[0]

            if (
                specific_suffix.endswith(".so")
                and binary_filename.endswith(specific_suffix)
                and resolved_path.endswith(".so")
            ):
                if (
                    os.path.basename(resolved_path)
                    == os.path.basename(binary_filename)[: -len(specific_suffix)]
                    + ".so"
                ):
                    resolved_path = binary_filename

        # Sometimes self-dependencies are on a numbered version, but deployed is
        # one version without it. Also be tolerant about changing suffixes
        # internally. The macOS just ignores that, and so we do.
        if not os.path.exists(resolved_path):
            if os.path.basename(resolved_path) == os.path.basename(binary_filename):
                resolved_path = binary_filename
            else:
                for suffix1, suffix2 in (
                    (".dylib", ".dylib"),
                    (".so", ".so"),
                    (".dylib", ".so"),
                    (".so", ".dylib"),
                ):
                    pattern = r"^(.*?)(\.\d+)*%s$" % re.escape(suffix1)

                    match = re.match(pattern, resolved_path)

                    if match:
                        candidate = match.group(1) + suffix2

                        if os.path.exists(candidate):
                            resolved_path = candidate
                        else:
                            candidate = os.path.join(
                                original_dir, os.path.basename(candidate)
                            )

                            if os.path.exists(candidate):
                                resolved_path = candidate
                            elif os.path.basename(candidate) == os.path.basename(
                                binary_filename
                            ):
                                # Versioned dependency on itself in non-existent path.
                                resolved_path = binary_filename
                            else:
                                # The resolved path may reference a less-versioned
                                # variant of the binary itself, e.g.
                                # libfoo.1.dylib when binary is libfoo.1.2.3.dylib.
                                binary_match = re.match(
                                    pattern, os.path.basename(binary_filename)
                                )
                                resolved_match = re.match(
                                    pattern, os.path.basename(resolved_path)
                                )

                                if (
                                    binary_match is not None
                                    and resolved_match is not None
                                    and binary_match.group(1) == resolved_match.group(1)
                                ):
                                    resolved_path = binary_filename

        if not os.path.exists(resolved_path):
            acceptable, plugin_name = isAcceptableMissingDLL(
                package_name=package_name,
                filename=binary_filename,
            )

            # TODO: Missing DLLs that are accepted, are not really forbidden, we
            # should instead acknowledge them as missing, and treat that properly
            # in using code.
            if acceptable is True:
                raise NuitkaForbiddenDLLEncounter(binary_filename, plugin_name)

            # We check both the user and the used DLL if they are listed. This
            # might be a form of bug hiding, that the later is not sufficient,
            # that we should address later.
            acceptable, plugin_name = isAcceptableMissingDLL(
                package_name=package_name,
                filename=resolved_path,
            )

            if acceptable is True:
                raise NuitkaForbiddenDLLEncounter(binary_filename, plugin_name)

            if not path.startswith(("@", "/")):
                continue

            return inclusion_logger.sysexit(
                """\
Error, failed to find path '%s' (resolved DLL to '%s') for binary '%s' from package '%s', please report the bug."""
                % (path, resolved_path, binary_filename, package_name)
            )

        # Some libraries depend on themselves.
        if areSamePaths(binary_filename, resolved_path):
            had_self = True
            continue

        result[resolved_path] = path

    return had_self, result, rpaths


def _detectBinaryRPathsMacOS(original_dir, binary_filename):
    stdout = getOtoolListing(filename=binary_filename, cached=True)

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

    result = OrderedSet()
    result.add(original_dir)

    for i, line in enumerate(lines):
        if line.endswith(b"cmd LC_RPATH"):
            line = lines[i + 2]
            if str is not bytes:
                line = line.decode("utf8")

            line = line.split("path ", 1)[1]
            line = line.split(" (offset", 1)[0]

            if line.startswith("@loader_path"):
                line = os.path.join(original_dir, line[13:])
            elif line.startswith("@executable_path"):
                continue

            result.add(line)

    return result


def _getStandaloneEntryPointDistPathMacOS(
    resolved_filename, binary_filename, standalone_entry_points
):
    for standalone_entry_point in standalone_entry_points:
        if areSamePaths(resolved_filename, standalone_entry_point.source_path):
            return standalone_entry_point.dest_path

        dist_filename = os.path.normpath(
            os.path.join(
                os.path.dirname(binary_filename),
                standalone_entry_point.dest_path,
            )
        )

        if areSamePaths(resolved_filename, dist_filename):
            return standalone_entry_point.dest_path

    return None


def fixupBinaryDLLPathsMacOS(
    binary_filename,
    package_name,
    original_location,
    standalone_entry_points,
    removed_dll_paths,
):
    """For macOS, the binary needs to be told to use relative DLL paths"""
    if areSamePaths(binary_filename, original_location):
        _clearBinaryPathDLLsMacOSCache(original_location)

    try:
        had_self, rpath_map = detectBinaryPathDLLsMacOS(
            original_dir=os.path.dirname(original_location),
            binary_filename=original_location,
            package_name=package_name,
            keep_unresolved=True,
            recursive_dlls=None,
            parent_rpaths=(),
        )
    except NuitkaForbiddenDLLEncounter:
        inclusion_logger.info("Not copying forbidden DLL '%s'." % binary_filename)
    else:
        mapping = []

        for resolved_filename, rpath_filename in rpath_map.items():
            dist_path = _getStandaloneEntryPointDistPathMacOS(
                resolved_filename=resolved_filename,
                binary_filename=binary_filename,
                standalone_entry_points=standalone_entry_points,
            )

            if dist_path is None:
                # Might be a framework, which is internally treated as data
                # files.
                dist_path = getIncludedFrameworkDistPathFromSourcePath(
                    source_path=resolved_filename
                )

            if dist_path is None:
                for removed_dll_path in removed_dll_paths:
                    if areSamePaths(resolved_filename, removed_dll_path):
                        break
                else:
                    return inclusion_logger.sysexit(
                        """\
Error, problem with dependency scan of '%s' with '%s' please report the bug."""
                        % (getReportPath(original_location), rpath_filename)
                    )

                continue

            mapping.append((rpath_filename, "@executable_path/" + dist_path))

        if mapping or had_self:
            callInstallNameTool(
                filename=binary_filename,
                mapping=mapping,
                id_path=os.path.basename(binary_filename) if had_self else None,
                rpath=None,
            )


#     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.
