package com.atlassian.stash.internal.repository.sync.auto;

import com.atlassian.bitbucket.repository.RefChange;
import com.atlassian.bitbucket.repository.RefChangeType;
import com.atlassian.bitbucket.repository.SimpleRefChange;
import com.google.common.collect.*;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Map;

/**
 * A task to be applied by the {@link AutoRefSyncProcessor}, combining one or more {@link RefChange changes} and
 * the {@link AutoRefSyncOperation operation} to perform for them.
 *
 * @since 3.3
 */
public class AutoRefSyncTask {

    private final AutoRefSyncOperation operation;
    private final List<RefChange> refChanges;

    private AutoRefSyncTask(AutoRefSyncRequest task) {
        this(task.getRefChanges(), task.getOperation());
    }

    private AutoRefSyncTask(List<RefChange> refChanges, AutoRefSyncOperation operation) {
        this.operation = operation;
        this.refChanges = refChanges;
    }

    /**
     * Merges the provided list of {@link AutoRefSyncRequest}s to produce the <i>minimum</i> set of tasks that can
     * apply them.
     * <p>
     * Multiple {@link AutoRefSyncOperation#SYNCHRONIZE Synchronization} requests may be merged into either one or
     * two passes, depending on whether {@link RefChangeType#DELETE deletions} are included. When synchronization
     * is necessary, it will be the first (and second, if two passes are necessary) task in the returned list. If
     * the requests include both {@link AutoRefSyncOperation#REVIEW} and {@link AutoRefSyncOperation#SYNCHRONIZE}
     * operations the merge result will generally include at least two tasks, since review and synchronization must
     * be performed separately.
     * <p>
     * In certain cases, it's possible the returned list will be <i>empty</i>. For example, if the provided list
     * contains two requests where the first request {@link RefChangeType#ADD adds} a ref and the second request
     * {@link RefChangeType#DELETE deletes} it, the merged change is to do <i>nothing</i>.
     *
     * @param requests the requests to merge
     * @return a list containing <i>zero to three</i> tasks, depending on the requested operations, in the order
     *         they should be applied
     */
    @Nonnull
    public static List<AutoRefSyncTask> merge(@Nullable List<AutoRefSyncRequest> requests) {
        if (requests == null || requests.isEmpty()) {
            return Collections.emptyList();
        }
        if (requests.size() == 1) {
            return ImmutableList.of(new AutoRefSyncTask(requests.get(0)));
        }

        //First, map the changes from all the requests by the ref they affect
        Map<String, AutoRefSyncOperation> operationsByRef = Maps.newHashMap();
        Multimap<String, RefChange> changesByRef = mapChangesByRef(requests, operationsByRef);

        //Once the changes are mapped, the next step is to use the lists of changes for each ref to figure out
        //where changes can be merged and applied at the same time
        List<RefChange> firstPass = Lists.newArrayListWithCapacity(changesByRef.keySet().size());
        List<RefChange> secondPass = Lists.newArrayList();
        List<RefChange> reviewPass = Lists.newArrayList();
        for (Collection<RefChange> changes : changesByRef.asMap().values()) {
            if (AutoRefSyncOperation.REVIEW.equals(operationsByRef.get(Iterables.getFirst(changes, null).getRef().getId()))) {
                //If the most recent operation for a given ref is REVIEW, ignore all of the previous changes
                //for the ref and just review the most recent change
                reviewPass.add(Iterables.getLast(changes));
            } else if (changes.size() == 1) {
                //If there was only a single change to a given ref, it can always be applied in the first pass
                firstPass.addAll(changes);
            } else {
                //Otherwise, we have to iterate over the changes to collapse them, merging any number of changes such
                //that they can be applied in at-most 2 passes
                mergeChanges(changes, firstPass, secondPass);
            }
        }

        ImmutableList.Builder<AutoRefSyncTask> builder = ImmutableList.builder();
        if (!firstPass.isEmpty()) {
            builder.add(new AutoRefSyncTask(firstPass, AutoRefSyncOperation.SYNCHRONIZE));
            if (!secondPass.isEmpty()) { //Only possible if there's a first pass
                builder.add(new AutoRefSyncTask(secondPass, AutoRefSyncOperation.SYNCHRONIZE));
            }
        }
        if (!reviewPass.isEmpty()) {
            builder.add(new AutoRefSyncTask(reviewPass, AutoRefSyncOperation.REVIEW));
        }
        return builder.build();
    }

    /**
     * @return the operation to apply to this set of {@link #getRefChanges() changes}
     */
    @Nonnull
    public AutoRefSyncOperation getOperation() {
        return operation;
    }

    /**
     * @return one or more {@link RefChange changes} to apply
     */
    @Nonnull
    public List<RefChange> getRefChanges() {
        return refChanges;
    }

