package com.atlassian.bitbucket.mesh.git.commit;

import com.atlassian.bitbucket.mesh.annotation.Profiled;
import com.atlassian.bitbucket.mesh.git.builder.GitProcess;
import com.atlassian.bitbucket.mesh.git.builder.GitProcessBuilderFactory;
import com.atlassian.bitbucket.mesh.git.builder.catfile.ResolveCommitsStdioHandler;
import com.atlassian.bitbucket.mesh.git.builder.diff.GitDiffOutput;
import com.atlassian.bitbucket.mesh.git.builder.log.GitLogBuilder;
import com.atlassian.bitbucket.mesh.git.builder.log.LastModifiedLogStdioHandler;
import com.atlassian.bitbucket.mesh.git.builder.lstree.BlobLsTreeStdioHandler;
import com.atlassian.bitbucket.mesh.git.builder.merge.FirstMergeBaseStdioHandler;
import com.atlassian.bitbucket.mesh.git.builder.merge.MergeBaseCompletionHandler;
import com.atlassian.bitbucket.mesh.git.builder.revlist.*;
import com.atlassian.bitbucket.mesh.git.exception.CommitNotFoundException;
import com.atlassian.bitbucket.mesh.git.exception.NoMergeBaseException;
import com.atlassian.bitbucket.mesh.git.exception.PathNotFoundException;
import com.atlassian.bitbucket.mesh.repository.Repository;
import com.google.common.collect.Iterables;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.annotation.Nonnull;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CancellationException;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import java.util.stream.Stream;

import static java.util.Objects.requireNonNull;
import static java.util.stream.Stream.concat;

@Profiled
public class DefaultCommitManager implements CommitManager {

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

    private final GitProcessBuilderFactory builderFactory;

    public DefaultCommitManager(GitProcessBuilderFactory builderFactory) {
        this.builderFactory = builderFactory;
    }

    @Nonnull
    @Override
    public Commit getCommit(@Nonnull GetCommitParameters parameters) {
        String path = requireNonNull(parameters, "parameters")
                .getPath(); //The parameters ensure this will never be blank or empty

        //Retrieve the requested commit or, if a path was provided, start walking from the requested
        //commit until a commit that modifies the specified path is found
        Commit commit = parameters.configureAndRun(buildCommitProcess(parameters, path));
        if (commit == null) {
            String commitId = parameters.getCommitId();
            if (path == null) {
                //In theory this shouldn't be possible because we don't use --ignore-missing on rev-list,
                //so if the requested commit doesn't exist, git should return non-zero and an exception
                //should be thrown from the completion handler. Just in case, though...
                throw new CommitNotFoundException(commitId);
            }
            //If the specified path is never modified by any commit reachable from the starting commit,
            //however, then we can end up here. That's not an error to rev-list, even without using
            //--ignore-missing, because it's a filter rather than a lookup
            throw new PathNotFoundException(commitId, path);
        }

        return commit;
    }

    @Override
    public String getMergeBase(@Nonnull GetMergeBaseParameters parameters) {
        Set<String> commitIds = requireNonNull(parameters, "parameters").getCommitIds();
        if (commitIds.isEmpty()) {
            //If there are no IDs, there's no merge base
            if (parameters.isRequired()) {
                throw new NoMergeBaseException();
            }
            return null;
        }
        if (commitIds.size() == 1) {
            //If there's only a single ID, it's its own merge base
            return Iterables.getOnlyElement(commitIds);
        }

        return parameters.configureAndRun(builderFactory.builder(parameters.getRepository()).mergeBase()
                .octopus()
                .alternates(parameters.getAlternates())
                .commits(commitIds)
                .completionHandler(new MergeBaseCompletionHandler(parameters.isRequired()))
                .build(new FirstMergeBaseStdioHandler()));
    }

    @Nonnull
    @Override
    public Map<String, String> resolveCommits(@Nonnull ResolveCommitsParameters parameters) {
        Repository repository = requireNonNull(parameters, "parameters").getRepository();
        return parameters.configureAndRun(builderFactory.builder(repository).catFile()
                .batchCheck()
                .build(new ResolveCommitsStdioHandler(parameters.getCommitishes())));
    }

    @Override
    public void streamCommits(@Nonnull StreamCommitsParameters parameters, @Nonnull CommitCallback callback) {
        requireNonNull(parameters, "parameters");
        requireNonNull(callback, "callback");

        Stream<String> stdin = prepareStdin(parameters.getExcludes(), parameters.getIncludes());

        CallbackRevListStdioHandler handler = new CallbackRevListStdioHandler(callback, parameters.isWithMessages(),
                parameters.getMaxMessageLength(), parameters.getPaths(), stdin);

        parameters.configureAndRun(parameters.isFollowingRenames() ?
                createLogCommand(parameters, handler) :
                createRevListCommand(parameters, handler));
    }

