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

import fcntl
import os
import sys
import tempfile

import portage
from portage.cache.mappings import slot_dict_class
from portage.util.futures import asyncio

from _emerge.SubProcess import SubProcess


class EbuildMetadataPhase(SubProcess):
    """
    Asynchronous interface for the ebuild "depend" phase which is
    used to extract metadata from the ebuild.
    """

    __slots__ = (
        "cpv",
        "eapi_supported",
        "ebuild_hash",
        "fd_pipes",
        "metadata",
        "portdb",
        "repo_path",
        "settings",
        "deallocate_config",
        "portage_ebuild_extra_source",
        "write_auxdb",
    ) + (
        "_eapi",
        "_eapi_lineno",
        "_raw_metadata",
    )

    _file_names = ("ebuild",)
    _files_dict = slot_dict_class(_file_names, prefix="")

    def _start(self):
        asyncio.ensure_future(
            self._async_start(), loop=self.scheduler
        ).add_done_callback(self._async_start_done)
        self._registered = True

    async def _async_start(self):
        from portage.package.ebuild.doebuild import doebuild

        from _emerge.EbuildPhase import _setup_locale

        ebuild_path = self.ebuild_hash.location

        with open(
            ebuild_path,
            encoding="utf-8",
            errors="replace",
        ) as f:
            self._eapi, self._eapi_lineno = portage._parse_eapi_ebuild_head(f)

        parsed_eapi = self._eapi
        if parsed_eapi is None:
            parsed_eapi = "0"

        if not parsed_eapi:
            # An empty EAPI setting is invalid.
            self._eapi_invalid(None)
            self.returncode = 1
            self._async_wait()
            return

        self.eapi_supported = portage.eapi_is_supported(parsed_eapi)
        if not self.eapi_supported:
            self.metadata = {"EAPI": parsed_eapi}
            self.returncode = os.EX_OK
            self._async_wait()
            return

        settings = self.settings
        settings.setcpv(self.cpv)
        settings.configdict["pkg"]["EAPI"] = parsed_eapi

        # This requires above setcpv and EAPI setup.
        await _setup_locale(self.settings)

        debug = settings.get("PORTAGE_DEBUG") == "1"
        master_fd = None
        slave_fd = None
        fd_pipes = None
        if self.fd_pipes is not None:
            fd_pipes = self.fd_pipes.copy()
        else:
            fd_pipes = {}

        null_input = open("/dev/null", "rb")
        fd_pipes.setdefault(0, null_input.fileno())
        fd_pipes.setdefault(1, sys.__stdout__.fileno())
        fd_pipes.setdefault(2, sys.__stderr__.fileno())

        # flush any pending output
        stdout_filenos = (sys.__stdout__.fileno(), sys.__stderr__.fileno())
        for fd in fd_pipes.values():
            if fd in stdout_filenos:
                sys.__stdout__.flush()
                sys.__stderr__.flush()
                break

        self._files = self._files_dict()
        files = self._files

        master_fd, slave_fd = os.pipe()

        fcntl.fcntl(
            master_fd,
            fcntl.F_SETFL,
            fcntl.fcntl(master_fd, fcntl.F_GETFL) | os.O_NONBLOCK,
        )

        fd_pipes[slave_fd] = slave_fd
        settings["PORTAGE_PIPE_FD"] = str(slave_fd)

        self._raw_metadata = []
        files.ebuild = master_fd
        self.scheduler.add_reader(files.ebuild, self._output_handler)

        tempfile_args = {}
        if sys.version_info >= (3, 12):
            tempfile_args["delete_on_close"] = False
        settings["SANDBOX_LOG"] = tempfile.NamedTemporaryFile(
            prefix="sandbox", **tempfile_args
        ).name

        retval = doebuild(
            ebuild_path,
            "depend",
            settings=settings,
            debug=debug,
            mydbapi=self.portdb,
            tree="porttree",
            fd_pipes=fd_pipes,
            returnproc=True,
        )
        settings.pop("PORTAGE_PIPE_FD", None)
        # At this point we can return settings to the caller
        # since we never use it for anything more than an
        # eapi_invalid call after this, and eapi_invalid is
        # insensitive to concurrent modifications.
        if (
            self.deallocate_config is not None
            and not self.deallocate_config.cancelled()
        ):
            self.deallocate_config.set_result(settings)

        os.close(slave_fd)
        null_input.close()

        if isinstance(retval, int):
            # doebuild failed before spawning
            self.returncode = retval
            self._async_wait()
            return

        self._proc = retval

    def _async_start_done(self, future):
        future.cancelled() or future.result()
        if not self._was_cancelled() and future.cancelled():
            self.cancel()
            self._was_cancelled()

        self.portage_ebuild_extra_source = self.settings.get(
            "PORTAGE_EBUILD_EXTRA_SOURCE"
        )

        if self.deallocate_config is not None and not self.deallocate_config.done():
            self.deallocate_config.set_result(self.settings)

        if self.returncode is not None:
            self._unregister()
            self.wait()

    def _output_handler(self):
        while True:
            buf = self._read_buf(self._files.ebuild)
            if buf is None:
                break  # EAGAIN
            elif buf:
                self._raw_metadata.append(buf)
            else:  # EIO/POLLHUP
                if self.pid is None:
                    self._unregister()
                    self._async_wait()
                else:
                    self._async_waitpid()
                break

    def _unregister(self):
        if self._files is not None:
            self.scheduler.remove_reader(self._files.ebuild)
        SubProcess._unregister(self)
        if self.portage_ebuild_extra_source:
            os.unlink(self.portage_ebuild_extra_source)

    def _async_waitpid_cb(self, *args, **kwargs):
        """
        Override _async_waitpid_cb to perform cleanup that is
        not necessarily idempotent.
        """
        SubProcess._async_waitpid_cb(self, *args, **kwargs)
        # self._raw_metadata is None when _start returns
        # early due to an unsupported EAPI
        if self.returncode == os.EX_OK and self._raw_metadata is not None:
            metadata_lines = (
                b"".join(self._raw_metadata).decode("utf-8", "replace").splitlines()
            )
            metadata = {}
            metadata_valid = True
            for l in metadata_lines:
                if "=" not in l:
                    metadata_valid = False
                    break
                key, value = l.split("=", 1)
                metadata[key] = value

            if metadata_valid and "EAPI" not in metadata:
                metadata_valid = False

            if metadata_valid:
                parsed_eapi = self._eapi
                if parsed_eapi is None:
                    parsed_eapi = "0"
                self.eapi_supported = portage.eapi_is_supported(metadata["EAPI"])
                if (not metadata["EAPI"] or self.eapi_supported) and metadata[
                    "EAPI"
                ] != parsed_eapi:
                    self._eapi_invalid(metadata)
                    metadata_valid = False

            if metadata_valid:
                # Since we're supposed to be able to efficiently obtain the
                # EAPI from _parse_eapi_ebuild_head, we don't write cache
                # entries for unsupported EAPIs.
                if self.eapi_supported:
                    if metadata.get("INHERITED", False):
                        metadata["_eclasses_"] = (
                            self.portdb.repositories.get_repo_for_location(
                                self.repo_path
                            ).eclass_db.get_eclass_data(metadata["INHERITED"].split())
                        )
                    else:
                        metadata["_eclasses_"] = {}
                    metadata.pop("INHERITED", None)

                    # If called by egencache, this cache write is
                    # undesirable when metadata-transfer is disabled.
                    if self.write_auxdb is not False:
                        self.portdb._write_cache(
                            self.cpv, self.repo_path, metadata, self.ebuild_hash
                        )
                else:
                    metadata = {"EAPI": metadata["EAPI"]}
                self.metadata = metadata
            else:
                self.returncode = 1
        elif self.returncode != os.EX_OK:
            # Examine SANDBOX_LOG to determine if we have a general failure
            # (say, a missing eclass) in which case we probably want to plough
            # on, or if we have a sandbox violation, in which case we stop dead.
            try:
                if os.stat(self.settings["SANDBOX_LOG"]).st_size > 0:
                    self.returncode = 2
            except OSError:
                # Some other, non-sandbox problem occurred.
                pass

    def _eapi_invalid(self, metadata):
        from portage.package.ebuild._metadata_invalid import eapi_invalid

        repo_name = self.portdb.getRepositoryName(self.repo_path)
        if metadata is not None:
            eapi_var = metadata["EAPI"]
        else:
            eapi_var = None
        eapi_invalid(
            self,
            self.cpv,
            repo_name,
            self.settings,
            eapi_var,
            self._eapi,
            self._eapi_lineno,
        )
