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


"""Recursion into other modules."""

import glob
import os

from nuitka.Errors import NuitkaForbiddenImportEncounter
from nuitka.freezer.ImportDetection import (
    detectEarlyImports,
    detectStdlibAutoInclusionModules,
)
from nuitka.importing import ImportCache, StandardLibrary
from nuitka.ModuleRegistry import (
    addRootModule,
    addUsedModule,
    getRootTopModule,
)
from nuitka.options.Options import (
    getShallFollowInNoCase,
    getShallFollowModules,
    hasPythonFlagPackageMode,
    isMultidistMode,
    isShowInclusion,
    isStandaloneMode,
    shallFollowAllImports,
    shallFollowNoImports,
    shallFollowStandardLibrary,
    shallMakeModule,
    shallMakePackage,
)
from nuitka.pgo.PGO import decideInclusionFromPGO
from nuitka.plugins.Hooks import (
    considerImplicitImports,
    onModuleEncounter,
    onModuleRecursion,
    onModuleUsageLookAhead,
)
from nuitka.PythonVersions import python_version
from nuitka.Tracing import recursion_logger
from nuitka.utils.FileOperations import listDir
from nuitka.utils.Importing import (
    getExtensionModuleSuffixes,
    getPackageDirFilename,
)
from nuitka.utils.ModuleNames import ModuleName

from .Importing import (
    getModuleNameAndKindFromFilename,
    isPackageDir,
    locateModule,
    warnAboutNotFoundImport,
)


def _recurseTo(module_name, module_filename, module_kind, reason):
    """Internal worker to recurse to a module.

    This actually builds the module object and adds it to the import cache.
    It is used by ``recurseTo`` after decision to recurse has been made.
    """
    from nuitka.tree import Building

    module = Building.buildModule(
        module_name=module_name,
        module_kind=module_kind,
        module_filename=module_filename,
        reason=reason,
        source_code=None,
        is_top=False,
        is_main=False,
        is_fake=False,
        hide_syntax_error=True,
    )

    ImportCache.addImportedModule(module)

    return module


def recurseTo(
    module_name,
    module_filename,
    module_kind,
    source_ref,
    reason,
    using_module_name,
):
    """Recurse to a new module.

    This checks if the module is already imported, and if not, triggers the
    recursion process, potentially calling plugins and then ``_recurseTo``.

    Args:
        module_name: The name of the module.
        module_filename: The filename of the module.
        module_kind: The kind of the module (e.g. 'extension', 'py').
        source_ref: The source reference where the import happens.
        reason: Textual reason why this is done.
        using_module_name: The module name that triggers this recursion.

    Returns:
        The module object.
    """
    try:
        module = ImportCache.getImportedModuleByNameAndPath(
            module_name, module_filename
        )
    except KeyError:
        module = None

    if module is None:
        onModuleRecursion(
            module_filename=module_filename,
            module_name=module_name,
            module_kind=module_kind,
            using_module_name=using_module_name,
            source_ref=source_ref,
            reason=reason,
        )

        module = _recurseTo(
            module_name=module_name,
            module_filename=module_filename,
            module_kind=module_kind,
            reason=reason,
        )

    return module


_recursion_decision_cache = {}


def getRecursionDecisions():
    """Access to recursion decisions, intended for reporting and code generation."""
    return _recursion_decision_cache


def getExcludedModuleNames():
    """Return the list of excluded module names for code generation.

    Returns:
        Yields (module_name, reason) tuples.
    """
    for (
        _using_module_name,
        _module_filename,
        module_name,
        _module_kind,
        _extra_recursion,
    ), (decision, reason) in _recursion_decision_cache.items():
        if decision is False and reason:
            yield module_name, reason


def decideRecursion(
    using_module_name, module_filename, module_name, module_kind, extra_recursion=False
):
    """Decide if we should recurse into a module.

    This function caches its results and handles the logic to decide if a
    module should be included in the compilation or not. It respects the
    user choices, plugins, and standard library rules.

    Args:
        using_module_name: The module name that triggers this recursion.
        module_filename: The filename of the module.
        module_name: The name of the module.
        module_kind: The kind of the module.
        extra_recursion: If true, this is a forced recursion (e.g. plugin).

    Returns:
        A tuple (decision, reason) where decision is a boolean or None, and
        reason is a string explaining the decision.
    """
    package_part, _remainder = module_name.splitModuleBasename()

    if package_part is not None and package_part.getTopLevelPackageName() != "":
        (
            _package_part,
            package_filename,
            package_module_kind,
            package_finding,
        ) = locateModule(module_name=package_part, parent_package=None, level=0)
        assert _package_part == package_part, (_package_part, package_part)

        # For bad decisions, this may already not work.
        if package_finding != "not-found":
            package_decision, package_reason = decideRecursion(
                using_module_name=using_module_name,
                module_filename=package_filename,
                module_name=package_part,
                module_kind=package_module_kind,
                extra_recursion=extra_recursion,
            )

            if package_decision is False:
                return package_decision, package_reason

    key = using_module_name, module_filename, module_name, module_kind, extra_recursion

    if key not in _recursion_decision_cache:
        _recursion_decision_cache[key] = _decideRecursion(
            using_module_name,
            module_filename,
            module_name,
            module_kind,
            extra_recursion,
        )

        # If decided true, give the plugins a chance to e.g. add more hard
        # module information, this indicates tentatively, that a module might
        # get used, but it may also not happen at all.
        if _recursion_decision_cache[key][0]:
            onModuleUsageLookAhead(
                module_name=module_name,
                module_filename=module_filename,
                module_kind=module_kind,
            )

    return _recursion_decision_cache[key]