    @Override
    public void streamLastModified(@Nonnull StreamLastModifiedParameters parameters,
                                   @Nonnull LastModifiedCallback callback) {
        String commitId = requireNonNull(parameters, "parameters").getCommitId();
        String path = parameters.getPath();

        List<String> files = parameters.configureAndRun(builderFactory.builder(parameters.getRepository()).lsTree()
                .nullTerminated(true)
                .tree(commitId, path)
                .build(new BlobLsTreeStdioHandler(commitId, path)));

        //Start the rev-list to retrieve the latest commit to the path, and then run log to try and determine
        //the latest commit to touch each file in the directory
        Future<Commit> futureCommit = buildCommitProcess(parameters, path).start();
        try {
            streamModifiedFiles(parameters, files, callback);
        } catch (RuntimeException e) {
            //If streaming the modified files fails, cancel retrieving the latest commit before propagating
            //the exception from streamModifiedFiles
            futureCommit.cancel(true);

            throw e;
        }

        //By this point, any file/commit pairs that are going to be streamed have been. All that's left is
        //to retrieve the latest commit and write that as the final fragment
        try {
            //Note that this does not block indefinitely because we always have an idle and execution timeout set on
            //the rev-list process (default if not set explicitly)
            Commit latestCommit = futureCommit.get();
            if (latestCommit != null) {
                callback.onLatestCommit(latestCommit);
            }
        } catch (CancellationException e) {
            log.debug("Canceled while retrieving latest commit to {}:{}", commitId, path);
        } catch (ExecutionException e) {
            log.warn("Could not retrieve latest commit to {}:{}", commitId, path);
        } catch (InterruptedException e) {
            log.debug("Interrupted while retrieving latest commit to {}:{}", commitId, path);
            futureCommit.cancel(true);

            Thread.currentThread().interrupt();
        }
    }

    @Override
    public void traverseCommits(@Nonnull TraverseCommitsParameters parameters, @Nonnull TraversalCallback callback) {
        requireNonNull(parameters, "parameters");
        requireNonNull(callback, "callback");

        Stream<String> stdin = concat(
                parameters.getIncludes().stream(),
                parameters.getExcludes().stream().map(rev -> "^" + rev));
        GitRevListBuilder builder = builderFactory.builder(parameters.getRepository()).revList()
                .all(parameters.isAll())
                .branches(parameters.isBranches())
                .alternates(parameters.getAlternates())
                .ignoreMissing(parameters.isIgnoreMissing())
                .order(parameters.getOrder())
                .parents(true)
                .stdin(true)
                .tags(parameters.isTags());

        parameters.configureAndRun(builder.build(new TraversalRevListStdioHandler(stdin, callback)));
    }

    private static void ensureRevSet(GitRevCoreBuilder<?> builder, Set<String> includes, Set<String> excludes, boolean byFlag) {
        //Always pass at least one rev on the command line if we're passing any via --stdin. This works
        //around a bug in git log where, when --ignore-missing is used with --stdin, if all the revs are
        //missing, log incorrectly defaults to including HEAD
        if (!includes.isEmpty()) {
            builder.rev(Iterables.getFirst(includes, null));
        } else if (!excludes.isEmpty()) {
            builder.rev(Iterables.getFirst(excludes, null));
        } else if (!byFlag) {
            //We have no revs set through a flag, no includes and no excludes. This will produce usage output from git,
            //so we assume the caller is attempting to retrieve commits for the default branch
            builder.rev("HEAD");
        }
    }

    private static Stream<String> prepareStdin(Set<String> excludes, Set<String> includes) {
        return concat(includes.stream(), excludes.stream().map(ref -> '^' + ref));
    }

    private GitProcess<Commit> buildCommitProcess(AbstractGetCommitParameters parameters, String path) {
        int maxMessageLength = parameters.getMaxMessageLength();

        //withPaths is always passed as false, even if a path was provided, because we're using git
        //rev-list, not git log, and so there's never any --name-status output to parse
        SingleCommitRevListStdioHandler handler = new SingleCommitRevListStdioHandler(
                path, maxMessageLength != 0, false, maxMessageLength);

        return builderFactory.builder(parameters.getRepository()).revList()
                .file(path)
                .format(handler.getFormat())
                .limit(1)
                .rev(parameters.getCommitId())
                .build(handler);
    }

