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


"""Caching of C compiler output."""

import ast
import os
import platform
import re
import shlex
import sys
from collections import defaultdict

from nuitka.Tracing import scons_details_logger, scons_logger
from nuitka.utils.AppDirs import getCacheDir
from nuitka.utils.Download import getCachedDownload
from nuitka.utils.Execution import ExecuteProcessResult
from nuitka.utils.FileOperations import (
    areSamePaths,
    getExternalUsePath,
    getFileContentByLine,
    getFileContents,
    getLinkTarget,
    getNormalizedPathJoin,
)
from nuitka.utils.Importing import importFromInlineCopy
from nuitka.utils.Utils import hasMacOSIntelSupport, isMacOS

from .SconsProgress import updateSconsProgressBar
from .SconsUtils import (
    getExecutablePath,
    getSconsReportValue,
    setEnvironmentVariable,
)

# Store for per-module ccache stats retrieved from the Scons build
ccache_module_stats = {}


def getCcacheModuleStats():
    """Returns the per-module hit statistics collected if any."""
    return ccache_module_stats


def _getPythonDirCandidates(python_prefix):
    result = [python_prefix]

    for python_dir in (
        sys.prefix,
        os.getenv("CONDA_PREFIX"),
        os.getenv("CONDA"),
    ):
        if python_dir and python_dir not in result:
            result.append(python_dir)

    return result


def _getCcacheGuessedPaths(python_prefix):
    if isMacOS():
        # For macOS, we might find Homebrew ccache installed but not in PATH.
        for python_dir in _getPythonDirCandidates(python_prefix):
            yield getNormalizedPathJoin(python_dir, "bin", "ccache")

        yield "/usr/local/opt/ccache"
        yield "/opt/homebrew/bin/ccache"


def _injectCcache(env, cc_path, python_prefix, assume_yes_for_downloads):
    ccache_binary = os.getenv("NUITKA_CCACHE_BINARY")

    # If not provided, search it in PATH and guessed directories.
    if ccache_binary is None:
        ccache_binary = getExecutablePath("ccache", env=env)

        if ccache_binary is None:
            for candidate in _getCcacheGuessedPaths(python_prefix):
                scons_details_logger.info(
                    "Checking if ccache is at '%s' guessed path." % candidate
                )

                if os.path.exists(candidate):
                    ccache_binary = candidate

                    scons_details_logger.info(
                        "Using ccache '%s' from guessed path." % ccache_binary
                    )

                    break

        if ccache_binary is None:
            if hasMacOSIntelSupport():
                # The 10.14 is the minimum we managed to compile ccache for.
                if tuple(int(d) for d in platform.release().split(".")) >= (18, 2):
                    url = "https://nuitka.net/ccache/v4.2.1/ccache-4.2.1.zip"

                    ccache_binary = getCachedDownload(
                        name="ccache",
                        url=url,
                        is_arch_specific=False,
                        specificity=url.rsplit("/", 2)[1],
                        unzip=True,
                        flatten=True,
                        binary="ccache",
                        message="Nuitka will make use of ccache to speed up repeated compilation.",
                        reject=None,
                        assume_yes_for_downloads=assume_yes_for_downloads,
                        download_ok=True,
                    )

    else:
        scons_details_logger.info(
            "Using ccache '%s' from 'NUITKA_CCACHE_BINARY' environment variable."
            % ccache_binary
        )

    if ccache_binary is not None and os.path.exists(ccache_binary):
        # In case we are on Windows, make sure the Anaconda form runs outside of Anaconda
        # environment, by adding DLL folder to PATH.
        assert areSamePaths(
            getExecutablePath(os.path.basename(env.the_compiler), env=env), cc_path
        )

        # Spare ccache the detection of the compiler, seems it will also misbehave when it's
        # prefixed with "ccache" on old gcc versions in terms of detecting need for C++ linkage.
        env["LINK"] = '"%s"' % cc_path

        scons_details_logger.info(
            "Found ccache '%s' to cache C compilation result." % ccache_binary
        )
        scons_details_logger.info(
            "Providing real CC path '%s' via PATH extension." % cc_path
        )

        values = [ccache_binary, cc_path]

        if env.zig_mode:
            values.append("cc" if env.c11_mode else "c++")

        # We use absolute paths for CC, pass it like this, as ccache does not like absolute.
        env["CXX"] = env["CC"] = " ".join('"%s"' % value for value in values)

        if env.zig_mode:
            env["LINK"] = " ".join('"%s"' % value for value in values[1:])

        return True

    return False


