package com.atlassian.stash.internal.scm.git.command;

import com.atlassian.bitbucket.commit.AbstractCommitCallback;
import com.atlassian.bitbucket.commit.Commit;
import com.atlassian.stash.internal.scm.git.command.revlist.CallbackRevListOutputHandler;
import com.atlassian.stash.internal.scm.git.command.revlist.RevListInputHandler;
import com.atlassian.stash.internal.scm.git.command.revlist.RevListOutputHandler;
import com.atlassian.bitbucket.pull.PullRequestRef;
import com.atlassian.bitbucket.repository.Branch;
import com.atlassian.bitbucket.repository.Repository;
import com.atlassian.bitbucket.scm.git.GitAgent;
import com.atlassian.bitbucket.scm.git.command.GitCommandBuilderFactory;
import com.atlassian.bitbucket.scm.pull.MinimalPullRequest;
import com.atlassian.bitbucket.scm.pull.RepositoryRescopeContext;
import com.google.common.collect.ArrayListMultimap;
import com.google.common.collect.Iterables;
import com.google.common.collect.Maps;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.annotation.Nonnull;
import java.io.IOException;
import java.util.*;
import java.util.concurrent.ExecutorService;

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

/**
 * @since 2.4
 */
public class RepositoryRescopeCommand extends SimpleGitCommand<Void> {

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

    private final GitAgent agent;
    private final Map<String, String> branches;
    private final GitCommandBuilderFactory builderFactory;
    private final Repository repository;
    private final RepositoryRescopeContext rescopeContext;

    public RepositoryRescopeCommand(@Nonnull ExecutorService executorService, GitAgent agent,
                                    GitCommandBuilderFactory builderFactory,
                                    Repository repository, RepositoryRescopeContext rescopeContext) {
        super(executorService);

        this.agent = agent;
        this.builderFactory = builderFactory;
        this.repository = repository;
        this.rescopeContext = rescopeContext;

        branches = Maps.newHashMap();
    }

    @Override
    public Void call() {
        //First, do a loose pass to detect rescopes. Each Rescope returned represents a pull request for which at
        //least one ref has been updated
        Map<String, Collection<Rescope>> looseRescopes = detectRescopes();
        for (Map.Entry<String, Collection<Rescope>> looseRescope : looseRescopes.entrySet()) {
            //Next, determine whether unmerged commits exist. This will call Rescope.notMerged for any pull request
            //with at least one commit remaining to be merged if the pull request is accepted
            processRescopes(looseRescope.getKey(), looseRescope.getValue());
        }

        //After running any rev-list commands necessary to ensure pull requests are updated correctly, apply each
        //rescope to its wrapped pull request
        for (Rescope rescope : Iterables.concat(looseRescopes.values())) {
            rescope.apply(rescopeContext);
        }

        return null;
    }

    private Map<String, Collection<Rescope>> detectRescopes() {
        ArrayListMultimap<String, Rescope> rescopes = ArrayListMultimap.create();
        for (MinimalPullRequest pullRequest : rescopeContext) {
            PullRequestRef toRef = pullRequest.getToRef();
            String newToHash = resolveRef(toRef);
            if (newToHash == null) {
                //The pull request's target branch has been deleted; we can't check if it's merged, so just decline
                log.debug("{}: {} will be declined; to ref {} has been deleted",
                        repository.getId(), pullRequest.getId(), toRef.getId());
                rescopeContext.decline(pullRequest);

                continue;
            }

            PullRequestRef fromRef = pullRequest.getFromRef();
            String newFromHash = resolveRef(fromRef);
            if (newToHash.equals(toRef.getLatestCommit())) {
                if (newFromHash == null) {
                    //The pull request's source branch has been deleted and its target branch has not been updated.
                    //That means the pull request cannot have been merged because merging it would have updated the
                    //target branch (regardless of strategy)
                    log.debug("{}: {} will be declined; from ref {} has been deleted without updating to ref",
                            repository.getId(), pullRequest.getId(), fromRef.getId());
                    rescopeContext.decline(pullRequest);

                    continue;
                }
                if (fromRef.getLatestCommit().equals(newFromHash)) {
                    //If where we're rescoping an entire repository, we may find one or more pull requests which have
                    //not been updated. These need no further processing on any level, so they are not added to the
                    //map of rescopes
                    log.debug("{}: {} is unchanged", repository.getId(), pullRequest.getId());
                    continue;
                }
            }

            //Otherwise, we know _either_ the from or to branch for the pull request has moved. That means we have to
            //check whether it has been merged by running rev-list to look for commits
            rescopes.put(newToHash, new Rescope(pullRequest, newFromHash, newToHash));
        }
        return rescopes.asMap();
    }

    private boolean isSameRepository(PullRequestRef ref) {
        return Objects.equals(repository.getId(), ref.getRepository().getId());
    }

