#
# Copyright 2017 the original author or authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#      http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#

"""
3-way merge function for config rev objects.
"""
from collections import OrderedDict
from copy import copy

from pyvoltha.common.config.config_proxy import CallbackType, OperationContext
from pyvoltha.common.config.config_rev import children_fields


class MergeConflictException(Exception):
    pass


def merge_3way(fork_rev, src_rev, dst_rev, merge_child_func, dry_run=False):
    """
    Attempt to merge src_rev into dst_rev but taking into account what have
    changed in both revs since the last known common point, the fork_rev.
    In case of conflict, raise a MergeConflictException(). If dry run is True,
    don't actually perform the merge, but detect potential conflicts.

    This function recurses into all children nodes stored under the rev and
    performs the merge if the children is also part of a transaction branch.

    :param fork_rev: Point of forking (last known common state between branches
    :param src_rev: Latest rev from which we merge to dst_rev
    :param dst_rev: Target (destination) rev
    :param merge_child_fun: To run a potential merge in all children that
    may need merge (determined from the local changes)
    :param dry_run: If True, do not perform the merge, but detect merge
    conflicts.
    :return: The new dst_rev (a new rev instance) the list of changes that
    occurred in this node or any of its children as part of this merge.
    """

    # to collect change tuples of (<callback-type>, <op-context>)
    changes = []

    class AnalyzeChanges(object):
        def __init__(self, lst1, lst2, keyname):
            self.keymap1 = OrderedDict((getattr(rev._config._data, keyname), i)
                                       for i, rev in enumerate(lst1))
            self.keymap2 = OrderedDict((getattr(rev._config._data, keyname), i)
                                       for i, rev in enumerate(lst2))
            self.added_keys = [
                k for k in self.keymap2.iterkeys() if k not in self.keymap1]
            self.removed_keys = [
                k for k in self.keymap1.iterkeys() if k not in self.keymap2]
            self.changed_keys = [
                k for k in self.keymap1.iterkeys()
                if k in self.keymap2 and
                    lst1[self.keymap1[k]]._hash != lst2[self.keymap2[k]]._hash
            ]

    # Note: there are a couple of special cases that can be optimized
    # for larer on. But since premature optimization is a bad idea, we
    # defer them.

    # deal with config data first
    if dst_rev._config is fork_rev._config:
        # no change in master, accept src if different
        config_changed = dst_rev._config != src_rev._config
    else:
        if dst_rev._config.hash != src_rev._config.hash:
            raise MergeConflictException('Config collision')
        config_changed = True

    # now to the external children fields
    new_children = dst_rev._children.copy()
    _children_fields = children_fields(fork_rev.data.__class__)

    for field_name, field in _children_fields.iteritems():

        fork_list = fork_rev._children[field_name]
        src_list = src_rev._children[field_name]
        dst_list = dst_rev._children[field_name]

        if dst_list == src_list:
            # we do not need to change the dst, however we still need
            # to complete the branch purging in child nodes so not
            # to leave dangling branches around
            [merge_child_func(rev) for rev in src_list]
            continue

        if not field.key:
            # If the list is not keyed, we really should not merge. We merely
            # check for collision, i.e., if both changed (and not same)
            if dst_list == fork_list:
                # dst branch did not change since fork

                assert src_list != fork_list, 'We should not be here otherwise'

                # the incoming (src) rev changed, and we have to apply it
                new_children[field_name] = [
                    merge_child_func(rev) for rev in src_list]

                if field.is_container:
                    changes.append((CallbackType.POST_LISTCHANGE,
                                    OperationContext(field_name=field_name)))

            else:
                if src_list != fork_list:
                    raise MergeConflictException(
                        'Cannot merge because single child node or un-keyed'
                        'children list has changed')

        else:

            if dst_list == fork_list:
                # Destination did not change

                # We need to analyze only the changes on the incoming rev
                # since fork
                src = AnalyzeChanges(fork_list, src_list, field.key)

                new_list = copy(src_list)  # we start from the source list

                for key in src.added_keys:
                    idx = src.keymap2[key]
                    new_rev = merge_child_func(new_list[idx])
                    new_list[idx] = new_rev
                    changes.append(
                        (CallbackType.POST_ADD,
                         new_rev.data))
                         # OperationContext(
                         #     field_name=field_name,
                         #     child_key=key,
                         #     data=new_rev.data)))

                for key in src.removed_keys:
                    old_rev = fork_list[src.keymap1[key]]
                    changes.append((
                        CallbackType.POST_REMOVE,
                        old_rev.data))
                        # OperationContext(
                        #     field_name=field_name,
                        #     child_key=key,
                        #     data=old_rev.data)))

                for key in src.changed_keys:
                    idx = src.keymap2[key]
                    new_rev = merge_child_func(new_list[idx])
                    new_list[idx] = new_rev
                    # updated child gets its own change event

                new_children[field_name] = new_list

            else:

                # For keyed fields we can really investigate what has been
                # added, removed, or changed in both branches and do a
                # fine-grained collision detection and merge

                src = AnalyzeChanges(fork_list, src_list, field.key)
                dst = AnalyzeChanges(fork_list, dst_list, field.key)

                new_list = copy(dst_list)  # this time we start with the dst

                for key in src.added_keys:
                    # we cannot add if it has been added and is different
                    if key in dst.added_keys:
                        # it has been added to both, we need to check if
                        # they are the same
                        child_dst_rev = dst_list[dst.keymap2[key]]
                        child_src_rev = src_list[src.keymap2[key]]
                        if child_dst_rev.hash == child_src_rev.hash:
                            # they match, so we do not need to change the
                            # dst list, but we still need to purge the src
                            # branch
                            merge_child_func(child_dst_rev)
                        else:
                            raise MergeConflictException(
                                'Cannot add because it has been added and '
                                'different'
                            )
                    else:
                        # this is a brand new key, need to add it
                        new_rev = merge_child_func(src_list[src.keymap2[key]])
                        new_list.append(new_rev)
                        changes.append((
                            CallbackType.POST_ADD,
                            new_rev.data))
                            # OperationContext(
                            #     field_name=field_name,
                            #     child_key=key,
                            #     data=new_rev.data)))

                for key in src.changed_keys:
                    # we cannot change if it was removed in dst
                    if key in dst.removed_keys:
                        raise MergeConflictException(
                            'Cannot change because it has been removed')

                    # if it changed in dst as well, we need to check if they
                    # match (same change
                    elif key in dst.changed_keys:
                        child_dst_rev = dst_list[dst.keymap2[key]]
                        child_src_rev = src_list[src.keymap2[key]]
                        if child_dst_rev.hash == child_src_rev.hash:
                            # they match, so we do not need to change the
                            # dst list, but we still need to purge the src
                            # branch
                            merge_child_func(child_src_rev)
                        elif child_dst_rev._config.hash != child_src_rev._config.hash:
                            raise MergeConflictException(
                                'Cannot update because it has been changed and '
                                'different'
                            )
                        else:
                            new_rev = merge_child_func(
                                src_list[src.keymap2[key]])
                            new_list[dst.keymap2[key]] = new_rev
                            # no announcement for child update

                    else:
                        # it only changed in src branch
                        new_rev = merge_child_func(src_list[src.keymap2[key]])
                        new_list[dst.keymap2[key]] = new_rev
                        # no announcement for child update

                for key in reversed(src.removed_keys):  # we go from highest
                                                        # index to lowest

                    # we cannot remove if it has changed in dst
                    if key in dst.changed_keys:
                        raise MergeConflictException(
                            'Cannot remove because it has changed')

                    # if it has not been removed yet from dst, then remove it
                    if key not in dst.removed_keys:
                        dst_idx = dst.keymap2[key]
                        old_rev = new_list.pop(dst_idx)
                        changes.append((
                            CallbackType.POST_REMOVE,
                            old_rev.data))
                            # OperationContext(
                            #     field_name=field_name,
                            #     child_key=key,
                            #     data=old_rev.data)))

                new_children[field_name] = new_list

    if not dry_run:
        rev = src_rev if config_changed else dst_rev
        rev = rev.update_all_children(new_children, dst_rev._branch)
        if config_changed:
            changes.append((CallbackType.POST_UPDATE, rev.data))
        return rev, changes

    else:
        return None, None