def enableCcache(env, source_dir, python_prefix):
    inject_ccache = not env.disable_ccache and not env.zig_mode

    if inject_ccache:
        # The ccache needs absolute path, otherwise it will not work.
        # spell-checker: ignore getpid
        ccache_logfile = getExternalUsePath(
            os.path.abspath(
                getNormalizedPathJoin(source_dir, "ccache-%d.txt" % os.getpid())
            ),
            only_dirname=True,
        )

        setEnvironmentVariable(env, "CCACHE_LOGFILE", ccache_logfile)
        env["CCACHE_LOGFILE"] = ccache_logfile

        # Unless asked to do otherwise, store ccache files in our own directory.
        if "CCACHE_DIR" not in os.environ:
            ccache_dir = getCacheDir("ccache", create=True)
            ccache_dir = getExternalUsePath(ccache_dir)
            setEnvironmentVariable(env, "CCACHE_DIR", ccache_dir)
            env["CCACHE_DIR"] = ccache_dir

        if "CLCACHE_MEMCACHED" in os.environ:
            scons_logger.warning(
                "The setting of 'CLCACHE_MEMCACHED' environment is not supported with clcache."
            )
            setEnvironmentVariable(env, "CLCACHE_MEMCACHED", None)

        # We know the include files we created are safe to use.
        setEnvironmentVariable(
            env, "CCACHE_SLOPPINESS", "include_file_ctime,include_file_mtime"
        )

        # First check if it's not already supposed to be a ccache, then do nothing.
        cc_path = getExecutablePath(env.the_compiler, env=env)

        cc_is_link, cc_link_path = getLinkTarget(cc_path)
        if cc_is_link and os.path.basename(cc_link_path) == "ccache":
            scons_details_logger.info(
                "Chosen compiler %s is pointing to ccache %s already."
                % (cc_path, cc_link_path)
            )

            inject_ccache = False

    if inject_ccache:
        inject_ccache = _injectCcache(
            env=env,
            cc_path=cc_path,
            python_prefix=python_prefix,
            assume_yes_for_downloads=env.assume_yes_for_downloads,
        )

    # If we failed to inject zig argument into ccache command line, we need to
    # do it now.
    if env.zig_mode:
        cc_path = getExecutablePath(env.the_compiler, env=env)

        env["CC"] = '"%s" "%s"' % (
            cc_path,
            "cc",
        )
        env["CXX"] = '"%s" "%s"' % (
            cc_path,
            "c++",
        )

        if "CCACHE_DIR" not in os.environ:
            zig_cache_dir = getCacheDir("zig", create=True)

            if not os.getenv("ZIG_LOCAL_CACHE_DIR"):
                zig_cache_dir = getExternalUsePath(zig_cache_dir)
                setEnvironmentVariable(
                    env,
                    "ZIG_LOCAL_CACHE_DIR",
                    getNormalizedPathJoin(zig_cache_dir, "local"),
                )

            if not os.getenv("ZIG_GLOBAL_CACHE_DIR"):
                zig_cache_dir = getExternalUsePath(zig_cache_dir)
                setEnvironmentVariable(
                    env,
                    "ZIG_GLOBAL_CACHE_DIR",
                    getNormalizedPathJoin(zig_cache_dir, "global"),
                )


