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

import functools

import _emerge.emergelog
from _emerge.AsynchronousTask import AsynchronousTask
from _emerge.EbuildExecuter import EbuildExecuter
from _emerge.EbuildPhase import EbuildPhase
from _emerge.EbuildBinpkg import EbuildBinpkg
from _emerge.EbuildFetcher import EbuildFetcher
from _emerge.CompositeTask import CompositeTask
from _emerge.EbuildMerge import EbuildMerge
from _emerge.EbuildFetchonly import EbuildFetchonly
from _emerge.EbuildBuildDir import EbuildBuildDir
from _emerge.MiscFunctionsProcess import MiscFunctionsProcess
from _emerge.TaskSequence import TaskSequence

import portage
from portage import _encodings, _unicode_encode, os
from portage.package.ebuild.digestcheck import digestcheck
from portage.package.ebuild.doebuild import _check_temp_dir
from portage.package.ebuild._spawn_nofetch import SpawnNofetchWithoutBuilddir
from portage.util._async.AsyncTaskFuture import AsyncTaskFuture
from portage.util.futures.executor.fork import ForkExecutor
from portage.util.path import first_existing


class EbuildBuild(CompositeTask):
    __slots__ = (
        "args_set",
        "config_pool",
        "find_blockers",
        "ldpath_mtimes",
        "logger",
        "opts",
        "pkg",
        "pkg_count",
        "prefetcher",
        "settings",
        "world_atom",
    ) + ("_build_dir", "_buildpkg", "_ebuild_path", "_issyspkg", "_tree")

    def _start(self):
        if not self.opts.fetchonly:
            rval = _check_temp_dir(self.settings)
            if rval != os.EX_OK:
                self.returncode = rval
                self._current_task = None
                self._async_wait()
                return

        # First get the SRC_URI metadata (it's not cached in self.pkg.metadata
        # because some packages have an extremely large SRC_URI value).
        self._start_task(
            AsyncTaskFuture(
                future=self.pkg.root_config.trees["porttree"].dbapi.async_aux_get(
                    self.pkg.cpv, ["SRC_URI"], myrepo=self.pkg.repo, loop=self.scheduler
                )
            ),
            self._start_with_metadata,
        )

    def _start_with_metadata(self, aux_get_task):
        self._assert_current(aux_get_task)
        if aux_get_task.cancelled:
            self._default_final_exit(aux_get_task)
            return

        pkg = self.pkg
        settings = self.settings
        root_config = pkg.root_config
        tree = "porttree"
        self._tree = tree
        portdb = root_config.trees[tree].dbapi
        settings.setcpv(pkg)
        (settings.configdict["pkg"]["SRC_URI"],) = aux_get_task.future.result()
        settings.configdict["pkg"]["EMERGE_FROM"] = "ebuild"
        if self.opts.buildpkgonly:
            settings.configdict["pkg"]["MERGE_TYPE"] = "buildonly"
        else:
            settings.configdict["pkg"]["MERGE_TYPE"] = "source"
        ebuild_path = portdb.findname(pkg.cpv, myrepo=pkg.repo)
        if ebuild_path is None:
            raise AssertionError(f"ebuild not found for '{pkg.cpv}'")
        self._ebuild_path = ebuild_path
        portage.doebuild_environment(
            ebuild_path, "setup", settings=self.settings, db=portdb
        )

        # Check the manifest here since with --keep-going mode it's
        # currently possible to get this far with a broken manifest.
        if not self._check_manifest():
            self.returncode = 1
            self._current_task = None
            self._async_wait()
            return

        prefetcher = self.prefetcher
        if prefetcher is not None and not prefetcher.isAlive():
            # Cancel it because it hasn't started yet.
            prefetcher.cancel()
            self.prefetcher = prefetcher = None
        if prefetcher is None:
            pass
        elif prefetcher.isAlive() and prefetcher.poll() is None:
            if not self.background:
                fetch_log = os.path.join(
                    _emerge.emergelog._emerge_log_dir, "emerge-fetch.log"
                )
                msg = (
                    "Fetching files in the background.",
                    "To view fetch progress, run in another terminal:",
                    f"tail -f {fetch_log}",
                )
                out = portage.output.EOutput()
                for l in msg:
                    out.einfo(l)

            self._current_task = prefetcher
            prefetcher.addExitListener(self._prefetch_exit)
            return

        self._prefetch_exit(prefetcher)

    def _check_manifest(self):
        success = True

        settings = self.settings
        if "strict" in settings.features and "digest" not in settings.features:
            settings["O"] = os.path.dirname(self._ebuild_path)
            quiet_setting = settings.get("PORTAGE_QUIET")
            settings["PORTAGE_QUIET"] = "1"
            try:
                success = digestcheck([], settings, strict=True)
            finally:
                if quiet_setting:
                    settings["PORTAGE_QUIET"] = quiet_setting
                else:
                    del settings["PORTAGE_QUIET"]

        return success

    def _prefetch_exit(self, prefetcher):
        if self._was_cancelled():
            self.wait()
            return

        opts = self.opts
        pkg = self.pkg
        settings = self.settings

        if opts.fetchonly:
            if opts.pretend:
                fetcher = EbuildFetchonly(
                    ebuild_path=self._ebuild_path,
                    fetch_all=opts.fetch_all_uri,
                    pkg=pkg,
                    pretend=opts.pretend,
                    settings=settings,
                )
                # Execute EbuildFetchonly in a subprocess since it needs to
                # run the event loop itself (even for pretend mode since it
                # may need to fetch mirror layouts as reported in bug 702154).
                self._start_task(
                    AsyncTaskFuture(
                        background=self.background,
                        scheduler=self.scheduler,
                        future=self.scheduler.run_in_executor(
                            ForkExecutor(loop=self.scheduler), fetcher.execute
                        ),
                    ),
                    self._fetchonly_exit,
                )
                return

            quiet_setting = settings.get("PORTAGE_QUIET", False)
            fetch_log = None
            logwrite_access = False
            if quiet_setting:
                fetch_log = os.path.join(
                    _emerge.emergelog._emerge_log_dir, "emerge-fetch.log"
                )
                logwrite_access = os.access(first_existing(fetch_log), os.W_OK)

            fetcher = EbuildFetcher(
                config_pool=self.config_pool,
                ebuild_path=self._ebuild_path,
                fetchall=self.opts.fetch_all_uri,
                fetchonly=self.opts.fetchonly,
                background=quiet_setting if logwrite_access else False,
                logfile=fetch_log if logwrite_access else None,
                pkg=self.pkg,
                scheduler=self.scheduler,
            )

            if fetch_log and logwrite_access:
                fetcher.addExitListener(self._fetchonly_exit)
                self._task_queued(fetcher)
                self.scheduler.fetch.schedule(fetcher, force_queue=True)
            else:
                self._start_task(fetcher, self._fetchonly_exit)
            return

        self._build_dir = EbuildBuildDir(scheduler=self.scheduler, settings=settings)
        self._start_task(
            AsyncTaskFuture(future=self._build_dir.async_lock()), self._start_pre_clean
        )

    def _start_pre_clean(self, lock_task):
        self._assert_current(lock_task)
        if lock_task.cancelled:
            self._default_final_exit(lock_task)
            return

        lock_task.future.result()
        # Cleaning needs to happen before fetch, since the build dir
        # is used for log handling.
        msg = " === ({} of {}) Cleaning ({}::{})".format(
            self.pkg_count.curval,
            self.pkg_count.maxval,
            self.pkg.cpv,
            self._ebuild_path,
        )
        short_msg = "emerge: ({} of {}) {} Clean".format(
            self.pkg_count.curval,
            self.pkg_count.maxval,
            self.pkg.cpv,
        )
        self.logger.log(msg, short_msg=short_msg)

        pre_clean_phase = EbuildPhase(
            background=self.background,
            phase="clean",
            scheduler=self.scheduler,
            settings=self.settings,
        )
        self._start_task(pre_clean_phase, self._pre_clean_exit)

    def _fetchonly_exit(self, fetcher):
        if not fetcher.cancelled and isinstance(fetcher, AsyncTaskFuture):
            # Set returncode from EbuildFetchonly.execute() result, since
            # it can fail if it can't resolve a mirror for a file.
            fetcher.returncode = fetcher.future.result()
        self._final_exit(fetcher)
        if not self.cancelled and self.returncode != os.EX_OK:
            self.returncode = None
            portdb = self.pkg.root_config.trees[self._tree].dbapi
            self._start_task(
                SpawnNofetchWithoutBuilddir(
                    background=self.background,
                    portdb=portdb,
                    ebuild_path=self._ebuild_path,
                    scheduler=self.scheduler,
                    settings=self.settings,
                ),
                self._nofetch_without_builddir_exit,
            )
            return

        self.wait()

    def _nofetch_without_builddir_exit(self, nofetch):
        self._final_exit(nofetch)
        self.returncode = 1
        self.wait()

    def _pre_clean_exit(self, pre_clean_phase):
        if self._default_exit(pre_clean_phase) != os.EX_OK:
            self._async_unlock_builddir(returncode=self.returncode)
            return

        # for log handling
        portage.prepare_build_dirs(self.pkg.root, self.settings, 1)

        fetcher = EbuildFetcher(
            config_pool=self.config_pool,
            ebuild_path=self._ebuild_path,
            fetchall=self.opts.fetch_all_uri,
            fetchonly=self.opts.fetchonly,
            background=self.background,
            logfile=self.settings.get("PORTAGE_LOG_FILE"),
            pkg=self.pkg,
            scheduler=self.scheduler,
        )

        self._start_task(
            AsyncTaskFuture(future=fetcher.async_already_fetched(self.settings)),
            functools.partial(self._start_fetch, fetcher),
        )

    def _start_fetch(self, fetcher, already_fetched_task):
        self._assert_current(already_fetched_task)
        if already_fetched_task.cancelled:
            self._default_final_exit(already_fetched_task)
            return

        try:
            already_fetched = already_fetched_task.future.result()
        except portage.exception.InvalidDependString as e:
            msg_lines = []
            msg = f"Fetch failed for '{self.pkg.cpv}' due to invalid SRC_URI: {e}"
            msg_lines.append(msg)
            fetcher._eerror(msg_lines)
            portage.elog.elog_process(self.pkg.cpv, self.settings)
            self._async_unlock_builddir(returncode=1)
            return

        if already_fetched:
            # This case is optimized to skip the fetch queue.
            fetcher = None
            self._fetch_exit(fetcher)
            return

        # Allow the Scheduler's fetch queue to control the
        # number of concurrent fetchers.
        fetcher.addExitListener(self._fetch_exit)
        self._task_queued(fetcher)
        self.scheduler.fetch.schedule(fetcher)

    def _fetch_exit(self, fetcher):
        if fetcher is not None and self._default_exit(fetcher) != os.EX_OK:
            self._fetch_failed()
            return

        # discard successful fetch log
        self._build_dir.clean_log()
        pkg = self.pkg
        logger = self.logger
        opts = self.opts
        pkg_count = self.pkg_count
        scheduler = self.scheduler
        settings = self.settings
        features = settings.features
        ebuild_path = self._ebuild_path
        system_set = pkg.root_config.sets["system"]

        # buildsyspkg: Check if we need to _force_ binary package creation
        self._issyspkg = (
            "buildsyspkg" in features
            and system_set.findAtomForPackage(pkg)
            and "buildpkg" not in features
            and opts.buildpkg != "n"
        )

        # Do not build binary cache for packages from volatile sources.
        # For volatile sources (eg., git), the PROPERTIES parameter in
        # the ebuild is set to 'live'.

        # The default behavior is to build binary cache for all pkgs.
        # "buildpkg-live" is a FEATURE that is enabled by default.
        # To not build binary cache for live pkgs, we disable it by
        # specifying FEATURES="-buildpkg-live"

        buildpkg_live = "buildpkg-live" in features
        live_ebuild = "live" in self.settings.get("PROPERTIES", "").split()
        buildpkg_live_disabled = live_ebuild and not buildpkg_live

        if (
            ("buildpkg" in features or self._issyspkg)
            and not buildpkg_live_disabled
            and not self.opts.buildpkg_exclude.findAtomForPackage(pkg)
        ):
            self._buildpkg = True

            msg = " === ({} of {}) Compiling/Packaging ({}::{})".format(
                pkg_count.curval,
                pkg_count.maxval,
                pkg.cpv,
                ebuild_path,
            )
            short_msg = "emerge: ({} of {}) {} Compile".format(
                pkg_count.curval,
                pkg_count.maxval,
                pkg.cpv,
            )
            logger.log(msg, short_msg=short_msg)

        else:
            msg = " === ({} of {}) Compiling/Merging ({}::{})".format(
                pkg_count.curval,
                pkg_count.maxval,
                pkg.cpv,
                ebuild_path,
            )
            short_msg = "emerge: ({} of {}) {} Compile".format(
                pkg_count.curval,
                pkg_count.maxval,
                pkg.cpv,
            )
            logger.log(msg, short_msg=short_msg)

        build = EbuildExecuter(
            background=self.background, pkg=pkg, scheduler=scheduler, settings=settings
        )
        self._start_task(build, self._build_exit)

    def _fetch_failed(self):
        # We only call the pkg_nofetch phase if either RESTRICT=fetch
        # is set or the package has explicitly overridden the default
        # pkg_nofetch implementation. This allows specialized messages
        # to be displayed for problematic packages even though they do
        # not set RESTRICT=fetch (bug #336499).

        if (
            "fetch" not in self.pkg.restrict
            and "nofetch" not in self.pkg.defined_phases
        ):
            self._async_unlock_builddir(returncode=self.returncode)
            return

        self.returncode = None
        nofetch_phase = EbuildPhase(
            background=self.background,
            phase="nofetch",
            scheduler=self.scheduler,
            settings=self.settings,
        )
        self._start_task(nofetch_phase, self._nofetch_exit)

    def _nofetch_exit(self, nofetch_phase):
        self._final_exit(nofetch_phase)
        self._async_unlock_builddir(returncode=1)

    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 returncode is not None:
            # The returncode will be set after unlock is complete.
            self.returncode = None
        portage.elog.elog_process(self.pkg.cpv, self.settings)
        self._start_task(
            AsyncTaskFuture(future=self._build_dir.async_unlock()),
            functools.partial(self._unlock_builddir_exit, returncode=returncode),
        )

    def _unlock_builddir_exit(self, unlock_task, returncode=None):
        self._assert_current(unlock_task)
        if unlock_task.cancelled and returncode is not None:
            self._default_final_exit(unlock_task)
            return

        # Normally, async_unlock should not raise an exception here.
        unlock_task.future.cancelled() or unlock_task.future.result()
        if returncode is not None:
            self.returncode = returncode
            self._async_wait()

    def _build_exit(self, build):
        if self._default_exit(build) != os.EX_OK:
            self._async_unlock_builddir(returncode=self.returncode)
            return

        buildpkg = self._buildpkg

        if not buildpkg:
            self._final_exit(build)
            self.wait()
            return

        if self._issyspkg:
            msg = ">>> This is a system package, " + "let's pack a rescue tarball.\n"
            self.scheduler.output(msg, log_path=self.settings.get("PORTAGE_LOG_FILE"))

        binpkg_tasks = TaskSequence()
        requested_binpkg_formats = self.settings.get(
            "PORTAGE_BINPKG_FORMAT", "tar"
        ).split()
        for pkg_fmt in portage.const.SUPPORTED_BINPKG_FORMATS:
            if pkg_fmt in requested_binpkg_formats:
                if pkg_fmt == "rpm":
                    binpkg_tasks.add(
                        EbuildPhase(
                            background=self.background,
                            phase="rpm",
                            scheduler=self.scheduler,
                            settings=self.settings,
                        )
                    )
                else:
                    task = EbuildBinpkg(
                        background=self.background,
                        pkg=self.pkg,
                        scheduler=self.scheduler,
                        settings=self.settings,
                    )
                    binpkg_tasks.add(task)
                    # Guarantee that _record_binpkg_info is called
                    # immediately after EbuildBinpkg. Note that
                    # task.addExitListener does not provide the
                    # necessary guarantee (see bug 578204).
                    binpkg_tasks.add(
                        self._RecordBinpkgInfo(ebuild_binpkg=task, ebuild_build=self)
                    )

        if binpkg_tasks:
            self._start_task(binpkg_tasks, self._buildpkg_exit)
            return

        self._final_exit(build)
        self.wait()

    class _RecordBinpkgInfo(AsynchronousTask):
        """
        This class wraps the EbuildBuild _record_binpkg_info method
        with an AsynchronousTask interface, so that it can be
        scheduled as a member of a TaskSequence.
        """

        __slots__ = (
            "ebuild_binpkg",
            "ebuild_build",
        )

        def _start(self):
            self.ebuild_build._record_binpkg_info(self.ebuild_binpkg)
            AsynchronousTask._start(self)

    def _buildpkg_exit(self, packager):
        """
        Released build dir lock when there is a failure or
        when in buildpkgonly mode. Otherwise, the lock will
        be released when merge() is called.
        """

        if self._default_exit(packager) != os.EX_OK:
            self._async_unlock_builddir(returncode=self.returncode)
            return

        if self.opts.buildpkgonly:
            phase = "success_hooks"
            success_hooks = MiscFunctionsProcess(
                background=self.background,
                commands=[phase],
                phase=phase,
                scheduler=self.scheduler,
                settings=self.settings,
            )
            self._start_task(success_hooks, self._buildpkgonly_success_hook_exit)
            return

        # Continue holding the builddir lock until
        # after the package has been installed.
        self._current_task = None
        self.returncode = packager.returncode
        self.wait()

    def _record_binpkg_info(self, task):
        if task.returncode != os.EX_OK:
            return

        # Save info about the created binary package, so that
        # identifying information can be passed to the install
        # task, to be recorded in the installed package database.
        pkg = task.get_binpkg_info()
        infoloc = os.path.join(self.settings["PORTAGE_BUILDDIR"], "build-info")
        info = {
            "BINPKGMD5": f"{pkg._metadata['MD5']}\n",
        }
        if pkg.build_id is not None:
            info["BUILD_ID"] = f"{pkg.build_id}\n"
        for k, v in info.items():
            with open(
                _unicode_encode(
                    os.path.join(infoloc, k), encoding=_encodings["fs"], errors="strict"
                ),
                mode="w",
                encoding=_encodings["repo.content"],
                errors="strict",
            ) as f:
                f.write(v)

    def _buildpkgonly_success_hook_exit(self, success_hooks):
        self._default_exit(success_hooks)
        self.returncode = None
        # Need to call "clean" phase for buildpkgonly mode
        portage.elog.elog_process(self.pkg.cpv, self.settings)
        phase = "clean"
        clean_phase = EbuildPhase(
            background=self.background,
            phase=phase,
            scheduler=self.scheduler,
            settings=self.settings,
        )
        self._start_task(clean_phase, self._clean_exit)

    def _clean_exit(self, clean_phase):
        if self._final_exit(clean_phase) != os.EX_OK or self.opts.buildpkgonly:
            self._async_unlock_builddir(returncode=self.returncode)
        else:
            self.wait()

    def create_install_task(self):
        """
        Install the package and then clean up and release locks.
        Only call this after the build has completed successfully
        and neither fetchonly nor buildpkgonly mode are enabled.
        """

        ldpath_mtimes = self.ldpath_mtimes
        logger = self.logger
        pkg = self.pkg
        pkg_count = self.pkg_count
        settings = self.settings
        world_atom = self.world_atom
        ebuild_path = self._ebuild_path
        tree = self._tree

        task = EbuildMerge(
            exit_hook=self._install_exit,
            find_blockers=self.find_blockers,
            ldpath_mtimes=ldpath_mtimes,
            logger=logger,
            pkg=pkg,
            pkg_count=pkg_count,
            pkg_path=ebuild_path,
            scheduler=self.scheduler,
            settings=settings,
            tree=tree,
            world_atom=world_atom,
        )

        msg = " === ({} of {}) Merging ({}::{})".format(
            pkg_count.curval,
            pkg_count.maxval,
            pkg.cpv,
            ebuild_path,
        )
        short_msg = "emerge: ({} of {}) {} Merge".format(
            pkg_count.curval,
            pkg_count.maxval,
            pkg.cpv,
        )
        logger.log(msg, short_msg=short_msg)

        return task

    def _install_exit(self, task):
        """
        @returns: Future, result is the returncode from an
                EbuildBuildDir.async_unlock() task
        """
        self._async_unlock_builddir()
        if self._current_task is None:
            result = self.scheduler.create_future()
            self.scheduler.call_soon(result.set_result, os.EX_OK)
        else:
            result = self._current_task.async_wait()
        return result
