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


"""Spawning processes.

This is to replace the standard spawn implementation with one that tracks the
progress, and gives warnings about things taking very long.
"""

import os
import shlex
import sys
import threading

from nuitka.containers.Namedtuples import makeNamedtupleClass
from nuitka.Tracing import my_print, scons_logger
from nuitka.utils.Execution import Process, executeProcess
from nuitka.utils.FileOperations import getFilenameExtension, getReportPath
from nuitka.utils.Timing import TimerReport

from .SconsCaching import runClCache
from .SconsProgress import (
    closeSconsProgressBar,
    reportSlowCompilation,
    updateSconsProgressBar,
)
from .SconsUtils import (
    decodeData,
    reportSconsUnexpectedOutput,
    writeSconsResourceUsageReport,
)

SubprocessSpawnResult = makeNamedtupleClass(
    "SubprocessSpawnResult",
    (
        "exit_code",
        "stdout",
        "stderr",
        "exception",
        "rusage",
    ),
)


# Thread class to run a command
class SubprocessThread(threading.Thread):
    def __init__(self, cmdline, env):
        threading.Thread.__init__(self)

        self.cmdline = cmdline
        self.env = env

        self.process_result = None

        self.timer_report = TimerReport(
            message="Running %s took %%.2f seconds"
            % repr(self.cmdline).replace("%", "%%"),
            min_report_time=360,
            logger=scons_logger,
        )

    def run(self):
        try:
            # execute the command, queue the result
            with self.timer_report:
                process_result = executeProcess(
                    command=self.cmdline,
                    env=self.env,
                    rusage=True,
                )

                self.process_result = SubprocessSpawnResult(
                    exception=None, **process_result.asDict()
                )

        except Exception as e:  # will rethrow all, pylint: disable=broad-except
            self.process_result = SubprocessSpawnResult(
                exit_code=None,
                stdout=None,
                stderr=None,
                exception=e,
                rusage=None,
            )

    def getProcessResult(self):
        return self.process_result


def _runProcessMonitored(env, cmdline, os_env):
    thread = SubprocessThread(cmdline, os_env)
    thread.start()

    # Allow 6 minutes before warning for long compile time.
    thread.join(360)

    if thread.is_alive():
        reportSlowCompilation(env, cmdline, thread.timer_report.getTimer().getDelta())

    thread.join()

    updateSconsProgressBar()

    return thread.getProcessResult()


def _filterMsvcLinkOutput(env, data, exit_code):
    # Training newline in some cases, esp. LTO it seems.
    data = data.rstrip()

    # It might be saying it's creating a link library.
    data = b"\r\n".join(
        line
        for line in data.split(b"\r\n")
        if b"   Creating library" not in line
        # On localized compilers, the message to ignore is not as clear.
        if not (b".exp" in line and b".lib" in line)
    )

    # The linker will say generating code at the end, due to localization
    # we don't know.
    if env.lto_mode and exit_code == 0:
        if len(data.split(b"\r\n")) == 2:
            data = b""

    if env.pgo_mode == "use" and exit_code == 0:
        # Very noisy, partially in native language for PGO link.
        data = b""

    return data


def _raiseCorruptedObjectFilesExit(cache_name):
    """Error exit due to corrupt object files and point to cache cleanup."""
    scons_logger.sysexit("""\
Error, the C linker reported a corrupt object file. You may need to run
Nuitka with '--clean-cache=%s' once to repair it, or else will
surely happen again.""" % cache_name)


def _getNoSuchCommandErrorMessage():
    import ctypes

    return ctypes.WinError(3).args[1]