def enableClcache(env, source_dir):
    # We allow using Python2 still
    if sys.version_info < (3, 5):
        return

    # spell-checker: ignore atomicwrites
    importFromInlineCopy("atomicwrites", must_exist=True)
    importFromInlineCopy("clcache", must_exist=True)

    # Avoid importing this in threads, triggers CPython 3.9 importing bugs at least,
    # do it now, so it's not a race issue.
    import concurrent.futures.thread  # pylint: disable=I0021,unused-import,unused-variable

    cl_binary = getExecutablePath(env.the_compiler, env=env)

    # The compiler is passed via environment.
    setEnvironmentVariable(env, "CLCACHE_CL", cl_binary)
    env["CXX"] = env["CC"] = "<clcache>"

    setEnvironmentVariable(env, "CLCACHE_HIDE_OUTPUTS", "1")

    # Use the mode of "clcache" that is not dependent on MSVC filenames output
    # spell-checker: ignore CLCACHE_NODIRECT
    if "CLCACHE_NODIRECT" not in os.environ:
        setEnvironmentVariable(env, "CLCACHE_NODIRECT", "1")

    # The clcache stats filename needs absolute path, otherwise it will not work.
    clcache_stats_filename = os.path.abspath(
        getNormalizedPathJoin(source_dir, "clcache-stats.%d.txt" % os.getpid())
    )

    setEnvironmentVariable(env, "CLCACHE_STATS", clcache_stats_filename)
    env["CLCACHE_STATS"] = clcache_stats_filename

    # Unless asked to do otherwise, store ccache files in our own directory.
    if "CLCACHE_DIR" not in os.environ:
        clcache_dir = getCacheDir("clcache", create=True)
        clcache_dir = getExternalUsePath(clcache_dir)
        setEnvironmentVariable(env, "CLCACHE_DIR", clcache_dir)
        env["CLCACHE_DIR"] = clcache_dir

    scons_details_logger.info(
        "Using inline copy of clcache with '%s' cl binary." % cl_binary
    )

    import atexit

    atexit.register(_writeClcacheStatistics)


def _writeClcacheStatistics():
    try:
        # pylint: disable=I0021,import-error,no-name-in-module,redefined-outer-name
        from clcache.caching import stats

        if stats is not None:
            stats.save()

    except IOError:
        raise
    except Exception:  # Catch all the things, pylint: disable=broad-except
        # This is run in "atexit" even without the module being loaded, or
        # the stats being begun or usable.
        pass


def _getCcacheStatistics(ccache_logfile):
    # parsing ccache is a bit complicated as we need to work around bugs
    # and version differences
    data = defaultdict(list)

    if os.path.exists(ccache_logfile):
        re_command = re.compile(r"\[.*? (\d+) *\] Command line: (.*)$")
        re_result = re.compile(r"\[.*? (\d+) *\] Result: (.*)$")
        re_anything = re.compile(r"\[.*? (\d+) *\] (.*)$")

        # Remember command from the pid, so later decision logged against pid
        # can be matched against it.
        commands = {}

        # Due to upstream ccache issues, lines in the log might have different
        # encodings, but latin1 will always work and was chosen to write this
        # file therefore.
        for line in getFileContentByLine(
            ccache_logfile, encoding="latin1", errors="backslashreplace"
        ):
            match = re_command.match(line)

            if match:
                pid, command = match.groups()
                commands[pid] = command

            match = re_result.match(line)
            if match:
                pid, result = match.groups()
                result = result.strip()

                try:
                    command = commands[pid]
                except KeyError:
                    # It seems writing to the file can be lossy, so we can have results for
                    # unknown commands, but we don't use the command yet anyway, so just
                    # be unique.
                    command = "unknown command leading to " + line

                # Older ccache on e.g. RHEL6 wasn't explicit about linking.
                if result == "unsupported compiler option":
                    if " -o " in command or "unknown command" in command:
                        result = "called for link"

                # But still try to catch this with log output if it happens.
                if result == "unsupported compiler option":
                    scons_logger.warning(
                        "Encountered unsupported compiler option for ccache in '%s'."
                        % command
                    )

                    all_text = []

                    for line2 in getFileContentByLine(
                        ccache_logfile, encoding="latin1", errors="backslashreplace"
                    ):
                        match = re_anything.match(line2)

                        if match:
                            pid2, result = match.groups()
                            if pid == pid2:
                                all_text.append(result)

                    scons_logger.warning("Full scons output: %s" % all_text)

                if result != "called for link":
                    data[command].append(result)

    return data


