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

import os
import shlex

import portage

portage._internal_caller = True
portage._sync_mode = True
from portage.output import bold, red, create_color_func
from portage._global_updates import _global_updates
from portage.sync.controller import SyncManager
from portage.util.digraph import digraph
from portage.util.futures import asyncio
from portage.util._async.AsyncScheduler import AsyncScheduler

import _emerge
from _emerge.emergelog import emergelog

warn = create_color_func("WARN")


class SyncRepos:
    short_desc = "Check repos.conf settings and/or sync repositories"

    @staticmethod
    def name():
        return "sync"

    def can_progressbar(self, func):
        return False

    def __init__(self, emerge_config=None, emerge_logging=False):
        """Class init function

        @param emerge_config: optional an emerge_config instance to use
        @param emerge_logging: boolean, defaults to False
        """
        from _emerge.main import parse_opts
        from _emerge.actions import load_emerge_config

        if emerge_config is None:
            # need a basic options instance
            actions, opts, _files = parse_opts([], silent=True)
            emerge_config = load_emerge_config(action="sync", args=_files, opts=opts)

            # Parse EMERGE_DEFAULT_OPTS, for settings like
            # --package-moves=n.
            cmdline = shlex.split(
                emerge_config.target_config.settings.get("EMERGE_DEFAULT_OPTS", "")
            )
            emerge_config.opts = parse_opts(cmdline, silent=True)[1]

            if hasattr(portage, "settings"):
                # cleanly destroy global objects
                portage._reset_legacy_globals()
                # update redundant global variables, for consistency
                # and in order to conserve memory
                portage.settings = emerge_config.target_config.settings
                portage.db = emerge_config.trees
                portage.root = portage.db._target_eroot

        self.emerge_config = emerge_config
        if emerge_logging:
            _emerge.emergelog._disable = False
        self.xterm_titles = (
            "notitles" not in self.emerge_config.target_config.settings.features
        )
        emergelog(self.xterm_titles, " === sync")

    def auto_sync(self, **kwargs):
        """Sync auto-sync enabled repos"""
        options = kwargs.get("options", None)
        if options:
            return_messages = options.get("return-messages", False)
        else:
            return_messages = False
        success, repos, msgs = self._get_repos(auto_sync_only=True)
        if not success:
            if return_messages:
                return (False, msgs)
            return (False, None)
        return self._sync(repos, return_messages, emaint_opts=options)

    def all_repos(self, **kwargs):
        """Sync all repos defined in repos.conf"""
        options = kwargs.get("options", None)
        if options:
            return_messages = options.get("return-messages", False)
        else:
            return_messages = False
        success, repos, msgs = self._get_repos(auto_sync_only=False)
        if not success:
            if return_messages:
                return (False, msgs)
            return (False, None)
        return self._sync(repos, return_messages, emaint_opts=options)

    def repo(self, **kwargs):
        """Sync the specified repo"""
        options = kwargs.get("options", None)
        if options:
            repo_names = options.get("repo", "")
            return_messages = options.get("return-messages", False)
        else:
            return_messages = False
        if isinstance(repo_names, str):
            repo_names = repo_names.split()
        success, repos, msgs = self._get_repos(
            auto_sync_only=False, match_repos=repo_names
        )
        if not success:
            if return_messages:
                return (False, msgs)
            return (False, None)
        return self._sync(repos, return_messages, emaint_opts=options)

    @staticmethod
    def _match_repos(repos, available):
        """Internal search, matches up the repo name or alias in repos.

        @param repos: list of repo names or aliases to match
        @param available: list of repo objects to search
        @return: list of repo objects that match
        """
        selected = []
        for repo in available:
            if repo.name in repos:
                selected.append(repo)
            elif repo.aliases is not None and any(
                alias in repos for alias in repo.aliases
            ):
                selected.append(repo)
        return selected

    def _get_repos(self, auto_sync_only=True, match_repos=None):
        msgs = []
        repos = self.emerge_config.target_config.settings.repositories
        if match_repos is not None:
            # Discard duplicate repository names or aliases.
            match_repos = set(match_repos)
            repos = self._match_repos(match_repos, repos)
            if len(repos) < len(match_repos):
                # Build a set of all the matched repos' names and aliases so we
                # can do a set difference for names that are missing.
                repo_names = set()
                for repo in repos:
                    repo_names.add(repo.name)
                    if repo.aliases is not None:
                        repo_names.update(repo.aliases)
                missing = match_repos - repo_names
                if missing:
                    msgs.append(
                        red(" * ")
                        + "The specified repo(s) were not found: %s"
                        % (" ".join(repo_name for repo_name in missing))
                        + "\n   ...returning"
                    )
                    return (False, repos, msgs)

        if auto_sync_only:
            repos = self._filter_auto(repos)

        sync_disabled = [repo for repo in repos if repo.sync_type is None]
        if sync_disabled:
            repos = [repo for repo in repos if repo.sync_type is not None]
            if match_repos is not None:
                msgs.append(
                    red(" * ")
                    + "The specified repo(s) have sync disabled: %s"
                    % " ".join(repo.name for repo in sync_disabled)
                    + "\n   ...returning"
                )
                return (False, repos, msgs)

        missing_sync_uri = [repo for repo in repos if repo.sync_uri is None]
        if missing_sync_uri:
            repos = [repo for repo in repos if repo.sync_uri is not None]
            msgs.append(
                red(" * ")
                + "The specified repo(s) are missing sync-uri: %s"
                % " ".join(repo.name for repo in missing_sync_uri)
                + "\n   ...returning"
            )
            return (False, repos, msgs)

        return (True, repos, msgs)

    def _filter_auto(self, repos):
        selected = []
        for repo in repos:
            if repo.auto_sync in ["yes", "true"]:
                selected.append(repo)
        return selected

    def _sync(self, selected_repos, return_messages, emaint_opts=None):
        from _emerge.post_emerge import display_news_notification

        msgs = []
        if not selected_repos:
            if return_messages:
                msgs.append("Nothing to sync... returning")
                return (True, msgs)
            return (True, None)

        if emaint_opts is not None:
            for k, v in emaint_opts.items():
                if v is not None:
                    k = "--" + k.replace("_", "-")
                    self.emerge_config.opts[k] = v

        # Portage needs to ensure a sane umask for the files it creates.
        os.umask(0o22)

        sync_manager = SyncManager(self.emerge_config.target_config.settings, emergelog)

        max_jobs = (
            self.emerge_config.opts.get("--jobs", 1)
            if "parallel-fetch" in self.emerge_config.target_config.settings.features
            else 1
        )
        sync_scheduler = SyncScheduler(
            emerge_config=self.emerge_config,
            selected_repos=selected_repos,
            sync_manager=sync_manager,
            max_jobs=max_jobs,
            event_loop=asyncio._safe_loop(),
        )

        sync_scheduler.start()
        sync_scheduler.wait()
        retvals = sync_scheduler.retvals
        msgs.extend(sync_scheduler.msgs)
        returncode = True

        if retvals:
            msgs.extend(self.rmessage(retvals, "sync"))
            for repo, retval in retvals:
                if retval != os.EX_OK:
                    returncode = False
                    break
        else:
            msgs.extend(self.rmessage([("None", os.EX_OK)], "sync"))

        # run the post_sync_hook one last time for
        # run only at sync completion hooks
        if sync_scheduler.global_hooks_enabled:
            rcode = sync_manager.perform_post_sync_hook("")
            if rcode:
                msgs.extend(self.rmessage([("None", rcode)], "post-sync"))
                if rcode != os.EX_OK:
                    returncode = False

        # Reload the whole config.
        portage._sync_mode = False
        self._reload_config()
        self._do_pkg_moves()
        msgs.extend(self._check_updates())
        display_news_notification(
            self.emerge_config.target_config, self.emerge_config.opts
        )

        if return_messages:
            return (returncode, msgs)
        return (returncode, None)

    def _do_pkg_moves(self):
        configs = [self.emerge_config.target_config]
        if (
            self.emerge_config.target_config.root
            != self.emerge_config.running_config.root
        ):
            configs.append(self.emerge_config.running_config)
        for root_config in configs:
            if self.emerge_config.opts.get(
                "--package-moves"
            ) != "n" and _global_updates(
                root_config.root,
                self.emerge_config.trees,
                root_config.mtimedb["updates"],
                quiet=("--quiet" in self.emerge_config.opts),
            ):
                root_config.mtimedb.commit()
                # Reload the whole config.
                self._reload_config()

    def _check_updates(self):
        from _emerge.chk_updated_cfg_files import chk_updated_cfg_files

        mybestpv = self.emerge_config.target_config.trees["porttree"].dbapi.xmatch(
            "bestmatch-visible", portage.const.PORTAGE_PACKAGE_ATOM
        )
        mypvs = portage.best(
            self.emerge_config.target_config.trees["vartree"].dbapi.match(
                portage.const.PORTAGE_PACKAGE_ATOM
            )
        )
        try:
            old_use = (
                self.emerge_config.target_config.trees["vartree"]
                .dbapi.aux_get(mypvs, ["USE"])[0]
                .split()
            )
        except KeyError:
            old_use = ()

        chk_updated_cfg_files(
            self.emerge_config.target_config.root,
            self.emerge_config.target_config.settings.get("CONFIG_PROTECT", "").split(),
        )

        msgs = []
        if (
            not (mybestpv and mypvs)
            or mybestpv == mypvs
            or "--quiet" in self.emerge_config.opts
        ):
            return msgs

        # Suggest to update to the latest available version of portage.
        # Since changes to PYTHON_TARGETS cause complications, this message
        # is suppressed if the new version has different PYTHON_TARGETS enabled
        # than previous version.
        portdb = self.emerge_config.target_config.trees["porttree"].dbapi
        portdb.doebuild_settings.setcpv(mybestpv, mydb=portdb)
        usemask = portdb.doebuild_settings.usemask
        useforce = portdb.doebuild_settings.useforce
        new_use = (
            frozenset(portdb.doebuild_settings["PORTAGE_USE"].split()) | useforce
        ) - usemask
        new_python_targets = frozenset(
            x for x in new_use if x.startswith("python_targets_")
        )
        old_python_targets = frozenset(
            x for x in old_use if x.startswith("python_targets_")
        )

        if new_python_targets == old_python_targets:
            msgs.append("")
            msgs.append(
                warn(" * ")
                + bold("An update to portage is available.")
                + " It is _highly_ recommended"
            )
            msgs.append(
                warn(" * ")
                + "that you update portage now, before any other packages are updated."
            )
            msgs.append("")
            msgs.append(
                warn(" * ")
                + "To update portage, run 'emerge --oneshot sys-apps/portage' now."
            )
            msgs.append("")
        return msgs

    def _reload_config(self):
        """Reload the whole config from scratch."""
        from _emerge.actions import load_emerge_config, adjust_configs

        load_emerge_config(emerge_config=self.emerge_config)
        adjust_configs(self.emerge_config.opts, self.emerge_config.trees)

    def rmessage(self, rvals, action):
        """Creates emaint style messages to return to the task handler"""
        messages = []
        for rval in rvals:
            messages.append(
                f"Action: {action} for repo: {rval[0]}, returned code = {rval[1]}"
            )
        return messages