# To work around Windows not supporting command lines of greater than 10K by
# default:
def _getWindowsSpawnFunction(env, source_files):
    # Too much error handling error, pylint: disable=too-many-branches

    def spawnWindowsCommand(
        sh, escape, cmd, args, os_env
    ):  # pylint: disable=unused-argument
        """Our own spawn implementation for use on Windows."""
        assert type(args) in (list, tuple)

        # The "del" appears to not work reliably, but is used with large amounts of
        # files to link. So, lets do this ourselves, plus it avoids a process
        # spawn.
        if cmd == "del":
            assert len(args) == 2

            os.unlink(args[1])
            return 0

        # For quoted arguments that end in a backslash, things don't work well
        # this is a workaround for it.
        def removeTrailingSlashQuote(arg):
            if arg.endswith(r"\""):
                return arg[:-1] + '\\"'
            else:
                return arg

        new_args = " ".join(removeTrailingSlashQuote(arg) for arg in args[1:])
        cmdline = cmd + " " + new_args

        # Special hook for clcache inline copy
        if cmd == "<clcache>":
            process_result = runClCache(
                args=args,
                os_env=os_env,
            )
        else:
            process_result = _runProcessMonitored(
                env=env,
                cmdline=cmdline,
                os_env=os_env,
            )

            if process_result.exception:
                closeSconsProgressBar()
                raise process_result.exception

        if process_result.exit_code != 0:
            closeSconsProgressBar()

        data = process_result.stdout

        if cmd == "link":
            data = _filterMsvcLinkOutput(
                env=env,
                data=data,
                exit_code=process_result.exit_code,
            )
        elif cmd in ("cl", "<clcache>"):
            # Skip forced output from cl.exe
            data = data[data.find(b"\r\n") + 2 :]

            source_base_names = [
                os.path.basename(source_file) for source_file in source_files
            ]

            def check(line):
                return line in (b"", b"Generating Code...") or line in source_base_names

            data = (
                b"\r\n".join(line for line in data.split(b"\r\n") if not check(line))
                + b"\r\n"
            )

        if data is not None and data.rstrip():
            my_print("Unexpected output from this command:", style="scons-unexpected")
            my_print(cmdline, style="scons-unexpected")

            if str is not bytes:
                data = decodeData(data)

            my_print(
                data, style="scons-unexpected", end="" if data.endswith("\n") else "\n"
            )

            reportSconsUnexpectedOutput(env, cmdline, stdout=data, stderr=None)

        err = process_result.stderr
        if err:
            if str is not bytes:
                err = decodeData(err)

            err = "\r\n".join(
                line
                for line in err.split("\r\n")
                if not isIgnoredError(line)
                if not (
                    env.mingw_mode
                    and env.lto_mode
                    and line == _getNoSuchCommandErrorMessage()
                )
            )

            if err:
                if "corrupt file" in err:
                    _raiseCorruptedObjectFilesExit(cache_name="clcache")
                if "Bad magic value" in err:
                    _raiseCorruptedObjectFilesExit(cache_name="ccache")

                err += "\r\n"
                my_print(err, style="scons-unexpected", end="")

                reportSconsUnexpectedOutput(env, cmdline, stdout=None, stderr=err)

        return process_result.exit_code

    return spawnWindowsCommand


def reverseShQuoting(arg):
    # Undo the damage that scons did in order to pass it to "sh"
    result = shlex.split(arg)
    assert len(result) == 1, (arg, result)
    return result[0]


def _formatForOutput(arg):
    if arg.startswith("-I"):
        prefix = "-I"
        arg = arg[2:]
    else:
        prefix = ""

    return prefix + getReportPath(arg)


def isIgnoredError(line):
    # Many cases and return driven, pylint: disable=too-many-branches,too-many-return-statements

    # spell-checker: ignore tmpnam,tempnam,structseq,bytearrayobject

    # Debian Python2 static libpython lto warnings:
    if "function `posix_tmpnam':" in line:
        return True
    if "function `posix_tempnam':" in line:
        return True

    # Self compiled Python2 static libpython lot warnings:
    if "the use of `tmpnam_r' is dangerous" in line:
        return True
    if "the use of `tempnam' is dangerous" in line:
        return True
    if line.startswith(("Objects/structseq.c:", "Python/import.c:")):
        return True
    if line == "In function 'load_next',":
        return True
    if "at Python/import.c" in line:
        return True

    # Debian Bullseye when compiling in directory with spaces:
    if "overriding recipe for target" in line:
        return True
    if "ignoring old recipe for target" in line:
        return True
    if "Error 1 (ignored)" in line:
        return True

    # Trusty has buggy toolchain that does this with LTO.
    if line == """\
bytearrayobject.o (symbol from plugin): warning: memset used with constant zero \
length parameter; this could be due to transposed parameters""":
        return True

    # The gcc LTO with debug information is deeply buggy with many messages:
    if "Dwarf Error:" in line:
        return True

    # gcc from MinGW64 12.1 gives these, that seem non-consequential.
    if line.startswith("mingw32-make:") and line.endswith("Error 1 (ignored)"):
        return True

    return False