def _normalizeCcacheHit(result):
    # These are not important to our users, time based decisions differentiate these.
    if result in (
        "cache hit (direct)",
        "cache hit (preprocessed)",
        "local_storage_hit",
        "primary_storage_hit",
    ):
        return "cache hit"
    elif result == "cache_miss":
        return "cache miss"

    # Newer ccache has these, but they duplicate:
    if result in (
        "direct_cache_hit",
        "direct_cache_miss",
        "preprocessed_cache_hit",
        "preprocessed_cache_miss",
        "primary_storage_miss",
        "called_for_link",
        "no_input_file",
        "local_storage_read_hit",
        "local_storage_read_miss",
        "local_storage_write",
        "local_storage_miss",
        # Usage of incbin causes this for the constants blob integration.
        "unsupported code directive",
        "disabled",
    ):
        return None

    return result


def _getModuleNameFromCommand(command):
    # Command format usually: gcc -o something.o -c ... file.c
    for arg in shlex.split(command):
        if arg.startswith("module.") and arg.endswith(".c"):
            return arg[7:-2]

    return None


def _checkCachingSuccessCcache(source_dir):
    ccache_logfile = getSconsReportValue(source_dir=source_dir, key="CCACHE_LOGFILE")

    if ccache_logfile is not None:
        stats = _getCcacheStatistics(ccache_logfile)

        if not stats:
            scons_logger.warning("""\
You are not using ccache, re-compilation of identical code \
will be slower than necessary. Use your OS package manager \
to install it.""")
        else:
            counts = defaultdict(int)
            for command, results in stats.items():
                for result in results:
                    result = _normalizeCcacheHit(result)

                    if result is None:
                        continue

                    if result in ("cache hit", "cache miss"):
                        module_name = _getModuleNameFromCommand(command)
                        if module_name is not None:
                            ccache_module_stats[module_name] = ("ccache", result)

                    counts[result] += 1

            scons_logger.info(
                "Compiled %d C files using ccache." % sum(counts.values())
            )
            for result, count in sorted(counts.items()):
                scons_logger.info(
                    "Cached C files (using ccache) with result '%s': %d"
                    % (result, count)
                )


def _checkCachingSuccessClcache(source_dir):
    clcache_stats_filename = getSconsReportValue(
        source_dir=source_dir, key="CLCACHE_STATS"
    )

    if clcache_stats_filename is not None and os.path.exists(clcache_stats_filename):
        stats = ast.literal_eval(getFileContents(clcache_stats_filename))

        clcache_hit = stats["CacheHits"]
        clcache_miss = stats["CacheMisses"]

        for source_file, result in stats.get("CacheHistory", {}).items():
            assert result in ("Hit", "Miss")

            module_name = source_file
            if source_file.startswith("module.") and source_file.endswith(".c"):
                module_name = source_file[7:-2]

            ccache_module_stats[module_name] = ("clcache", "cache " + result.lower())

        scons_logger.info(
            "Compiled %d C files using clcache with %d cache hits and %d cache misses."
            % (clcache_hit + clcache_miss, clcache_hit, clcache_miss)
        )


def checkCachingSuccess(source_dir):
    _checkCachingSuccessCcache(source_dir)
    if os.name == "nt":
        _checkCachingSuccessClcache(source_dir)


def runClCache(args, os_env):
    # pylint: disable=I0021,import-error,no-name-in-module,redefined-outer-name
    from clcache.caching import runClCache

    # No Python2 compatibility
    if str is bytes:
        scons_logger.sysexit("Error, cannot use Python2 for scons when using MSVC.")

    # The first argument is "<clcache>" and should not be used.
    stdout, stderr, exit_code = runClCache(
        os.environ["CLCACHE_CL"], [arg.strip('"') for arg in args[1:]], os_env
    )

    updateSconsProgressBar()

    return ExecuteProcessResult(
        stdout=stdout, stderr=stderr, exit_code=exit_code, rusage=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.