    private void processRescopes(String toHash, final Collection<Rescope> rescopes) {
        //Extract the search hashes from all of the rescopes, filtering out any rescopes where the searchHash matches
        //the toHash. This has the potential to optimize out running the rev-list for remotely-merged pull requests
        Set<String> includes = chain(rescopes)
                .transform(rescope -> rescope.searchHash)
                .filter(searchHash -> !Objects.equals(searchHash, toHash))
                .toSet();
        if (includes.isEmpty()) {
            //If the search hashes for all the rescopes match the target hash, the rev-list run would include and
            //exclude the same refs. This allows us to skip running the command entirely
            return;
        }
        RevListOutputHandler outputHandler = new CallbackRevListOutputHandler(repository, new AbstractCommitCallback() {

            @Override
            public boolean onCommit(@Nonnull Commit commit) throws IOException {
                String hash = commit.getId();
                for (Rescope rescope : rescopes) {
                    if (rescope.type == RescopeType.MERGE && hash.equals(rescope.searchHash)) {
                        rescope.notMerged();
                    }
                }

                return true;
            }
        });

        //Rather than doing a lot of "Which repository is which" checking here, just let the set dedupe everything
        Set<Repository> alternates = new HashSet<>(rescopes.size() + 1, 1f);
        for (Rescope rescope : rescopes) {
            alternates.add(rescope.pullRequest.getFromRef().getRepository());
            alternates.add(rescope.pullRequest.getToRef().getRepository());
        }
        //After the set is built, remove the rescope repository, since it will provide the working directory and,
        //therefore, its objects will automatically be available. This may make the set empty
        alternates.remove(repository);

        builderFactory.builder(repository).revList()
                .alternates(alternates)
                .format(outputHandler.getFormat())
                .ignoreMissing(true)
                .inputHandler(RevListInputHandler.forRefs(includes, Collections.singleton(toHash)))
                .build(outputHandler).call();
    }

    private String resolveRef(PullRequestRef ref) {
        if (isSameRepository(ref)) {
            String name = ref.getId();
            if (branches.containsKey(name)) {
                return branches.get(name); //Will return null for deleted branches
            }

            Branch branch = agent.resolveBranch(repository, name, true);
            String hash = branch == null ? null : branch.getLatestCommit();
            branches.put(name, hash);
            log.debug("{}: Resolved {} to {}", repository.getId(), name, hash);

            return hash;
        }

        //If the ref does not belong to the updated repository, for the purpose of this rescope it cannot have moved
        return ref.getLatestCommit();
    }

    private static class Rescope {

        private final String newFromHash;
        private final String newToHash;
        private final MinimalPullRequest pullRequest;
        private final Repository repository;
        private final String searchHash;

        private RescopeType type;

        private Rescope(MinimalPullRequest pullRequest, String newFromHash, String newToHash) {
            this.pullRequest = pullRequest;
            this.newFromHash = newFromHash;
            this.newToHash = newToHash;

            repository = pullRequest.getToRef().getRepository();
            //If the from branch has been deleted, the search hash for determining if this pull request has
            //been merged is the old from hash. Otherwise, it's the from branch's current hash
            searchHash = newFromHash == null ? pullRequest.getFromRef().getLatestCommit() : newFromHash;

            //Start from the assumption that the pull request was merged. As processRescopes searches for
            //commits, it may indicate that this is not true by calling notMerged()
            type = RescopeType.MERGE;
        }

        public void apply(RepositoryRescopeContext rescopeContext) {
            switch (type) {
                case DECLINE:
                    log.debug("{}: {} will be declined; from ref {} has been deleted without merging",
                            repository.getId(), pullRequest.getId(), pullRequest.getFromRef().getId());
                    rescopeContext.decline(pullRequest);

                    break;
                case MERGE:
                    log.debug("{}: {} has been remotely merged", repository.getId(), pullRequest.getId());
                    rescopeContext.merge(pullRequest);

                    break;
                case UPDATE:
                    log.debug("{}: {} will be updated; from({}->{}), to({}->{})",
                            repository.getId(), pullRequest.getId(), pullRequest.getFromRef().getLatestCommit(),
                            newFromHash, pullRequest.getToRef().getLatestCommit(), newToHash);
                    rescopeContext.update(pullRequest, newFromHash, newToHash);

                    break;
                default:
                    throw new IllegalStateException("Rescope type " + type + " is not supported");
            }
        }

        public void notMerged() {
            //If the searchHash is returned by a rev-list, it means this pull request is not merged.
            //- If the newFromHash is null, indicating the source branch has been deleted, the pull request is declined
            //- Otherwise, it means there are still commits to be merged by the pull request and it should be updated
            type = newFromHash == null ? RescopeType.DECLINE : RescopeType.UPDATE;
        }
    }

    private enum RescopeType {

        DECLINE,
        MERGE,
        UPDATE
    }
}
