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.Repository;
import com.atlassian.bitbucket.repository.RepositoryService;
import com.atlassian.bitbucket.util.*;
import com.atlassian.bitbucket.concurrent.BucketProcessor;
import com.atlassian.stash.internal.repository.sync.BulkRefSyncRequest;
import com.atlassian.stash.internal.repository.sync.InternalRefSyncService;
import com.atlassian.bitbucket.pull.PullRequest;
import com.atlassian.bitbucket.pull.PullRequestSearchRequest;
import com.atlassian.bitbucket.pull.PullRequestService;
import com.atlassian.bitbucket.pull.PullRequestState;
import com.atlassian.bitbucket.user.ApplicationUser;
import com.atlassian.bitbucket.permission.Permission;
import com.atlassian.bitbucket.user.SecurityService;
import com.atlassian.bitbucket.user.UserService;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Maps;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.annotation.Nonnull;
import java.util.*;

import static com.atlassian.bitbucket.util.Chainable.chain;

/**
 * Merges one or more {@link AutoRefSyncRequest}s into {@link AutoRefSyncTask}s and applies those requests in order.
 *
 * @since 3.3
 */
public class AutoRefSyncProcessor implements BucketProcessor<AutoRefSyncRequest> {

    private static final Logger log = LoggerFactory.getLogger(AutoRefSyncProcessor.class);

    private final PullRequestService pullRequestService;
    private final RepositoryService repositoryService;
    private final SecurityService securityService;
    private final InternalRefSyncService syncService;
    private final UserService userService;

    public AutoRefSyncProcessor(PullRequestService pullRequestService, RepositoryService repositoryService,
                                SecurityService securityService, InternalRefSyncService syncService,
                                UserService userService) {
        this.pullRequestService = pullRequestService;
        this.repositoryService = repositoryService;
        this.securityService = securityService;
        this.syncService = syncService;
        this.userService = userService;
    }

    @Override
    public void process(@Nonnull String bucketId, @Nonnull final List<AutoRefSyncRequest> tasks) {
        final ApplicationUser user = findUser(tasks);
        if (user == null) {
            log.warn("Dropping {} request(s); no valid user could be found", tasks.size());

            return;
        }

        securityService.impersonating(user, "Processing ref sync")
                .withPermission(Permission.REPO_WRITE)
                .call(new UncheckedOperation<Object>() {

                    @Override
                    public Object perform() {
                        doProcess(user, tasks);

                        return null;
                    }
                });
    }