class SpawnThread(threading.Thread):
    def __init__(self, env, command, os_env):
        threading.Thread.__init__(self)

        self.env = env
        self.command = command
        self.os_env = os_env

        self.timer_report = TimerReport(
            message="Running %s took %%.2f seconds"
            % (
                " ".join(_formatForOutput(arg) for arg in self.command).replace(
                    "%", "%%"
                ),
            ),
            min_report_time=360,
            logger=scons_logger,
        )

        self.process_result = None

        self.is_terminated = False
        self.process = None

    def run(self):
        try:
            # execute the command, queue the result
            with self.timer_report:
                self.process_result = self.spawnSubprocess(env=self.env)
        except Exception as e:  # will rethrow all, pylint: disable=broad-except
            self.process_result = SubprocessSpawnResult(
                stdout=None, stderr=None, exit_code=None, exception=e, rusage=None
            )
        except SystemExit as e:
            self.process_result = SubprocessSpawnResult(
                stdout=None, stderr=None, exit_code=e.code, exception=None, rusage=None
            )

    def spawnSubprocess(self, env):
        self.process = Process(
            command=self.command,
            env=self.os_env,
            rusage=True,
        )
        process_result = self.process.communicate()
        exit_code = process_result.exit_code

        if self.is_terminated and exit_code != 0:
            return SubprocessSpawnResult(exception=None, **process_result.asDict())

        stderr = process_result.stderr
        if str is not bytes:
            stderr = decodeData(stderr)

        ignore_next = False
        for line in stderr.splitlines():
            if ignore_next:
                ignore_next = False
                continue

            # Corrupt object files, probably from ccache being interrupted at the wrong time.
            if "Bad magic value" in line:
                _raiseCorruptedObjectFilesExit(cache_name="ccache")

            if isIgnoredError(line):
                ignore_next = True
                continue

            if exit_code != 0 and "terminated with signal 11" in line:
                exit_code = -11

            my_print(line, style="scons-unexpected", file=sys.stderr)

            reportSconsUnexpectedOutput(env, self.command, stdout=None, stderr=line)

        return SubprocessSpawnResult(
            exception=None, **process_result.replace(exit_code=exit_code).asDict()
        )

    def getSpawnResult(self):
        return self.process_result

    def stopThread(self):
        self.is_terminated = True
        if self.process is not None:
            self.process.stop()


_threads = []
_stop_threads_lock = threading.Lock()


def _stopOtherThreads():
    # Ensure only one thread can stop others. Never released, only supposed
    # to happen once, pylint: disable=consider-using-with
    if _stop_threads_lock.acquire(False):

        current_thread = threading.current_thread()
        for thread in list(_threads):  # Iterate over a copy
            if thread is not current_thread:
                thread.stopThread()


def _runSpawnMonitored(env, args, os_env):
    thread = SpawnThread(env, args, os_env)

    _threads.append(thread)

    try:
        thread.start()

        # Allow 6 minutes before warning for long compile time.
        thread.join(360)

        if thread.is_alive():
            reportSlowCompilation(env, args, thread.timer_report.getTimer().getDelta())

        thread.join()

        spawn_result = thread.getSpawnResult()

        if spawn_result.exit_code == 0 and spawn_result.exception is None:
            updateSconsProgressBar()

        return spawn_result

    finally:
        _threads.remove(thread)


def _getWrappedSpawnFunction(env):
    def spawnCommand(sh, escape, cmd, args, os_env):
        # Signature needed towards Scons core, pylint: disable=unused-argument
        assert type(args) in (list, tuple)

        args = [reverseShQuoting(arg) for arg in args]

        source_filename = None
        source_name = None

        for arg in args[1:]:
            source_basename = os.path.basename(arg)
            source_ext = getFilenameExtension(source_basename)

            if source_ext in (".c", ".cpp") and os.path.exists(arg):
                source_name = source_basename[: -len(source_ext)]
                source_filename = arg
                break

        # Avoid using ccache on binary constants blob, not useful and not working
        # with old ccache.
        if source_filename is not None and source_name == "__constants_data":
            os_env = dict(os_env)
            os_env["CCACHE_DISABLE"] = "1"

        spawn_result = _runSpawnMonitored(env, args, os_env)

        if spawn_result.exception:
            closeSconsProgressBar()
            _stopOtherThreads()

            raise spawn_result.exception

        if spawn_result.exit_code != 0:
            closeSconsProgressBar()
            _stopOtherThreads()

        # Segmentation fault should give a clear error.
        if spawn_result.exit_code == -11:
            scons_logger.sysexit("""\
Error, the C compiler '%s' crashed with segfault. Consider upgrading \
it or using '--clang' option.""" % env.the_compiler)

        if (
            spawn_result.exit_code == 0
            and spawn_result.rusage is not None
            and env.collect_resources
        ):
            if source_filename is not None:
                writeSconsResourceUsageReport(
                    source_dir=env.source_dir,
                    source_filename=os.path.basename(source_filename),
                    rusage=spawn_result.rusage,
                )
            elif "-o" in args:
                writeSconsResourceUsageReport(
                    source_dir=env.source_dir,
                    source_filename="@linker",
                    rusage=spawn_result.rusage,
                )

        return spawn_result.exit_code

    return spawnCommand


def enableSpawnMonitoring(env, source_files):
    if os.name == "nt":
        env["SPAWN"] = _getWindowsSpawnFunction(env=env, source_files=source_files)
    else:
        env["SPAWN"] = _getWrappedSpawnFunction(env=env)


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