# Copyright 2010-2012 Gentoo Foundation
# Distributed under the terms of the GNU General Public License v2

import copy


class BacktrackParameter:
    __slots__ = (
        "circular_dependency",
        "needed_unstable_keywords",
        "runtime_pkg_mask",
        "needed_use_config_changes",
        "needed_license_changes",
        "prune_rebuilds",
        "rebuild_list",
        "reinstall_list",
        "needed_p_mask_changes",
        "slot_operator_mask_built",
        "slot_operator_replace_installed",
    )

    def __init__(self):
        self.circular_dependency = {}
        self.needed_unstable_keywords = set()
        self.needed_p_mask_changes = set()
        self.runtime_pkg_mask = {}
        self.needed_use_config_changes = {}
        self.needed_license_changes = {}
        self.rebuild_list = set()
        self.reinstall_list = set()
        self.slot_operator_replace_installed = set()
        self.slot_operator_mask_built = set()
        self.prune_rebuilds = False

    def __deepcopy__(self, memo=None):
        if memo is None:
            memo = {}
        result = BacktrackParameter()
        memo[id(self)] = result

        # Shallow copies are enough here, as we only need to ensure that nobody adds stuff
        # to our sets and dicts. The existing content is immutable.
        result.circular_dependency = copy.copy(self.circular_dependency)
        result.needed_unstable_keywords = copy.copy(self.needed_unstable_keywords)
        result.needed_p_mask_changes = copy.copy(self.needed_p_mask_changes)
        result.needed_use_config_changes = copy.copy(self.needed_use_config_changes)
        result.needed_license_changes = copy.copy(self.needed_license_changes)
        result.rebuild_list = copy.copy(self.rebuild_list)
        result.reinstall_list = copy.copy(self.reinstall_list)
        result.slot_operator_replace_installed = copy.copy(
            self.slot_operator_replace_installed
        )
        result.slot_operator_mask_built = self.slot_operator_mask_built.copy()
        result.prune_rebuilds = self.prune_rebuilds

        # runtime_pkg_mask contains nested dicts that must also be copied
        result.runtime_pkg_mask = {}
        for k, v in self.runtime_pkg_mask.items():
            result.runtime_pkg_mask[k] = copy.copy(v)

        return result

    def __eq__(self, other):
        return (
            self.circular_dependency == other.circular_dependency
            and self.needed_unstable_keywords == other.needed_unstable_keywords
            and self.needed_p_mask_changes == other.needed_p_mask_changes
            and self.runtime_pkg_mask == other.runtime_pkg_mask
            and self.needed_use_config_changes == other.needed_use_config_changes
            and self.needed_license_changes == other.needed_license_changes
            and self.rebuild_list == other.rebuild_list
            and self.reinstall_list == other.reinstall_list
            and self.slot_operator_replace_installed
            == other.slot_operator_replace_installed
            and self.slot_operator_mask_built == other.slot_operator_mask_built
            and self.prune_rebuilds == other.prune_rebuilds
        )


class _BacktrackNode:
    __slots__ = (
        "parameter",
        "depth",
        "mask_steps",
        "terminal",
    )

    def __init__(
        self, parameter=BacktrackParameter(), depth=0, mask_steps=0, terminal=True
    ):
        self.parameter = parameter
        self.depth = depth
        self.mask_steps = mask_steps
        self.terminal = terminal

    def __eq__(self, other):
        return self.parameter == other.parameter