def _decideRecursion(
    using_module_name, module_filename, module_name, module_kind, extra_recursion
):
    """Internal worker for recursion decision.

    This implements the core logic for ``decideRecursion``. It checks various
    flags, plugins, and standard library status to determine if a module
    should be followed.
    """
    # Many branches, which make decisions immediately, by returning
    # pylint: disable=too-many-branches,too-many-return-statements
    if module_name == "__main__":
        return False, "Main program is not followed to a second time."

    if using_module_name == "__main__" and isMultidistMode():
        return True, "Main program multidist entry points."

    if module_kind == "extension" and not isStandaloneMode():
        return False, "Extension modules cannot be inspected."

    if module_kind == "built-in":
        return False, "Built-in modules cannot be inspected."

    if module_name in detectEarlyImports():
        return True, "Technically required for CPython library startup."

    is_stdlib = module_filename is not None and StandardLibrary.isStandardLibraryPath(
        module_filename
    )

    any_case, reason = module_name.matchesToShellPatterns(
        patterns=getShallFollowModules()
    )

    no_case, reason = module_name.matchesToShellPatterns(
        patterns=getShallFollowInNoCase()
    )

    if is_stdlib and module_name in detectStdlibAutoInclusionModules() and not no_case:
        return True, "Including as part of the non-excluded parts of standard library."

    # In '-m' mode, when including the package, do not duplicate main program.
    if (
        hasPythonFlagPackageMode()
        and not shallMakeModule()
        and module_name.getBasename() == "__main__"
    ):
        if module_name.getPackageName() == getRootTopModule().getRuntimePackageValue():
            return False, "Main program is already included in package mode."

    plugin_decision, deciding_plugins = onModuleEncounter(
        using_module_name=using_module_name,
        module_filename=module_filename,
        module_name=module_name,
        module_kind=module_kind,
    )

    # For checks, we do not consider anti-bloat plugin to be as important, to
    # cause a conflict with the user choices. We could make an extra attribute
    # out of that, but it seems overkill for now.
    deciding_plugins = [
        deciding_plugin
        for deciding_plugin in deciding_plugins
        if deciding_plugin.plugin_name != "anti-bloat"
    ]

    if no_case:
        if plugin_decision and plugin_decision[0]:
            deciding_plugins[0].sysexit(
                "Conflict between user and plugin decision for module '%s'."
                % module_name
            )

        return (
            False,
            "Module %s instructed by user to not follow to using '--nofollow-import-to'."
            % reason,
        )

    if any_case:
        if plugin_decision and not plugin_decision[0] and deciding_plugins:
            deciding_plugins[0].sysexit(
                "Conflict between user and plugin decision for module '%s'."
                % module_name
            )

        return True, "Module %s instructed by user to follow to." % reason

    if plugin_decision is not None:
        return plugin_decision

    if shallMakePackage():
        if module_name.hasNamespace(getRootTopModule().getFullName()):
            return True, "Submodule of compiled package."

    if extra_recursion:
        return True, "Lives in user provided directory."

    if module_kind == "extension" and isStandaloneMode():
        return True, "Extension module needed for standalone mode."

    # PGO decisions are not overruling plugins, but all command line options, they are
    # supposed to be applied already.

    if not is_stdlib or shallFollowStandardLibrary():
        # TODO: Bad placement of this function or should PGO also know about
        # bytecode modules loaded or not.
        from nuitka.tree.Building import decideCompilationMode

        if (
            decideCompilationMode(
                is_top=False,
                module_name=module_name,
                module_filename=module_filename,
                for_pgo=True,
            )
            == "compiled"
        ):
            pgo_decision = decideInclusionFromPGO(
                module_name=module_name,
                module_kind=module_kind,
            )

            if pgo_decision is not None:
                return pgo_decision, "PGO based decision"

    if is_stdlib and not isStandaloneMode() and not shallFollowStandardLibrary():
        return (
            False,
            "Not following into stdlib unless standalone or requested to follow into stdlib.",
        )

    if shallFollowAllImports():
        return (True, "Instructed by user to follow to all modules.")

    if shallFollowNoImports():
        return (
            None,
            "Instructed by user to not follow at all using '--nofollow-imports'.",
        )

    # Means, we were not given instructions how to handle things.
    return (
        None,
        "Default behavior in non-standalone mode, not following without request.",
    )