    /**
     * Creates a {@link GitLogBuilder} and configures it with the {@link CallbackRevListStdioHandler#getFormat() format}
     * from the provided {@code handler} and other settings from the provided {@link StreamCommitsParameters}.
     *
     * @param parameters the parameters to use for configuring the command
     * @param handler    the output handler to receive output when the command is {@link GitProcess#start()} () called}
     * @return a {@link GitProcess} built from the provided output handler using {@code git log}
     */
    private GitProcess<Void> createLogCommand(StreamCommitsParameters parameters,
                                              CallbackRevListStdioHandler handler) {
        GitLogBuilder builder = builderFactory.builder(parameters.getRepository()).log()
                .all(parameters.isAll())
                .branches(parameters.isBranches())
                .alternates(parameters.getAlternates())
                .completionHandler(new LogCompletionHandler(parameters.getIncludes(),
                        parameters.getExcludes(), parameters.isIgnoringMissing()))
                .files(parameters.getPaths())
                .format("commit %H%n" + handler.getFormat()) //Add a leading "commit abc123" to mimic rev-list
                .ignoreMissing(parameters.isIgnoringMissing())
                .merges(parameters.getMerges())
                .order(parameters.getOrder())
                .since(parameters.getSince())
                .stdin(!parameters.getIncludes().isEmpty() || !parameters.getExcludes().isEmpty())
                .tags(parameters.isTags())
                .walk(parameters.getWalk());

        if (parameters.isFollowingRenames()) {
            //When following renames, we also need the --name-status output so we know how the filename
            //has changed. That information allows the UI to link to the correct path when clicking on
            //an entry in the history where the file had a different name
            builder.follow(true)
                    .output(GitDiffOutput.NAME_STATUS);

            //We also can't use --skip with --follow, so we need to skip inside the handler. That means we
            //need to include the skipped commits in the applied limit
            handler.setSkip(parameters.getSkip());
            builder.limit(parameters.getSkip() + parameters.getLimit());
        } else {
            //If we're not following renames, we can apply skip and limit as normal
            builder.limit(parameters.getLimit())
                    .skip(parameters.getSkip());
        }

        ensureRevSet(builder, parameters.getIncludes(), parameters.getExcludes(),
                parameters.isAll() || parameters.isBranches() || parameters.isTags());
        return builder.build(handler);
    }

    /**
     * Creates a {@link GitRevListBuilder} and configures it with the {@link CallbackRevListStdioHandler#getFormat()
     * format} from the provided {@code handler} and other settings from the provided {@link StreamCommitsParameters}.
     *
     * @param parameters the parameters to use for configuring the command
     * @param handler    the output handler to receive output when the command is {@link GitProcess#start() called}
     * @return a {@link GitProcess} built from the provided output handler using {@code git rev-list}
     */
    private GitProcess<Void> createRevListCommand(StreamCommitsParameters parameters,
                                                  CallbackRevListStdioHandler handler) {
        GitRevListBuilder builder = builderFactory.builder(parameters.getRepository()).revList()
                .all(parameters.isAll())
                .alternates(parameters.getAlternates())
                .branches(parameters.isBranches())
                .completionHandler(new RevCoreCompletionHandler(parameters.getIncludes(),
                        parameters.getExcludes(), parameters.isIgnoringMissing()))
                .format(handler.getFormat())
                .files(parameters.getPaths())
                .ignoreMissing(parameters.isIgnoringMissing())
                .limit(parameters.getLimit())
                .merges(parameters.getMerges())
                .order(parameters.getOrder())
                .since(parameters.getSince())
                .skip(parameters.getSkip())
                .stdin(!parameters.getIncludes().isEmpty() || !parameters.getExcludes().isEmpty())
                .tags(parameters.isTags())
                .walk(parameters.getWalk());

        return builder.build(handler);
    }

    private void streamModifiedFiles(StreamLastModifiedParameters parameters, List<String> files,
                                     LastModifiedCallback callback) {
        if (files == null || files.isEmpty()) {
            return;
        }
        if (files.size() == 1) {
            String basePath = parameters.getPath();
            String file = files.get(0);

            //Note that the rev-list command requires the _fully qualified path_, but, if a commit is
            //found, we map it by the _unqualified_ path
            Commit commit = buildCommitProcess(parameters, basePath == null ? file : basePath + "/" + file).run();
            if (commit != null) {
                callback.onFileModified(file, commit);
            }

            return;
        }

        LastModifiedLogStdioHandler logHandler = new LastModifiedLogStdioHandler(callback, parameters.getCommitId(),
                parameters.getPath(), files, parameters.getMaxMessageLength());

        //--relative isn't used here because git log doesn't apply it consistently. If there's a merge
        //commit, it still uses the full path to the file in its output.
        parameters.configureAndRun(builderFactory.builder(parameters.getRepository()).command("log")
                .argument("-c")
                .argument("--diff-filter=d") //Exclude deletions, since we can't possibly care about them
                .argument("--format=" + logHandler.getFormat())
                .argument("--name-status")
                .argument("--stdin")
                .argument("--topo-order")
                .build(logHandler));
    }
}
