# Copyright (c) 2016 Andrew Hills
#
# 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 3 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

"""
This module contains methods for comparing instances in the
:mod:`compose <debrepo.composes>`/:mod:`repository <debrepo.repos>` hierarchy.
Each method returns a :class:`Diff` instance representing additions, removals,
and other changes between the two given instances.

Much like :mod:`~debrepo.composes`, :mod:`repositories <debrepo.repos>`, and
:mod:`~debrepo.packages`, recursion into deeper levels of the hierarchy is
automatic.
"""


import logging

from debrepo.packages import InvalidPackage


class Diff(object):
    """
    Basic representation of a difference between a new and old version of an
    object
    """

    def __init__(
            self, new_name, old_name, compared=None, added=None, removed=None):
        """
        The *new_name* and *old_name* arguments are intended to represent
        changes in identifier. In cases where this doesn't make any sense, or
        it doesn't matter, the *new_name* identifier is used.

        The *compared* argument is a list of subcomponents which are shared and
        have been compared between two instances. This is often a list of
        :class:`Diff` instances representing those subcomponents. If an
        instance has no subcomponents, the field is not used.

        The *added* and *removed* arguments are lists of subcomponents which
        appear only in the new and old instances, respectively. If an instance
        has no subcomponents, they may represent relevant metadata changes, or
        be left unused.
        """

        self.new_name = new_name
        self.old_name = old_name
        self.compared = compared
        self.added = added
        self.removed = removed

    def __eq__(self, other):
        return self.new_name == other.new_name and \
                self.old_name == other.old_name and \
                self.added == other.added and \
                self.removed == other.removed and \
                self.compared == other.compared


def diff_composes(new_compose, old_compose, logindent=0):
    """
    Compare two :class:`~debrepo.composes.Compose` instances and their
    subcomponents recursively.

    Returns a :class:`Diff` instance with the names of the two composes, lists
    of added and removed repositories, and a list of :class:`Diff` instances
    for compared repositories generated by :func:`diff_repos`.
    """

    logprefix = ' ' * logindent * 2
    compared = []

    # Find new/obsolete repos
    new_compose_repo_names = set([repo.name for repo in new_compose.repos])
    old_compose_repo_names = set([repo.name for repo in old_compose.repos])
    added_repos = new_compose_repo_names - old_compose_repo_names
    removed_repos = old_compose_repo_names - new_compose_repo_names
    same_repos = new_compose_repo_names & old_compose_repo_names

    logging.info(
            logprefix + '%d repos added in %s',
            len(added_repos), new_compose.name)
    logging.info(
            logprefix + '%d repos removed from %s',
            len(removed_repos), old_compose.name)

    # For each shared repo, find new/obsolete dists
    logging.info(
            logprefix + '%d repos shared by %s & %s',
            len(same_repos), new_compose.name, old_compose.name)
    for new_repo in [
            repo for repo in new_compose.repos if repo.name in same_repos]:
        repo_name = new_repo.name
        logging.info(logprefix + 'In shared repo %s:', repo_name)
        old_repo = old_compose.get_repo(repo_name)

        compared.append(
                diff_repos(new_repo, old_repo, logindent=logindent + 1))

    logging.debug(
            logprefix + '%d repos compared in %s',
            len(compared), new_compose.name)
    return Diff(
            new_compose.name, old_compose.name, compared=compared,
            added=added_repos or None, removed=removed_repos or None)


