# Copyright (C) 2020 Red Hat, Inc.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU Library General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.

from __future__ import absolute_import
from __future__ import print_function
from __future__ import unicode_literals

import libdnf
import hawkey

from dnf.i18n import _
import dnf.exceptions

import json


VERSION_MAJOR = 0
VERSION_MINOR = 0
VERSION = "%s.%s" % (VERSION_MAJOR, VERSION_MINOR)
"""
The version of the stored transaction.

MAJOR version denotes backwards incompatible changes (old dnf won't work with
new transaction JSON).

MINOR version denotes extending the format without breaking backwards
compatibility (old dnf can work with new transaction JSON). Forwards
compatibility needs to be handled by being able to process the old format as
well as the new one.
"""


class TransactionError(dnf.exceptions.Error):
    def __init__(self, msg):
        super(TransactionError, self).__init__(msg)


class TransactionReplayError(dnf.exceptions.Error):
    def __init__(self, filename, errors):
        """
        :param filename: The name of the transaction file being replayed
        :param errors: a list of error classes or a string with an error description
        """

        # store args in case someone wants to read them from a caught exception
        self.filename = filename
        if isinstance(errors, (list, tuple)):
            self.errors = errors
        else:
            self.errors = [errors]

        if filename:
            msg = _('The following problems occurred while replaying the transaction from file "{filename}":').format(filename=filename)
        else:
            msg = _('The following problems occurred while running a transaction:')

        for error in self.errors:
            msg += "\n  " + str(error)

        super(TransactionReplayError, self).__init__(msg)


class IncompatibleTransactionVersionError(TransactionReplayError):
    def __init__(self, filename, msg):
        super(IncompatibleTransactionVersionError, self).__init__(filename, msg)


def _check_version(version, filename):
    major, minor = version.split('.')

    try:
        major = int(major)
    except ValueError as e:
        raise TransactionReplayError(
            filename,
            _('Invalid major version "{major}", number expected.').format(major=major)
        )

    try:
        int(minor)  # minor is unused, just check it's a number
    except ValueError as e:
        raise TransactionReplayError(
            filename,
            _('Invalid minor version "{minor}", number expected.').format(minor=minor)
        )

    if major != VERSION_MAJOR:
        raise IncompatibleTransactionVersionError(
            filename,
            _('Incompatible major version "{major}", supported major version is "{major_supp}".')
                .format(major=major, major_supp=VERSION_MAJOR)
        )


def serialize_transaction(transaction):
    """
    Serializes a transaction to a data structure that is equivalent to the stored JSON format.
    :param transaction: the transaction to serialize (an instance of dnf.db.history.TransactionWrapper)
    """

    data = {
        "version": VERSION,
    }
    rpms = []
    groups = []
    environments = []

    if transaction is None:
        return data

    for tsi in transaction.packages():
        if tsi.is_package():
            rpms.append({
                "action": tsi.action_name,
                "nevra": tsi.nevra,
                "reason": libdnf.transaction.TransactionItemReasonToString(tsi.reason),
                "repo_id": tsi.from_repo
            })

        elif tsi.is_group():
            group = tsi.get_group()

            group_data = {
                "action": tsi.action_name,
                "id": group.getGroupId(),
                "packages": [],
                "package_types": libdnf.transaction.compsPackageTypeToString(group.getPackageTypes())
            }

            for pkg in group.getPackages():
                group_data["packages"].append({
                    "name": pkg.getName(),
                    "installed": pkg.getInstalled(),
                    "package_type": libdnf.transaction.compsPackageTypeToString(pkg.getPackageType())
                })

            groups.append(group_data)

        elif tsi.is_environment():
            env = tsi.get_environment()

            env_data = {
                "action": tsi.action_name,
                "id": env.getEnvironmentId(),
                "groups": [],
                "package_types": libdnf.transaction.compsPackageTypeToString(env.getPackageTypes())
            }

            for grp in env.getGroups():
                env_data["groups"].append({
                    "id": grp.getGroupId(),
                    "installed": grp.getInstalled(),
                    "group_type": libdnf.transaction.compsPackageTypeToString(grp.getGroupType())
                })

            environments.append(env_data)

    if rpms:
        data["rpms"] = rpms

    if groups:
        data["groups"] = groups

    if environments:
        data["environments"] = environments

    return data