class Backtracker:
    __slots__ = (
        "_max_depth",
        "_unexplored_nodes",
        "_current_node",
        "_nodes",
        "_root",
    )

    def __init__(self, max_depth):
        self._max_depth = max_depth
        self._unexplored_nodes = []
        self._current_node = None
        self._nodes = []

        self._root = _BacktrackNode()
        self._add(self._root)

    def _add(self, node, explore=True):
        """
        Adds a newly computed backtrack parameter. Makes sure that it doesn't already exist and
        that we don't backtrack deeper than we are allowed by --backtrack.
        """
        if not self._check_runtime_pkg_mask(node.parameter.runtime_pkg_mask):
            return

        if node.mask_steps <= self._max_depth and node not in self._nodes:
            if explore:
                self._unexplored_nodes.append(node)
            self._nodes.append(node)

    def get(self):
        """
        Returns a backtrack parameter. The backtrack graph is explored with depth first.
        """
        if self._unexplored_nodes:
            node = self._unexplored_nodes.pop()
            self._current_node = node
            return copy.deepcopy(node.parameter)
        return None

    def __len__(self):
        return len(self._unexplored_nodes)

    def _check_runtime_pkg_mask(self, runtime_pkg_mask):
        """
        If a package gets masked that caused other packages to be masked
        before, we revert the mask for other packages (bug 375573).
        """

        for pkg, mask_info in runtime_pkg_mask.items():
            if (
                "missing dependency" in mask_info
                or "slot_operator_mask_built" in mask_info
            ):
                continue

            entry_is_valid = False
            any_conflict_parents = False

            for ppkg, patom in runtime_pkg_mask[pkg].get("slot conflict", set()):
                any_conflict_parents = True
                if ppkg not in runtime_pkg_mask:
                    entry_is_valid = True
                    break
            else:
                if not any_conflict_parents:
                    # Even though pkg was involved in a slot conflict
                    # where it was matched by all involved parent atoms,
                    # consider masking it in order to avoid a missed
                    # update as in bug 692746.
                    entry_is_valid = True

            if not entry_is_valid:
                return False

        return True

    def _feedback_slot_conflicts(self, conflicts_data):
        # Only create BacktrackNode instances for the first
        # conflict which occurred, since the conflicts that
        # occurred later may have been caused by the first
        # conflict.
        self._feedback_slot_conflict(conflicts_data[0])

    def _feedback_slot_conflict(self, conflict_data):
        for similar_pkgs in conflict_data:
            new_node = copy.deepcopy(self._current_node)
            new_node.depth += 1
            new_node.mask_steps += 1
            new_node.terminal = False
            for pkg, parent_atoms in similar_pkgs:
                new_node.parameter.runtime_pkg_mask.setdefault(pkg, {})[
                    "slot conflict"
                ] = parent_atoms
            self._add(new_node)

    def _feedback_missing_dep(self, dep):
        new_node = copy.deepcopy(self._current_node)
        new_node.depth += 1
        new_node.mask_steps += 1
        new_node.terminal = False

        new_node.parameter.runtime_pkg_mask.setdefault(dep.parent, {})[
            "missing dependency"
        ] = {(dep.parent, dep.root, dep.atom)}

        self._add(new_node)

    def _feedback_config(self, changes, explore=True):
        """
        Handle config changes. Don't count config changes for the maximum backtrack depth.
        """
        new_node = copy.deepcopy(self._current_node)
        new_node.depth += 1
        para = new_node.parameter

        for change, data in changes.items():
            if change == "circular_dependency":
                for pkg, circular_children in data.items():
                    para.circular_dependency.setdefault(pkg, set()).update(
                        circular_children
                    )
            elif change == "needed_unstable_keywords":
                para.needed_unstable_keywords.update(data)
            elif change == "needed_p_mask_changes":
                para.needed_p_mask_changes.update(data)
            elif change == "needed_license_changes":
                for pkg, missing_licenses in data:
                    para.needed_license_changes.setdefault(pkg, set()).update(
                        missing_licenses
                    )
            elif change == "needed_use_config_changes":
                for pkg, (new_use, new_changes) in data:
                    para.needed_use_config_changes[pkg] = (new_use, new_changes)
            elif change == "slot_conflict_abi":
                new_node.terminal = False
            elif change == "slot_operator_mask_built":
                para.slot_operator_mask_built.update(data)
                for pkg, mask_reasons in data.items():
                    para.runtime_pkg_mask.setdefault(pkg, {}).update(mask_reasons)
            elif change == "slot_operator_replace_installed":
                para.slot_operator_replace_installed.update(data)
            elif change == "rebuild_list":
                para.rebuild_list.update(data)
            elif change == "reinstall_list":
                para.reinstall_list.update(data)
            elif change == "prune_rebuilds":
                para.prune_rebuilds = True
                para.slot_operator_replace_installed.clear()
                for pkg in para.slot_operator_mask_built:
                    runtime_masks = para.runtime_pkg_mask.get(pkg)
                    if runtime_masks is None:
                        continue
                    runtime_masks.pop("slot_operator_mask_built", None)
                    if not runtime_masks:
                        para.runtime_pkg_mask.pop(pkg)
                para.slot_operator_mask_built.clear()

        self._add(new_node, explore=explore)
        self._current_node = new_node

    def feedback(self, infos):
        """
        Takes information from the depgraph and computes new backtrack parameters to try.
        """
        assert (
            self._current_node is not None
        ), "call feedback() only after get() was called"

        # Not all config changes require a restart, that's why they can appear together
        # with other conflicts.
        if "config" in infos:
            self._feedback_config(infos["config"], explore=len(infos) == 1)

        # There is at most one of the following types of conflicts for a given restart.
        if "slot conflict" in infos:
            self._feedback_slot_conflicts(infos["slot conflict"])
        elif "missing dependency" in infos:
            self._feedback_missing_dep(infos["missing dependency"])

    def backtracked(self):
        """
        If we didn't backtrack, there is only the root.
        """
        return len(self._nodes) > 1

    def get_best_run(self):
        """
        Like, get() but returns the backtrack parameter that has as many config changes as possible,
        but has no masks. This makes --autounmask effective, but prevents confusing error messages
        with "masked by backtracking".
        """
        best_node = self._root
        for node in self._nodes:
            if node.terminal and node.depth > best_node.depth:
                best_node = node

        return copy.deepcopy(best_node.parameter)