def diff_repos(new_repo, old_repo, logindent=0):
    """
    Compare two :class:`~debrepo.repos.Repo` instances and their subcomponents
    recursively.

    Returns a :class:`Diff` instance with the names of the two repositories,
    lists of added and removed distributions, and a list of :class:`Diff`
    instances for compared distributions generated by :func:`diff_dists`.
    """

    logprefix = ' ' * logindent * 2
    compared = []

    # Find new/obsolete dists
    new_repo_dist_names = set([dist.name for dist in new_repo.dists])
    old_repo_dist_names = set([dist.name for dist in old_repo.dists])
    added_dists = new_repo_dist_names - old_repo_dist_names
    removed_dists = old_repo_dist_names - new_repo_dist_names
    same_dists = new_repo_dist_names & old_repo_dist_names

    logging.info(
            logprefix + '%d dists added to %s',
            len(added_dists), new_repo.name)
    logging.info(
            logprefix + '%d dists removed from %s',
            len(removed_dists), old_repo.name)

    # For each shared dist, find new/obsolete components
    logging.info(
            logprefix + '%d dists shared by %s',
            len(same_dists), new_repo.name)
    for new_dist in [
            dist for dist in new_repo.dists if dist.name in same_dists]:
        dist_name = new_dist.name
        logging.info(logprefix + 'In shared dist %s:', dist_name)
        old_dist = old_repo.get_dist(dist_name)

        compared.append(
                diff_dists(new_dist, old_dist, logindent=logindent + 1))

    logging.debug(
            logprefix + '%d dists compared in %s',
            len(compared), new_repo.name)
    return Diff(
            new_repo.name, old_repo.name, compared=compared,
            added=added_dists or None, removed=removed_dists or None)


def diff_dists(new_dist, old_dist, logindent=0):
    """
    Compare two :class:`~debrepo.repos.Dist` instances and their subcomponents
    recursively.

    Returns a :class:`Diff` instance with the names of the two distributions,
    lists of added and removed components, and a list of :class:`Diff`
    instances for compared components generated by :func:`diff_components`.
    """

    logprefix = ' ' * logindent * 2
    compared = []

    # Find new/obsolete components
    new_dist_component_names = set(
            [component.name for component in new_dist.components])
    old_dist_component_names = set(
            [component.name for component in old_dist.components])
    added_components = new_dist_component_names - old_dist_component_names
    removed_components = old_dist_component_names - new_dist_component_names
    same_components = new_dist_component_names & old_dist_component_names

    logging.info(
            logprefix + '%d components added to %s',
            len(added_components), new_dist.name)
    logging.info(
            logprefix + '%d components removed from %s',
            len(removed_components), old_dist.name)

    # For each shared component, find new/obsolete arches
    logging.info(
            logprefix + '%d components shared by %s',
            len(same_components), new_dist.name)
    for new_component in [
            component for component in new_dist.components
            if component.name in same_components]:
        component_name = new_component.name
        logging.info(logprefix + 'In shared component %s:', component_name)
        old_component = old_dist.get_component(component_name)

        compared.append(diff_components(
            new_component, old_component, logindent=logindent + 1))

    logging.debug(
            logprefix + '%d components compared in %s',
            len(compared), new_dist.name)
    return Diff(
            new_dist.name, old_dist.name, compared=compared,
            added=added_components or None, removed=removed_components or None)


def diff_components(new_component, old_component, logindent=0):
    """
    Compare two :class:`~debrepo.repos.Component` instances and their
    subcomponents recursively.

    Returns a :class:`Diff` instance with the names of the two components,
    lists of added and removed architectures, and a list of :class:`Diff`
    instances for compared architectures generated by :func:`diff_arches`.
    """

    logprefix = ' ' * logindent * 2
    compared = []

    # Find new/obsolete arches
    new_component_arch_names = set(
            [arch.name for arch in new_component.arches])
    old_component_arch_names = set(
            [arch.name for arch in old_component.arches])
    added_arches = new_component_arch_names - old_component_arch_names
    removed_arches = old_component_arch_names - new_component_arch_names
    same_arches = new_component_arch_names & old_component_arch_names

    logging.info(
            logprefix + '%d arches added to %s',
            len(added_arches), new_component.name)
    logging.info(
            logprefix + '%d arches removed from %s',
            len(removed_arches), old_component.name)

    # For each shared arch, find new/obsolete packages
    logging.info(
            logprefix + '%d arches shared by %s',
            len(same_arches), new_component.name)
    for new_arch in [
            arch for arch in new_component.arches if arch.name in same_arches]:
        arch_name = new_arch.name
        logging.info(logprefix + 'In shared arch %s:', arch_name)
        old_arch = old_component.get_arch(arch_name)

        compared.append(
                diff_arches(new_arch, old_arch, logindent=logindent + 1))

    logging.debug(
            logprefix + '%d arches compared in %s',
            len(compared), new_component.name)
    return Diff(
            new_component.name, old_component.name, compared=compared,
            added=added_arches or None, removed=removed_arches or None)