def isSameModulePath(path1, path2):
    """Check if two paths refer to the same module.

    This normalizes paths, handling ``__init__.py`` specially to compare
    package directories with their init files as equal.
    """
    if os.path.basename(path1) == "__init__.py":
        path1 = os.path.dirname(path1)
    if os.path.basename(path2) == "__init__.py":
        path2 = os.path.dirname(path2)

    return os.path.abspath(path1) == os.path.abspath(path2)


def _addIncludedModule(module, package_only):
    """Add a module to the included modules.

    This is the function that actually adds a module to the compilation set.
    For packages, it may also scan the directory for sub-modules if strictly
    necessary or requested.
    """
    # Many branches, for the decision is very complex
    # pylint: disable=too-many-branches
    if isShowInclusion():
        recursion_logger.info(
            "Included '%s' as '%s'."
            % (
                module.getFullName(),
                module,
            )
        )

    ImportCache.addImportedModule(module)

    if module.isCompiledPythonPackage() or module.isUncompiledPythonPackage():
        package_filename = module.getFilename()

        if os.path.isdir(package_filename):
            # Must be a namespace package.
            assert python_version >= 0x300

            package_dir = package_filename

            # Only include it, if it contains actual modules, they will recurse
            # to this one as a parent and find it again.
        else:
            package_dir = os.path.dirname(package_filename)

            # Real packages will always be included.
            addRootModule(module)

        if isShowInclusion():
            recursion_logger.info("Package directory '%s'." % package_dir)

        if not package_only:
            for sub_path, sub_filename in listDir(package_dir):
                if sub_filename in ("__init__.py", "__pycache__"):
                    continue

                if isPackageDir(sub_path) and not os.path.exists(sub_path + ".py"):
                    scanPluginSinglePath(
                        sub_path,
                        module_package=module.getFullName(),
                        package_only=False,
                    )
                elif sub_filename.endswith(".py"):
                    if os.path.isdir(sub_path[:-3]):
                        if getPackageDirFilename(sub_path[:-3]) is not None:
                            continue

                    scanPluginSinglePath(
                        sub_path,
                        module_package=module.getFullName(),
                        package_only=False,
                    )
                else:
                    for suffix in getExtensionModuleSuffixes():
                        if (
                            sub_filename.endswith(suffix)
                            and "." not in sub_filename[: -len(suffix)]
                        ):
                            scanPluginSinglePath(
                                sub_path,
                                module_package=module.getFullName(),
                                package_only=False,
                            )

    elif module.isCompiledPythonModule() or module.isUncompiledPythonModule():
        addRootModule(module)
    elif module.isPythonExtensionModule():
        if isStandaloneMode():
            addRootModule(module)
    else:
        assert False, module


def scanPluginSinglePath(plugin_filename, module_package, package_only):
    """Scan a single path passed via plugin or command line.

    This checks if the path should be included and if so, recurses to it.
    """
    # The importing wants these to be unique.
    plugin_filename = os.path.abspath(plugin_filename)
    if isShowInclusion():
        recursion_logger.info(
            "Checking detail plug-in path '%s' '%s':"
            % (plugin_filename, module_package)
        )

    module_name, module_kind = getModuleNameAndKindFromFilename(plugin_filename)

    module_name = ModuleName.makeModuleNameInPackage(module_name, module_package)

    if module_kind == "extension" and not isStandaloneMode():
        recursion_logger.warning(
            """\
Cannot include extension module '%s' unless using at least standalone mode, \
where they would be copied. In this mode, extension modules are not part of \
the compiled result, and therefore asking to include them makes no sense.
"""
            % module_name.asString()
        )

    if module_kind is not None:
        decision, decision_reason = decideRecursion(
            using_module_name=None,
            module_filename=plugin_filename,
            module_name=module_name,
            module_kind=module_kind,
            extra_recursion=True,
        )

        if decision:
            module = recurseTo(
                module_filename=plugin_filename,
                module_name=module_name,
                module_kind=module_kind,
                source_ref=None,
                reason="command line",
                using_module_name=None,
            )

            if module:
                _addIncludedModule(module=module, package_only=package_only)
            else:
                recursion_logger.warning(
                    "Failed to include module from '%s'." % plugin_filename
                )
        else:
            recursion_logger.warning(
                "Not allowed to include module '%s' due to '%s'."
                % (module_name, decision_reason)
            )


