# Copyright 1999-2025 Gentoo Authors
# Distributed under the terms of the GNU General Public License v2

import functools
import io
import stat
import textwrap
from _emerge.SpawnProcess import SpawnProcess
from _emerge.EbuildBuildDir import EbuildBuildDir
from _emerge.EbuildIpcDaemon import EbuildIpcDaemon
import portage
from portage.elog import messages as elog_messages
from portage import installation
from portage.package.ebuild._ipc.ExitCommand import ExitCommand
from portage.package.ebuild._ipc.QueryCommand import QueryCommand
from portage import os
from portage.util.futures import asyncio
from portage.util import apply_secpass_permissions, no_color


class AbstractEbuildProcess(SpawnProcess):
    __slots__ = (
        "phase",
        "settings",
        "_build_dir",
        "_build_dir_unlock",
        "_ipc_daemon",
        "_exit_command",
        "_exit_timeout_id",
        "_start_future",
    )

    _phases_without_builddir = (
        "clean",
        "cleanrm",
        "depend",
        "help",
    )
    _phases_interactive_whitelist = ("config",)

    # Number of milliseconds to allow natural exit of the ebuild
    # process after it has called the exit command via IPC. It
    # doesn't hurt to be generous here since the scheduler
    # continues to process events during this period, and it can
    # return long before the timeout expires.
    _exit_timeout = 10  # seconds

    # The EbuildIpcDaemon support is well tested, but this variable
    # is left so we can temporarily disable it if any issues arise.
    _enable_ipc_daemon = (
        installation.TYPE == installation.TYPES.SOURCE or "True" == "True"
    )

    def __init__(self, **kwargs):
        SpawnProcess.__init__(self, **kwargs)
        if self.phase is None:
            phase = self.settings.get("EBUILD_PHASE")
            if not phase:
                phase = "other"
            self.phase = phase

    def _start(self):
        need_builddir = self.phase not in self._phases_without_builddir

        # This can happen if the pre-clean phase triggers
        # die_hooks for some reason, and PORTAGE_BUILDDIR
        # doesn't exist yet.
        if need_builddir and not os.path.isdir(self.settings["PORTAGE_BUILDDIR"]):
            msg = (
                f"The ebuild phase '{self.phase}' has been aborted since "
                f"PORTAGE_BUILDDIR does not exist: '{self.settings['PORTAGE_BUILDDIR']}'"
            )
            self._eerror(textwrap.wrap(msg, 72))
            self.returncode = 1
            self._async_wait()
            return

        start_ipc_daemon = False
        if self._enable_ipc_daemon:
            self.settings.pop("PORTAGE_EBUILD_EXIT_FILE", None)
            if self.phase not in self._phases_without_builddir:
                start_ipc_daemon = True
                if "PORTAGE_BUILDDIR_LOCKED" not in self.settings:
                    self._build_dir = EbuildBuildDir(
                        scheduler=self.scheduler, settings=self.settings
                    )
                    self._start_future = self._build_dir.async_lock()
                    self._start_future.add_done_callback(
                        functools.partial(
                            self._start_post_builddir_lock,
                            start_ipc_daemon=start_ipc_daemon,
                        )
                    )
                    return
            else:
                self.settings.pop("PORTAGE_IPC_DAEMON", None)
        else:
            # Since the IPC daemon is disabled, use a simple tempfile based
            # approach to detect unexpected exit like in bug #190128.
            self.settings.pop("PORTAGE_IPC_DAEMON", None)
            if self.phase not in self._phases_without_builddir:
                exit_file = os.path.join(
                    self.settings["PORTAGE_BUILDDIR"], ".exit_status"
                )
                self.settings["PORTAGE_EBUILD_EXIT_FILE"] = exit_file
                try:
                    os.unlink(exit_file)
                except OSError:
                    if os.path.exists(exit_file):
                        # make sure it doesn't exist
                        raise
            else:
                self.settings.pop("PORTAGE_EBUILD_EXIT_FILE", None)

        self._start_post_builddir_lock(start_ipc_daemon=start_ipc_daemon)

    def _start_post_builddir_lock(self, lock_future=None, start_ipc_daemon=False):
        if lock_future is not None:
            if lock_future is not self._start_future:
                raise AssertionError("lock_future is not self._start_future")
            self._start_future = None
            if lock_future.cancelled():
                self._build_dir = None
                self.cancelled = True
                self._was_cancelled()
                self._async_wait()
                return

            lock_future.result()

        if start_ipc_daemon:
            self.settings["PORTAGE_IPC_DAEMON"] = "1"
            self._start_ipc_daemon()

        if self.fd_pipes is None:
            self.fd_pipes = {}
        null_fd = None
        if (
            0 not in self.fd_pipes
            and self.phase not in self._phases_interactive_whitelist
            and "interactive" not in self.settings.get("PROPERTIES", "").split()
        ):
            null_fd = os.open("/dev/null", os.O_RDONLY)
            self.fd_pipes[0] = null_fd

        self.log_filter_file = self.settings.get("PORTAGE_LOG_FILTER_FILE_CMD")
        try:
            SpawnProcess._start(self)
        finally:
            if null_fd is not None:
                os.close(null_fd)

    def _init_ipc_fifos(self):
        input_fifo = os.path.join(self.settings["PORTAGE_BUILDDIR"], ".ipc", "in")
        output_fifo = os.path.join(self.settings["PORTAGE_BUILDDIR"], ".ipc", "out")

        for p in (input_fifo, output_fifo):
            st = None
            try:
                st = os.lstat(p)
            except OSError:
                os.mkfifo(p)
            else:
                if not stat.S_ISFIFO(st.st_mode):
                    st = None
                    try:
                        os.unlink(p)
                    except OSError:
                        pass
                    os.mkfifo(p)

            apply_secpass_permissions(
                p,
                uid=os.getuid(),
                gid=portage.data.portage_gid,
                mode=0o770,
                stat_cached=st,
            )

        return (input_fifo, output_fifo)

    def _start_ipc_daemon(self):
        self._exit_command = ExitCommand()
        self._exit_command.reply_hook = self._exit_command_callback
        query_command = QueryCommand(self.settings, self.phase)
        commands = {
            "available_eclasses": query_command,
            "best_version": query_command,
            "eclass_path": query_command,
            "exit": self._exit_command,
            "has_version": query_command,
            "license_path": query_command,
            "master_repositories": query_command,
            "repository_path": query_command,
        }
        input_fifo, output_fifo = self._init_ipc_fifos()
        self._ipc_daemon = EbuildIpcDaemon(
            commands=commands,
            input_fifo=input_fifo,
            output_fifo=output_fifo,
            scheduler=self.scheduler,
        )
        self._ipc_daemon.start()

    def _exit_command_callback(self):
        if self._registered:
            # Let the process exit naturally, if possible.
            self._exit_timeout_id = self.scheduler.call_later(
                self._exit_timeout, self._exit_command_timeout_cb
            )

    def _exit_command_timeout_cb(self):
        if self._registered:
            # If it doesn't exit naturally in a reasonable amount
            # of time, kill it (solves bug #278895). We try to avoid
            # this when possible since it makes sandbox complain about
            # being killed by a signal.
            self.cancel()
            self._exit_timeout_id = self.scheduler.call_later(
                self._cancel_timeout, self._cancel_timeout_cb
            )
        else:
            self._exit_timeout_id = None

    def _cancel_timeout_cb(self):
        self._exit_timeout_id = None
        self._async_waitpid()

    def _orphan_process_warn(self):
        phase = self.phase

        msg = (
            f"The ebuild phase '{phase}' with pid {self.pid} appears "
            "to have left an orphan process running in the background."
        )

        self._eerror(textwrap.wrap(msg, 72))

    def _can_log(self, slave_fd):
        # With sesandbox, logging works through a pty but not through a
        # normal pipe. So, disable logging if ptys are broken.
        # See Bug #162404.
        # TODO: Add support for logging via named pipe (fifo) with
        # sesandbox, since EbuildIpcDaemon uses a fifo and it's known
        # to be compatible with sesandbox.
        return not (
            "sesandbox" in self.settings.features and self.settings.selinux_enabled()
        ) or os.isatty(slave_fd)

    def _killed_by_signal(self, signum):
        msg = f"The ebuild phase '{self.phase}' has been killed by signal {signum}."
        self._eerror(textwrap.wrap(msg, 72))

    def _unexpected_exit(self):
        phase = self.phase

        msg = (
            f"The ebuild phase '{phase}' has exited "
            "unexpectedly. This type of behavior "
            "is known to be triggered "
            "by things such as failed variable "
            "assignments (bug #190128) or bad substitution "
            "errors (bug #200313). Normally, before exiting, bash should "
            "have displayed an error message above. If bash did not "
            "produce an error message above, it's possible "
            "that the ebuild has called `exit` when it "
            "should have called `die` instead. This behavior may also "
            "be triggered by a corrupt bash binary or a hardware "
            "problem such as memory or cpu malfunction. If the problem is not "
            "reproducible or it appears to occur randomly, then it is likely "
            "to be triggered by a hardware problem. "
            "If you suspect a hardware problem then you should "
            "try some basic hardware diagnostics such as memtest. "
            "Please do not report this as a bug unless it is consistently "
            "reproducible and you are sure that your bash binary and hardware "
            "are functioning properly."
        )

        self._eerror(textwrap.wrap(msg, 72))

    def _eerror(self, lines):
        self._elog("eerror", lines)

    def _elog(self, elog_funcname, lines):
        out = io.StringIO()
        phase = self.phase
        elog_func = getattr(elog_messages, elog_funcname)
        global_havecolor = portage.output.havecolor
        try:
            portage.output.havecolor = not no_color(self.settings)
            for line in lines:
                elog_func(line, phase=phase, key=self.settings.mycpv, out=out)
        finally:
            portage.output.havecolor = global_havecolor
        msg = out.getvalue()
        if msg:
            log_path = None
            if self.settings.get("PORTAGE_BACKGROUND") != "subprocess":
                log_path = self.settings.get("PORTAGE_LOG_FILE")
            self.scheduler.output(msg, log_path=log_path)

    def _async_waitpid_cb(self, *args, **kwargs):
        """
        Override _async_waitpid_cb to perform cleanup that is
        not necessarily idempotent.
        """
        SpawnProcess._async_waitpid_cb(self, *args, **kwargs)

        if self._exit_timeout_id is not None:
            self._exit_timeout_id.cancel()
            self._exit_timeout_id = None

        if self._ipc_daemon is not None:
            self._ipc_daemon.cancel()
            if self._exit_command.exitcode is not None:
                self.returncode = self._exit_command.exitcode
            else:
                if self.returncode < 0:
                    if not self.cancelled:
                        self._killed_by_signal(-self.returncode)
                else:
                    self.returncode = 1
                    if not self.cancelled:
                        self._unexpected_exit()

        elif not self.cancelled:
            exit_file = self.settings.get("PORTAGE_EBUILD_EXIT_FILE")
            if exit_file and not os.path.exists(exit_file):
                if self.returncode < 0:
                    if not self.cancelled:
                        self._killed_by_signal(-self.returncode)
                else:
                    self.returncode = 1
                    if not self.cancelled:
                        self._unexpected_exit()

    def _async_wait(self):
        """
        Override _async_wait to asynchronously unlock self._build_dir
        when necessary.
        """
        if self._build_dir is None:
            SpawnProcess._async_wait(self)
        elif self._build_dir_unlock is None:
            if self.returncode is None:
                raise asyncio.InvalidStateError(f"Result is not ready for {self}")
            self._async_unlock_builddir(returncode=self.returncode)

    def _async_unlock_builddir(self, returncode=None):
        """
        Release the lock asynchronously, and if a returncode parameter
        is given then set self.returncode and notify exit listeners.
        """
        if self._build_dir_unlock is not None:
            raise AssertionError("unlock already in progress")
        if returncode is not None:
            # The returncode will be set after unlock is complete.
            self.returncode = None
        self._build_dir_unlock = self._build_dir.async_unlock()
        # Unlock only once.
        self._build_dir = None
        self._build_dir_unlock.add_done_callback(
            functools.partial(self._unlock_builddir_exit, returncode=returncode)
        )

    def _unlock_builddir_exit(self, unlock_future, returncode=None):
        # Normally, async_unlock should not raise an exception here.
        unlock_future.cancelled() or unlock_future.result()
        if returncode is not None:
            if unlock_future.cancelled():
                self.cancelled = True
                self._was_cancelled()
            else:
                self.returncode = returncode
            SpawnProcess._async_wait(self)