    /**
     * {@link AutoRefSyncTask#merge(List) Merges} the provided {@link AutoRefSyncRequest requests} and applies them.
     * <p>
     * Operations are mapped as follows:
     * <ul>
     *     <li>{@link AutoRefSyncOperation#REVIEW REVIEW} -> {@link InternalRefSyncService#updateStatus(BulkRefSyncRequest)}</li>
     *     <li>{@link AutoRefSyncOperation#SYNCHRONIZE SYNCHRONIZE} -> {@link InternalRefSyncService#synchronize(BulkRefSyncRequest)}</li>
     * </ul>
     *
     * @param user     the user to put on the {@link BulkRefSyncRequest}s executed
     * @param requests the requests to merge and apply, <i>already ordered</i> by timestamp
     */
    private void doProcess(ApplicationUser user, List<AutoRefSyncRequest> requests) {
        AutoRefSyncRequest first = requests.get(0);

        //First, retrieve the repository to make sure it hasn't been deleted while we were waiting to synchronize
        int repositoryId = first.getRepositoryId();
        Repository repository = repositoryService.getById(repositoryId);
        if (repository == null) {
            log.info("Dropping {} request(s); repository {} no longer exists", requests.size(), repositoryId);

            return;
        }

        //Next, retrieve the upstream to make sure it also still exists
        int upstreamId = first.getUpstreamId();
        Repository upstream = repositoryService.getById(upstreamId);
        if (upstream == null) {
            log.info("{}: Dropping {} request(s); upstream repository {} no longer exists",
                    repository, requests.size(), upstreamId);

            return;
        }

        //Once we've validated the existence of both repositories, merge the requests (to reduce the amount of
        //processing we perform) and then apply them in merged order.
        //
        //Merged order will include up to 3 requests, in the following order:
        //  1. SYNCHRONIZE
        //  2. SYNCHRONIZE (The second synchronize pass only happens if a ref was deleted/recreated)
        //  3. REVIEW
        //
        //The merged list _may_ be empty, in certain edge cases. It may contain only SYNCHRONIZE or only REVIEW
        //requests, or it may contain both (in the listed order).
        for (AutoRefSyncTask task : AutoRefSyncTask.merge(requests)) {
            try {
                if (task.getOperation() == AutoRefSyncOperation.REVIEW) {
                    BulkRefSyncRequest request = new BulkRefSyncRequest(repository, upstream, user, task.getRefChanges());
                    syncService.updateStatus(request);
                } else {
                    Collection<RefChange> refChanges = filterBranchDeletionsInPullRequests(repository, task.getRefChanges());
                    if (!refChanges.isEmpty()) {
                        BulkRefSyncRequest request = new BulkRefSyncRequest(repository, upstream, user, refChanges);
                        syncService.synchronize(request);
                    }
                }
            } catch (RuntimeException e) {
                log.warn("{}: Could not {} changes with {}", repository, task.getOperation(), upstream, e);
            }
        }
    }

    /**
     * Filter out branch deletions for branches which are in an open pull request.
     * <p>
     * Branch deletions only occur if the branch matches exactly. There is a slight edge case where an
     * identical branch is created in the upstream repository in order to run CI, then deleted. In this
     * case we want to avoid ref sync deleting the ref and rejecting the open pull request.
     */
    private Collection<RefChange> filterBranchDeletionsInPullRequests(Repository repository,
                                                                      Collection<RefChange> refChanges) {
        Collection<String> refsToDelete = chain(refChanges)
                .filter(refChange -> refChange.getType().equals(RefChangeType.DELETE))
                .transform(RefChange.TO_REF_ID)
                .toSet();

        if (refsToDelete.isEmpty()) {
            return refChanges;
        }

        Map<String, RefChange> refIdToRefChanges = new HashMap<>(Maps.uniqueIndex(refChanges, change ->
                change.getRef().getId()));

        final PullRequestSearchRequest searchRequest = new PullRequestSearchRequest.Builder()
                .fromRepositoryId(repository.getId())
                .fromRefIds(refsToDelete)
                .state(PullRequestState.OPEN)
                .build();
        Iterable<PullRequest> pullRequests = new PagedIterable<>(request -> {
            return pullRequestService.search(searchRequest, request);
        }, 500);

        for (PullRequest pullRequest : pullRequests) {
            refIdToRefChanges.remove(pullRequest.getFromRef().getId());
        }

        return ImmutableList.copyOf(refIdToRefChanges.values());
    }

    /**
     * Loops over the provided {@link AutoRefSyncRequest}s and returns the first valid {@link ApplicationUser}.
     * <p>
     * It is expected that/normal for there to be multiple {@link AutoRefSyncRequest#getUserId() users} represented
     * among the pending requests. To account for the fact that a user may have gone inactive between when any given
     * task is queued and when it's processed, the user for each task is tried in order. Once a user is found,
     * synchronization will be performed as that user. That means that user, in turn, will be on all of the events
     * raised as synchronization progresses.
     *
     * @param tasks one or more requests to try to load users for
     * @return the first valid user that could be loaded
     */
    private ApplicationUser findUser(List<AutoRefSyncRequest> tasks) {
        for (AutoRefSyncRequest task : tasks) {
            ApplicationUser user = userService.getUserById(task.getUserId());
            if (user != null) {
                return user;
            }
        }

        return null;
    }
}