def scanPluginPath(plugin_filename, module_package):
    """Scan a directory path passed via plugin or command line.

    This iterates over the directory and calls ``scanPluginSinglePath`` for
    items found.
    """
    if isShowInclusion():
        recursion_logger.info(
            "Checking top level inclusion path '%s' for package '%s'."
            % (plugin_filename, module_package)
        )

    # Files and package directories are handled here.
    if os.path.isfile(plugin_filename) or isPackageDir(plugin_filename):
        scanPluginSinglePath(
            plugin_filename,
            module_package=module_package,
            package_only=False,
        )
    # This effectively only covers files known to not be packages due to name
    # or older Python version.
    elif os.path.isdir(plugin_filename):
        for sub_path, sub_filename in listDir(plugin_filename):
            assert sub_filename != "__init__.py"

            if isPackageDir(sub_path) or sub_path.endswith(".py"):
                scanPluginSinglePath(
                    sub_path,
                    module_package=None,
                    package_only=False,
                )
                continue

            for suffix in getExtensionModuleSuffixes():
                if sub_path.endswith(suffix):
                    scanPluginSinglePath(
                        sub_path,
                        module_package=None,
                        package_only=False,
                    )

    else:
        recursion_logger.warning(
            "Failed to include module from '%s'." % plugin_filename
        )


def scanPluginFilenamePattern(pattern):
    """Scan files matching a glob pattern.

    This is used for ``--include-module`` or similar options where wildcards
    are supported.
    """
    if isShowInclusion():
        recursion_logger.info("Checking plug-in pattern '%s':" % pattern)

    assert not os.path.isdir(pattern), pattern

    found = False

    for filename in glob.iglob(pattern):
        if filename.endswith(".pyc"):
            continue

        if not os.path.isfile(filename):
            continue

        found = True
        scanPluginSinglePath(
            filename,
            module_package=None,
            package_only=False,
        )

    if not found:
        recursion_logger.warning(
            "Didn't match any files against pattern '%s'." % pattern
        )


def considerUsedModules(module, pass_count):
    """Consider the used modules of a module for recursion.

    This iterates over the modules used by the given module and decides
    whether to recurse into them. It handles the different passes (stdlib
    vs non-stdlib) and forbidden imports.
    """
    # Modules that are only there because they are in standard library are not
    # supposed to have dependencies included at all.
    if module.reason == "stdlib":
        return

    for used_module in module.getUsedModules():
        # The pass number is used to indicate if stdlib modules yet, or only them

        if used_module.reason == "stdlib":
            if pass_count == 1:
                continue
        else:
            if pass_count == -1:
                continue

        if used_module.finding == "not-found":
            warnAboutNotFoundImport(
                importing=module,
                source_ref=used_module.source_ref,
                module_name=used_module.module_name,
                level=used_module.level,
            )

        # Nothing was found here
        if used_module.filename is None:
            continue

        try:
            decision, decision_reason = decideRecursion(
                using_module_name=module.getFullName(),
                module_filename=used_module.filename,
                module_name=used_module.module_name,
                module_kind=used_module.module_kind,
            )

            if decision:
                new_module = recurseTo(
                    module_name=used_module.module_name,
                    module_filename=used_module.filename,
                    module_kind=used_module.module_kind,
                    source_ref=used_module.source_ref,
                    reason=used_module.reason,
                    using_module_name=module.module_name,
                )

                addUsedModule(
                    module=new_module,
                    using_module=module,
                    # TODO: Cleanup argument names here.
                    usage_tag=used_module.reason,
                    reason=decision_reason,
                    source_ref=used_module.source_ref,
                )
        except NuitkaForbiddenImportEncounter as e:
            recursion_logger.sysexit(
                "Error, forbidden import of '%s' (intending to avoid '%s') in module '%s' at '%s' encountered."
                % (
                    e.args[0],
                    e.args[1],
                    module.getFullName(),
                    used_module.source_ref.getAsString(),
                )
            )

    try:
        considerImplicitImports(module=module)
    except NuitkaForbiddenImportEncounter as e:
        recursion_logger.sysexit(
            "Error, forbidden import of '%s' (intending to avoid '%s') done implicitly by module '%s'."
            % (e.args[0], e.args[1], module.getFullName())
        )


def scanIncludedPackage(package_name):
    """Scan a whole package for inclusion.

    This is used when a package is explicitly requested to be included.
    It locates the package and then scans it.
    """
    package_name, package_directory, _module_kind, finding = locateModule(
        module_name=ModuleName(package_name),
        parent_package=None,
        level=0,
    )

    if finding != "absolute":
        recursion_logger.sysexit(
            "Error, failed to locate package '%s' you asked to include." % package_name
        )

    scanPluginPath(
        plugin_filename=package_directory,
        module_package=package_name.getPackageName(),
    )


#     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
#
#        http://www.gnu.org/licenses/agpl.txt
#
#     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.