    /**
     * Maps the {@link RefChange}s from the provided {@link AutoRefSyncRequest}s by their {@link RefChange#getRefId()
     * ref IDs}, simultaneously mapping the <i>final</i> {@link AutoRefSyncOperation operation} applied to each.
     * <p>
     * As {@link RefChange}s are mapped, the {@link AutoRefSyncRequest#getOperation operation} from the containing
     * request is applied to each. Subsequent requests may overwrite the operation for any given ref. When mapping
     * is complete, the {@link AutoRefSyncOperation operation} mapped for each ref represents the <i>final</i>
     * operation requested for that ref.
     *
     * @param requests        the requests to map {@link RefChange}s from
     * @param operationsByRef a map to accumulate the <i>final</i> operation requested for each mapped ref
     * @return a map from each ref to a collection of the {@link RefChange}s applied to it <i>in request order</i>
     */
    private static Multimap<String, RefChange> mapChangesByRef(List<AutoRefSyncRequest> requests,
                                                               Map<String, AutoRefSyncOperation> operationsByRef) {
        Multimap<String, RefChange> changesByRef = ArrayListMultimap.create();
        for (AutoRefSyncRequest request : requests) {
            AutoRefSyncOperation operation = request.getOperation();

            for (RefChange change : request.getRefChanges()) {
                //Whatever task happens _last_ for a given ref is the task we need to perform
                operationsByRef.put(change.getRef().getId(), operation);

                changesByRef.put(change.getRef().getId(), change);
            }
        }
        return changesByRef;
    }

    /**
     * Merges the provided collection of {@link RefChange}s and adds the results to the {@code firstPass} and
     * {@code secondPass} as appropriate. All of the changes in the provided collection are assumed to be for
     * the same ref.
     * <p>
     * In general, any number of changes can be collapse into a single {@link RefChange}. The exception is when
     * a {@link RefChangeType#DELETE deletion} occurs. When deletions happen, the net behavior varies:
     * <ul>
     *     <li>If the first change is an {@link RefChangeType#ADD addition} and the last is a deletion, the
     *     entire collection collapses to <i>nothing</i> and is not added to either pass</li>
     *     <li>If the first change is an {@link RefChangeType#UPDATE update} or deletion and the last change
     *     is a deletion, the entire collection collapses down to a single deletion</li>
     *     <li>Otherwise, if the last change is not a deletion, the last deletion in the list is added to the
     *     first pass and the changes after that are merged to and added to the second pass</li>
     * </ul>
     *
     * @param changes    the changes to merge
     * @param firstPass  merged changes to apply in the first {@link AutoRefSyncOperation#SYNCHRONIZE synchronize} pass
     * @param secondPass merged changes to apply in the second {@link AutoRefSyncOperation#SYNCHRONIZE synchronize} pass
     */
    private static void mergeChanges(Collection<RefChange> changes, List<RefChange> firstPass,
                                     List<RefChange> secondPass) {
        SimpleRefChange.Builder first = null;
        SimpleRefChange.Builder second = null;
        boolean forSecond = false;
        for (RefChange change : changes) {
            if (first == null) {
                //The first RefChange is used to create the "first" builder, providing the type and both hashes.
                //As subsequent RefChanges are examined, the type and to hash may be replaced
                first = new SimpleRefChange.Builder(change);
            }

            //Note: This is _intentionally_ not an else if, to handle the first RefChange being a DELETE. This
            //may result in some redundant values being applied to the "first" builder, but it keeps the code
            //paths clearer by eliminating any extra if checks inside the first == null check.
            if (change.getType() == RefChangeType.DELETE) {
                if (first.build().getType() == RefChangeType.ADD) {
                    //Handles the special case of ADD -> ... -> DELETE. Since the ref was just added, there is
                    //no point in trying to create it just so that we can delete it. That means all of the
                    //changes that have been merged so far should just be eliminated completely
                    first = second = null;
                    forSecond = false;
                } else {
                    //The next RefChange, if any, should be used to create the "second" builder, instead of being
                    //merged into the "first" builder
                    forSecond = true;
                    //If the "second" builder was already created, we're collapsing those changes into the "first"
                    //builder, so null it out. This handles:
                    //UPDATE -> DELETE -> ADD -> ... -> DELETE
                    //The ADD would have been used to create "second", but since the ref is already deleted again
                    //there is no reason to process that ADD or any changes that came after it
                    second = null;

                    //Deletions always merge into the "first" builder, and deletion is the _only_ RefChangeType
                    //that trumps the original change type
                    first.toHash(change.getToHash())
                            .type(RefChangeType.DELETE);
                }
            } else if (forSecond) {
                //If we make it here, we have an ADD or an UPDATE. "first" is guaranteed to be assigned, but
                //"second" may still be null. forSecond helps us know what to do next
                second = new SimpleRefChange.Builder(change);
                forSecond = false;
            } else {
                //Otherwise, for ADD and UPDATE changes, overwrite the previous change's toHash with theirs. For
                //example, given the following three changes:
                //ADD:    0000000 -> AAAAAAA
                //UPDATE: AAAAAAA -> BBBBBBB
                //UPDATE: BBBBBBB -> CCCCCCC
                //The merged change is ADD: 0000000 -> CCCCCCC to create the ref with its ultimate value. Given
                //just the UPDATE changes, it would be UPDATE: AAAAAAA -> CCCCCCC to update the ref directly to
                //its ultimate value, skipping the intermediate ones
                (second == null ? first : second).toHash(change.getToHash());
            }
        }

        if (first != null) {
            //Because ADD -> ... -> DELETE can completely eliminate changes for a given ref, it is possible that
            //we could leave the loop above with first still null
            firstPass.add(first.build());

            if (second != null) {
                //If ADD/UPDATE actions were detected after a DELETE, a second pass may be necessary. If second
                //is not null, add it to the second pass
                secondPass.add(second.build());
            }
        }
    }
}