class TransactionReplay(object):
    """
    A class that encapsulates replaying a transaction. The transaction data are
    loaded and stored when the class is initialized. The transaction is run by
    calling the `run()` method, after the transaction is created (but before it is
    performed), the `post_transaction()` method needs to be called to verify no
    extra packages were pulled in and also to fix the reasons.
    """

    def __init__(
        self,
        base,
        filename="",
        data=None,
        ignore_extras=False,
        ignore_installed=False,
        skip_unavailable=False
    ):
        """
        :param base: the dnf base
        :param filename: the filename to load the transaction from (conflicts with the 'data' argument)
        :param data: the dictionary to load the transaction from (conflicts with the 'filename' argument)
        :param ignore_extras: whether to ignore extra package pulled into the transaction
        :param ignore_installed: whether to ignore installed versions of packages
        :param skip_unavailable: whether to skip transaction packages that aren't available
        """

        self._base = base
        self._filename = filename
        self._ignore_installed = ignore_installed
        self._ignore_extras = ignore_extras
        self._skip_unavailable = skip_unavailable

        if not self._base.conf.strict:
            self._skip_unavailable = True

        self._nevra_cache = set()
        self._nevra_reason_cache = {}
        self._warnings = []

        if filename and data:
            raise ValueError(_("Conflicting TransactionReplay arguments have been specified: filename, data"))
        elif filename:
            self._load_from_file(filename)
        else:
            self._load_from_data(data)


    def _load_from_file(self, fn):
        self._filename = fn
        with open(fn, "r") as f:
            try:
                replay_data = json.load(f)
            except json.decoder.JSONDecodeError as e:
                raise TransactionReplayError(fn, str(e) + ".")

        try:
            self._load_from_data(replay_data)
        except TransactionError as e:
            raise TransactionReplayError(fn, e)

    def _load_from_data(self, data):
        self._replay_data = data
        self._verify_toplevel_json(self._replay_data)

        self._rpms = self._replay_data.get("rpms", [])
        self._assert_type(self._rpms, list, "rpms", "array")

        self._groups = self._replay_data.get("groups", [])
        self._assert_type(self._groups, list, "groups", "array")

        self._environments = self._replay_data.get("environments", [])
        self._assert_type(self._environments, list, "environments", "array")

    def _raise_or_warn(self, warn_only, msg):
        if warn_only:
            self._warnings.append(msg)
        else:
            raise TransactionError(msg)

    def _assert_type(self, value, t, id, expected):
        if not isinstance(value, t):
            raise TransactionError(_('Unexpected type of "{id}", {exp} expected.').format(id=id, exp=expected))

    def _verify_toplevel_json(self, replay_data):
        fn = self._filename

        if "version" not in replay_data:
            raise TransactionReplayError(fn, _('Missing key "{key}".'.format(key="version")))

        self._assert_type(replay_data["version"], str, "version", "string")

        _check_version(replay_data["version"], fn)

    def _replay_pkg_action(self, pkg_data):
        try:
            action = pkg_data["action"]
            nevra = pkg_data["nevra"]
            repo_id = pkg_data["repo_id"]
            reason = libdnf.transaction.StringToTransactionItemReason(pkg_data["reason"])
        except KeyError as e:
            raise TransactionError(
                _('Missing object key "{key}" in an rpm.').format(key=e.args[0])
            )
        except IndexError as e:
            raise TransactionError(
                _('Unexpected value of package reason "{reason}" for rpm nevra "{nevra}".')
                    .format(reason=pkg_data["reason"], nevra=nevra)
            )

        subj = hawkey.Subject(nevra)
        parsed_nevras = subj.get_nevra_possibilities(forms=[hawkey.FORM_NEVRA])

        if len(parsed_nevras) != 1:
            raise TransactionError(_('Cannot parse NEVRA for package "{nevra}".').format(nevra=nevra))

        parsed_nevra = parsed_nevras[0]
        na = "%s.%s" % (parsed_nevra.name, parsed_nevra.arch)

        query_na = self._base.sack.query().filter(name=parsed_nevra.name, arch=parsed_nevra.arch)

        epoch = parsed_nevra.epoch if parsed_nevra.epoch is not None else 0
        query = query_na.filter(epoch=epoch, version=parsed_nevra.version, release=parsed_nevra.release)

        # In case the package is found in the same repo as in the original
        # transaction, limit the query to that plus installed packages. IOW
        # remove packages with the same NEVRA in case they are found in
        # multiple repos and the repo the package came from originally is one
        # of them.
        # This can e.g. make a difference in the system-upgrade plugin, in case
        # the same NEVRA is in two repos, this makes sure the same repo is used
        # for both download and upgrade steps of the plugin.
        if repo_id:
            query_repo = query.filter(reponame=repo_id)
            if query_repo:
                query = query_repo.union(query.installed())

        if not query:
            self._raise_or_warn(self._skip_unavailable, _('Cannot find rpm nevra "{nevra}".').format(nevra=nevra))
            return

        # a cache to check no extra packages were pulled into the transaction
        if action != "Reason Change":
            self._nevra_cache.add(nevra)

        # store reasons for forward actions and "Removed", the rest of the
        # actions reasons should stay as they were determined by the transaction
        if action in ("Install", "Upgrade", "Downgrade", "Reinstall", "Removed"):
            self._nevra_reason_cache[nevra] = reason

        if action in ("Install", "Upgrade", "Downgrade"):
            if action == "Install" and query_na.installed() and not self._base._get_installonly_query(query_na):
                self._raise_or_warn(self._ignore_installed,
                    _('Package "{na}" is already installed for action "{action}".').format(na=na, action=action))

            sltr = dnf.selector.Selector(self._base.sack).set(pkg=query)
            self._base.goal.install(select=sltr, optional=not self._base.conf.strict)
        elif action == "Reinstall":
            query = query.available()

            if not query:
                self._raise_or_warn(self._skip_unavailable,
                    _('Package nevra "{nevra}" not available in repositories for action "{action}".')
                    .format(nevra=nevra, action=action))
                return

            sltr = dnf.selector.Selector(self._base.sack).set(pkg=query)
            self._base.goal.install(select=sltr, optional=not self._base.conf.strict)
        elif action in ("Upgraded", "Downgraded", "Reinstalled", "Removed", "Obsoleted"):
            query = query.installed()

            if not query:
                self._raise_or_warn(self._ignore_installed,
                    _('Package nevra "{nevra}" not installed for action "{action}".').format(nevra=nevra, action=action))
                return

            # erasing the original version (the reverse part of an action like
            # e.g. upgrade) is more robust, but we can't do it if
            # skip_unavailable is True, because if the forward part of the
            # action is skipped, we would simply remove the package here
            if not self._skip_unavailable or action == "Removed":
                for pkg in query:
                    self._base.goal.erase(pkg, clean_deps=False)
        elif action == "Reason Change":
            self._base.history.set_reason(query[0], reason)
        else:
            raise TransactionError(
                _('Unexpected value of package action "{action}" for rpm nevra "{nevra}".')
                    .format(action=action, nevra=nevra)
            )

    def _create_swdb_group(self, group_id, pkg_types, pkgs):
        comps_group = self._base.comps._group_by_id(group_id)
        if not comps_group:
            self._raise_or_warn(self._skip_unavailable, _("Group id '%s' is not available.") % group_id)
            return None

        swdb_group = self._base.history.group.new(group_id, comps_group.name, comps_group.ui_name, pkg_types)

        try:
            for pkg in pkgs:
                name = pkg["name"]
                self._assert_type(name, str, "groups.packages.name", "string")
                installed = pkg["installed"]
                self._assert_type(installed, bool, "groups.packages.installed", "boolean")
                package_type = pkg["package_type"]
                self._assert_type(package_type, str, "groups.packages.package_type", "string")

                try:
                    swdb_group.addPackage(name, installed, libdnf.transaction.stringToCompsPackageType(package_type))
                except libdnf.error.Error as e:
                    raise TransactionError(str(e))

        except KeyError as e:
            raise TransactionError(
                _('Missing object key "{key}" in groups.packages.').format(key=e.args[0])
            )

        return swdb_group

    def _swdb_group_install(self, group_id, pkg_types, pkgs):
        swdb_group = self._create_swdb_group(group_id, pkg_types, pkgs)

        if swdb_group is not None:
            self._base.history.group.install(swdb_group)

    def _swdb_group_upgrade(self, group_id, pkg_types, pkgs):
        if not self._base.history.group.get(group_id):
            self._raise_or_warn( self._ignore_installed, _("Group id '%s' is not installed.") % group_id)
            return

        swdb_group = self._create_swdb_group(group_id, pkg_types, pkgs)

        if swdb_group is not None:
            self._base.history.group.upgrade(swdb_group)

    def _swdb_group_downgrade(self, group_id, pkg_types, pkgs):
        if not self._base.history.group.get(group_id):
            self._raise_or_warn(self._ignore_installed, _("Group id '%s' is not installed.") % group_id)
            return

        swdb_group = self._create_swdb_group(group_id, pkg_types, pkgs)

        if swdb_group is not None:
            self._base.history.group.downgrade(swdb_group)

    def _swdb_group_remove(self, group_id, pkg_types, pkgs):
        if not self._base.history.group.get(group_id):
            self._raise_or_warn(self._ignore_installed, _("Group id '%s' is not installed.") % group_id)
            return

        swdb_group = self._create_swdb_group(group_id, pkg_types, pkgs)

        if swdb_group is not None:
            self._base.history.group.remove(swdb_group)

    def _create_swdb_environment(self, env_id, pkg_types, groups):
        comps_env = self._base.comps._environment_by_id(env_id)
        if not comps_env:
            self._raise_or_warn(self._skip_unavailable, _("Environment id '%s' is not available.") % env_id)
            return None

        swdb_env = self._base.history.env.new(env_id, comps_env.name, comps_env.ui_name, pkg_types)

        try:
            for grp in groups:
                id = grp["id"]
                self._assert_type(id, str, "environments.groups.id", "string")
                installed = grp["installed"]
                self._assert_type(installed, bool, "environments.groups.installed", "boolean")
                group_type = grp["group_type"]
                self._assert_type(group_type, str, "environments.groups.group_type", "string")

                try:
                    group_type = libdnf.transaction.stringToCompsPackageType(group_type)
                except libdnf.error.Error as e:
                    raise TransactionError(str(e))

                if group_type not in (
                    libdnf.transaction.CompsPackageType_MANDATORY,
                    libdnf.transaction.CompsPackageType_OPTIONAL
                ):
                    raise TransactionError(
                        _('Invalid value "{group_type}" of environments.groups.group_type, '
                            'only "mandatory" or "optional" is supported.'
                        ).format(group_type=grp["group_type"])
                    )

                swdb_env.addGroup(id, installed, group_type)
        except KeyError as e:
            raise TransactionError(
                _('Missing object key "{key}" in environments.groups.').format(key=e.args[0])
            )

        return swdb_env

    def _swdb_environment_install(self, env_id, pkg_types, groups):
        swdb_env = self._create_swdb_environment(env_id, pkg_types, groups)

        if swdb_env is not None:
            self._base.history.env.install(swdb_env)

    def _swdb_environment_upgrade(self, env_id, pkg_types, groups):
        if not self._base.history.env.get(env_id):
            self._raise_or_warn(self._ignore_installed,_("Environment id '%s' is not installed.") % env_id)
            return

        swdb_env = self._create_swdb_environment(env_id, pkg_types, groups)

        if swdb_env is not None:
            self._base.history.env.upgrade(swdb_env)

    def _swdb_environment_downgrade(self, env_id, pkg_types, groups):
        if not self._base.history.env.get(env_id):
            self._raise_or_warn(self._ignore_installed, _("Environment id '%s' is not installed.") % env_id)
            return

        swdb_env = self._create_swdb_environment(env_id, pkg_types, groups)

        if swdb_env is not None:
            self._base.history.env.downgrade(swdb_env)

    def _swdb_environment_remove(self, env_id, pkg_types, groups):
        if not self._base.history.env.get(env_id):
            self._raise_or_warn(self._ignore_installed, _("Environment id '%s' is not installed.") % env_id)
            return

        swdb_env = self._create_swdb_environment(env_id, pkg_types, groups)

        if swdb_env is not None:
            self._base.history.env.remove(swdb_env)

    def get_data(self):
        """
        :returns: the loaded data of the transaction
        """

        return self._replay_data

    def get_warnings(self):
        """
        :returns: an array of warnings gathered during the transaction replay
        """

        return self._warnings

    def run(self):
        """
        Replays the transaction.
        """

        fn = self._filename
        errors = []

        for pkg_data in self._rpms:
            try:
                self._replay_pkg_action(pkg_data)
            except TransactionError as e:
                errors.append(e)

        for group_data in self._groups:
            try:
                action = group_data["action"]
                group_id = group_data["id"]

                try:
                    pkg_types = libdnf.transaction.stringToCompsPackageType(group_data["package_types"])
                except libdnf.error.Error as e:
                    errors.append(TransactionError(str(e)))
                    continue

                if action == "Install":
                    self._swdb_group_install(group_id, pkg_types, group_data["packages"])
                elif action == "Removed":
                    self._swdb_group_remove(group_id, pkg_types, group_data["packages"])
                # Groups are not versioned, but a reverse transaction could be applied,
                # therefore we treat both actions the same way
                elif action == "Upgrade" or action == "Upgraded":
                    self._swdb_group_upgrade(group_id, pkg_types, group_data["packages"])
                elif action == "Downgrade" or action == "Downgraded":
                    self._swdb_group_downgrade(group_id, pkg_types, group_data["packages"])
                else:
                    errors.append(TransactionError(
                        _('Unexpected value of group action "{action}" for group "{group}".')
                            .format(action=action, group=group_id)
                    ))
            except KeyError as e:
                errors.append(TransactionError(
                    _('Missing object key "{key}" in a group.').format(key=e.args[0])
                ))
            except TransactionError as e:
                errors.append(e)

        for env_data in self._environments:
            try:
                action = env_data["action"]
                env_id = env_data["id"]

                try:
                    pkg_types = libdnf.transaction.stringToCompsPackageType(env_data["package_types"])
                except libdnf.error.Error as e:
                    errors.append(TransactionError(str(e)))
                    continue

                if action == "Install":
                    self._swdb_environment_install(env_id, pkg_types, env_data["groups"])
                elif action == "Removed":
                    self._swdb_environment_remove(env_id, pkg_types, env_data["groups"])
                # Environments are not versioned, but a reverse transaction could be applied,
                # therefore we treat both actions the same way
                elif action == "Upgrade" or action == "Upgraded":
                    self._swdb_environment_upgrade(env_id, pkg_types, env_data["groups"])
                elif action == "Downgrade" or action == "Downgraded":
                    self._swdb_environment_downgrade(env_id, pkg_types, env_data["groups"])
                else:
                    errors.append(TransactionError(
                        _('Unexpected value of environment action "{action}" for environment "{env}".')
                            .format(action=action, env=env_id)
                    ))
            except KeyError as e:
                errors.append(TransactionError(
                    _('Missing object key "{key}" in an environment.').format(key=e.args[0])
                ))
            except TransactionError as e:
                errors.append(e)

        if errors:
            raise TransactionReplayError(fn, errors)

    def post_transaction(self):
        """
        Sets reasons in the transaction history to values from the stored transaction.

        Also serves to check whether additional packages were pulled in by the
        transaction, which results in an error (unless ignore_extras is True).
        """

        if not self._base.transaction:
            return

        errors = []

        for tsi in self._base.transaction:
            try:
                pkg = tsi.pkg
            except KeyError as e:
                # the transaction item has no package, happens for action == "Reason Change"
                continue

            nevra = str(pkg)

            if nevra not in self._nevra_cache:
                # if ignore_installed is True, we don't want to check for
                # Upgraded/Downgraded/Reinstalled extras in the transaction,
                # basically those may be installed and we are ignoring them
                if not self._ignore_installed or not tsi.action in (
                    libdnf.transaction.TransactionItemAction_UPGRADED,
                    libdnf.transaction.TransactionItemAction_DOWNGRADED,
                    libdnf.transaction.TransactionItemAction_REINSTALLED
                ):
                    msg = _('Package nevra "{nevra}", which is not present in the transaction file, was pulled '
                        'into the transaction.'
                    ).format(nevra=nevra)

                    if not self._ignore_extras:
                        errors.append(TransactionError(msg))
                    else:
                        self._warnings.append(msg)

            try:
                replay_reason = self._nevra_reason_cache[nevra]

                if tsi.action in (
                    libdnf.transaction.TransactionItemAction_INSTALL,
                    libdnf.transaction.TransactionItemAction_REMOVE
                ) or libdnf.transaction.TransactionItemReasonCompare(replay_reason, tsi.reason) > 0:
                    tsi.reason = replay_reason
            except KeyError as e:
                # if the pkg nevra wasn't found, we don't want to change the reason
                pass

        if errors:
            raise TransactionReplayError(self._filename, errors)