class SyncScheduler(AsyncScheduler):
    """
    Sync repos in parallel, but don't sync a given repo until all
    of its masters have synced.
    """

    def __init__(self, **kwargs):
        """
        @param emerge_config: an emerge_config instance
        @param selected_repos: list of RepoConfig instances
        @param sync_manager: a SyncManger instance
        """
        self._emerge_config = kwargs.pop("emerge_config")
        self._selected_repos = kwargs.pop("selected_repos")
        self._sync_manager = kwargs.pop("sync_manager")
        AsyncScheduler.__init__(self, **kwargs)
        self._init_graph()
        self.retvals = []
        self.msgs = []

    def _init_graph(self):
        """
        Graph relationships between repos and their masters.
        """
        self._sync_graph = digraph()
        self._leaf_nodes = []
        self._repo_map = {}
        self._running_repos = set()
        selected_repo_names = frozenset(repo.name for repo in self._selected_repos)
        for repo in self._selected_repos:
            self._repo_map[repo.name] = repo
            self._sync_graph.add(repo.name, None)
            for master in repo.masters:
                if master.name in selected_repo_names:
                    self._repo_map[master.name] = master
                    self._sync_graph.add(master.name, repo.name)
        self._complete_graph = self._sync_graph.copy()
        self._hooks_repos = set()
        self._update_leaf_nodes()

    def _task_exit(self, task):
        """
        Remove the task from the graph, in order to expose
        more leaf nodes.
        """
        self._running_tasks.discard(task)
        # Set hooks_enabled = True by default, in order to ensure
        # that hooks will be called in a backward-compatible manner
        # even if all sync tasks have failed.
        hooks_enabled = True
        returncode = task.returncode
        if task.returncode == os.EX_OK:
            returncode, message, updatecache_flg, hooks_enabled = task.result
            if message:
                self.msgs.append(message)
        repo = task.kwargs["repo"].name
        self._running_repos.remove(repo)
        self.retvals.append((repo, returncode))
        self._sync_graph.remove(repo)
        self._update_leaf_nodes()
        if hooks_enabled:
            self._hooks_repos.add(repo)
        super()._task_exit(self)

    def _master_hooks(self, repo_name):
        """
        @param repo_name: a repo name
        @type repo_name: str
        @return: True if hooks would have been executed for any master
                repositories of the given repo, False otherwise
        @rtype: bool
        """
        traversed_nodes = set()
        node_stack = [repo_name]
        while node_stack:
            node = node_stack.pop()
            if node in self._hooks_repos:
                return True
            if node not in traversed_nodes:
                traversed_nodes.add(node)
                node_stack.extend(self._complete_graph.child_nodes(node))
        return False

    @property
    def global_hooks_enabled(self):
        """
        @return: True if repo.postsync.d hooks would have been executed
                for any repositories.
        @rtype: bool
        """
        return bool(self._hooks_repos)

    def _update_leaf_nodes(self):
        """
        Populate self._leaf_nodes with current leaves from
        self._sync_graph. If a circular master relationship
        is discovered, choose a random node to break the cycle.
        """
        if self._sync_graph and not self._leaf_nodes:
            self._leaf_nodes = [
                obj
                for obj in self._sync_graph.leaf_nodes()
                if obj not in self._running_repos
            ]

            if not (self._leaf_nodes or self._running_repos):
                # If there is a circular master relationship,
                # choose a random node to break the cycle.
                self._leaf_nodes = [next(iter(self._sync_graph))]

    def _next_task(self):
        """
        Return a task for the next available leaf node.
        """
        if not self._sync_graph:
            raise StopIteration()
        # If self._sync_graph is non-empty, then self._leaf_nodes
        # is guaranteed to be non-empty, since otherwise
        # _can_add_job would have returned False and prevented
        # _next_task from being immediately called.
        node = self._leaf_nodes.pop()
        self._running_repos.add(node)
        self._update_leaf_nodes()

        return self._sync_manager.sync_async(
            emerge_config=self._emerge_config,
            repo=self._repo_map[node],
            master_hooks=self._master_hooks(node),
        )

    def _can_add_job(self):
        """
        Returns False if there are no leaf nodes available.
        """
        if not AsyncScheduler._can_add_job(self):
            return False
        return bool(self._leaf_nodes) and not self._terminated.is_set()

    def _keep_scheduling(self):
        """
        Schedule as long as the graph is non-empty, and we haven't
        been terminated.
        """
        return bool(self._sync_graph) and not self._terminated.is_set()