def diff_arches(new_arch, old_arch, logindent=0):
    """
    Compare two :class:`~debrepo.repos.Arch` instances and their subcomponents
    recursively.

    Returns a :class:`Diff` instance with the names of the two architectures,
    lists of added and removed packages, and a map of source (parent) package
    names to :class:`Diff` instances containing the new changelog minus the old
    in the :attr:`~Diff.compared` attribute and the new and old version numbers
    in the :attr:`~Diff.added` and :attr:`~Diff.removed` attributes,
    respectively.
    """

    logprefix = ' ' * logindent * 2
    compared = {}

    # Find new/obsolete packages
    new_arch_pkg_names = set([pkg.name for pkg in new_arch.packages])
    old_arch_pkg_names = set([pkg.name for pkg in old_arch.packages])
    added_pkgs = new_arch_pkg_names - old_arch_pkg_names
    removed_pkgs = old_arch_pkg_names - new_arch_pkg_names
    same_pkgs = new_arch_pkg_names & old_arch_pkg_names

    logging.info(
            logprefix + '%d packages added to %s',
            len(added_pkgs), new_arch.name)
    logging.info(
            logprefix + '%d packages removed from %s',
            len(removed_pkgs), old_arch.name)

    # For each shared package, diff changelogs
    logging.info(
            logprefix + '%d packages shared by %s',
            len(same_pkgs), new_arch.name)
    for new_pkg in [
            pkg for pkg in new_arch.packages if pkg.name in same_pkgs]:
        pkg_name = new_pkg.name
        parent_name = new_pkg.parent
        # Skip packages that are subpackages of existing packages, since they
        # share the same changelog.
        if parent_name:
            if parent_name in compared:
                logging.debug(
                        logprefix + 'skipping subpackage %s for parent %s',
                        new_pkg.name, new_pkg.parent)
                continue
        else:
            parent_name = pkg_name
        old_pkg = old_arch.get_package(pkg_name)
        pkg_diff = Diff(new_pkg.name, old_pkg.name)

        if new_pkg.version != old_pkg.version:
            logging.info(
                    logprefix + '%s: %s -> %s (from %s)',
                    parent_name, old_pkg.version, new_pkg.version, pkg_name)
            pkg_diff.added = new_pkg.version
            pkg_diff.removed = old_pkg.version

        try:
            def squash_newlines(entry):
                return str(entry).replace('\n\n', '\n')
            new_changelog = map(squash_newlines, new_pkg.changelog())
            old_changelog = map(squash_newlines, old_pkg.changelog())
        except InvalidPackage as e:
            # If the changelog can't be extracted, skip this step
            logging.debug(
                    logprefix + 'skipping changelog for %s: %s',
                    pkg_name, e.message)
        else:
            # Sets are easier to compare
            newcl = set(new_changelog)
            oldcl = set(old_changelog)
            if oldcl - newcl:
                logging.warning('%s changelog history diverges!', pkg_name)
            diffcl = newcl - oldcl
            logging.info(
                    logprefix + '%d changelog entries since the last version',
                    len(diffcl))
            # Preserve order by pulling from the original list
            pkg_diff.compared = [
                    entry for entry in new_changelog if entry in diffcl]

        if pkg_diff.added or pkg_diff.removed or pkg_diff.compared:
            compared[parent_name] = pkg_diff

    logging.debug(
            logprefix + '%d packages compared in %s',
            len(compared), new_arch.name)
    return Diff(
            new_arch.name, old_arch.name, compared=compared,
            added=added_pkgs or None, removed=removed_pkgs or None)
