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

import com.atlassian.bitbucket.mesh.config.ProcessConfig;
import com.atlassian.bitbucket.mesh.git.GitConstants;
import com.atlassian.bitbucket.mesh.git.GitProcessCompletionHandler;
import com.atlassian.bitbucket.mesh.git.GitQuarantineRegistry;
import com.atlassian.bitbucket.mesh.git.binary.GitBinary;
import com.atlassian.bitbucket.mesh.git.builder.add.DefaultGitAddBuilder;
import com.atlassian.bitbucket.mesh.git.builder.add.GitAddBuilder;
import com.atlassian.bitbucket.mesh.git.builder.archive.DefaultGitArchiveBuilder;
import com.atlassian.bitbucket.mesh.git.builder.archive.GitArchiveBuilder;
import com.atlassian.bitbucket.mesh.git.builder.blame.DefaultGitBlame;
import com.atlassian.bitbucket.mesh.git.builder.blame.GitBlame;
import com.atlassian.bitbucket.mesh.git.builder.branch.DefaultGitBranch;
import com.atlassian.bitbucket.mesh.git.builder.branch.GitBranch;
import com.atlassian.bitbucket.mesh.git.builder.catfile.DefaultGitCatFile;
import com.atlassian.bitbucket.mesh.git.builder.catfile.GitCatFile;
import com.atlassian.bitbucket.mesh.git.builder.checkout.DefaultGitCheckoutBuilder;
import com.atlassian.bitbucket.mesh.git.builder.checkout.GitCheckoutBuilder;
import com.atlassian.bitbucket.mesh.git.builder.checkrefformat.DefaultGitCheckRefFormat;
import com.atlassian.bitbucket.mesh.git.builder.checkrefformat.GitCheckRefFormat;
import com.atlassian.bitbucket.mesh.git.builder.clone.DefaultGitClone;
import com.atlassian.bitbucket.mesh.git.builder.clone.GitClone;
import com.atlassian.bitbucket.mesh.git.builder.commit.DefaultGitCommitBuilder;
import com.atlassian.bitbucket.mesh.git.builder.commit.GitCommitBuilder;
import com.atlassian.bitbucket.mesh.git.builder.commitgraph.DefaultGitCommitGraph;
import com.atlassian.bitbucket.mesh.git.builder.commitgraph.GitCommitGraph;
import com.atlassian.bitbucket.mesh.git.builder.config.DefaultGitConfig;
import com.atlassian.bitbucket.mesh.git.builder.config.GitConfig;
import com.atlassian.bitbucket.mesh.git.builder.countobjects.DefaultGitCountObjectsBuilder;
import com.atlassian.bitbucket.mesh.git.builder.countobjects.GitCountObjectsBuilder;
import com.atlassian.bitbucket.mesh.git.builder.diff.DefaultGitDiffBuilder;
import com.atlassian.bitbucket.mesh.git.builder.diff.DefaultGitDiffTreeBuilder;
import com.atlassian.bitbucket.mesh.git.builder.diff.GitDiffBuilder;
import com.atlassian.bitbucket.mesh.git.builder.diff.GitDiffTreeBuilder;
import com.atlassian.bitbucket.mesh.git.builder.fetch.DefaultGitFetchBuilder;
import com.atlassian.bitbucket.mesh.git.builder.fetch.GitFetchBuilder;
import com.atlassian.bitbucket.mesh.git.builder.foreachref.DefaultGitForEachRefBuilder;
import com.atlassian.bitbucket.mesh.git.builder.foreachref.GitForEachRefBuilder;
import com.atlassian.bitbucket.mesh.git.builder.formatpatch.DefaultGitFormatPatchBuilder;
import com.atlassian.bitbucket.mesh.git.builder.formatpatch.GitFormatPatchBuilder;
import com.atlassian.bitbucket.mesh.git.builder.gc.DefaultGitGcBuilder;
import com.atlassian.bitbucket.mesh.git.builder.gc.GitGcBuilder;
import com.atlassian.bitbucket.mesh.git.builder.httpbackend.DefaultGitHttpBackendBuilder;
import com.atlassian.bitbucket.mesh.git.builder.httpbackend.GitHttpBackendBuilder;
import com.atlassian.bitbucket.mesh.git.builder.indexpack.DefaultGitIndexPackBuilder;
import com.atlassian.bitbucket.mesh.git.builder.indexpack.GitIndexPackBuilder;
import com.atlassian.bitbucket.mesh.git.builder.init.DefaultGitInitBuilder;
import com.atlassian.bitbucket.mesh.git.builder.init.GitInitBuilder;
import com.atlassian.bitbucket.mesh.git.builder.log.DefaultGitLogBuilder;
import com.atlassian.bitbucket.mesh.git.builder.log.GitLogBuilder;
import com.atlassian.bitbucket.mesh.git.builder.lsremote.DefaultGitLsRemoteBuilder;
import com.atlassian.bitbucket.mesh.git.builder.lsremote.GitLsRemoteBuilder;
import com.atlassian.bitbucket.mesh.git.builder.lstree.DefaultGitLsTreeBuilder;
import com.atlassian.bitbucket.mesh.git.builder.lstree.GitLsTreeBuilder;
import com.atlassian.bitbucket.mesh.git.builder.merge.DefaultGitMerge;
import com.atlassian.bitbucket.mesh.git.builder.merge.DefaultGitMergeBase;
import com.atlassian.bitbucket.mesh.git.builder.merge.GitMerge;
import com.atlassian.bitbucket.mesh.git.builder.merge.GitMergeBase;
import com.atlassian.bitbucket.mesh.git.builder.mergetree.DefaultGitMergeTreeBuilder;
import com.atlassian.bitbucket.mesh.git.builder.mergetree.GitMergeTreeBuilder;
import com.atlassian.bitbucket.mesh.git.builder.notes.DefaultGitNotes;
import com.atlassian.bitbucket.mesh.git.builder.notes.GitNotes;
import com.atlassian.bitbucket.mesh.git.builder.packobjects.DefaultGitPackObjectsBuilder;
import com.atlassian.bitbucket.mesh.git.builder.packobjects.GitPackObjectsBuilder;
import com.atlassian.bitbucket.mesh.git.builder.packrefs.DefaultGitPackRefsBuilder;
import com.atlassian.bitbucket.mesh.git.builder.packrefs.GitPackRefsBuilder;
import com.atlassian.bitbucket.mesh.git.builder.prune.DefaultGitPruneBuilder;
import com.atlassian.bitbucket.mesh.git.builder.prune.GitPruneBuilder;
import com.atlassian.bitbucket.mesh.git.builder.prunepacked.DefaultGitPrunePackedBuilder;
import com.atlassian.bitbucket.mesh.git.builder.prunepacked.GitPrunePackedBuilder;
import com.atlassian.bitbucket.mesh.git.builder.push.DefaultGitPushBuilder;
import com.atlassian.bitbucket.mesh.git.builder.push.GitPushBuilder;
import com.atlassian.bitbucket.mesh.git.builder.rebase.DefaultGitRebaseBuilder;
import com.atlassian.bitbucket.mesh.git.builder.rebase.GitRebaseBuilder;
import com.atlassian.bitbucket.mesh.git.builder.receivepack.DefaultGitReceivePackBuilder;
import com.atlassian.bitbucket.mesh.git.builder.receivepack.GitReceivePackBuilder;
import com.atlassian.bitbucket.mesh.git.builder.remote.DefaultGitRemote;
import com.atlassian.bitbucket.mesh.git.builder.remote.GitRemote;
import com.atlassian.bitbucket.mesh.git.builder.repack.DefaultGitRepackBuilder;
import com.atlassian.bitbucket.mesh.git.builder.repack.GitRepackBuilder;
import com.atlassian.bitbucket.mesh.git.builder.reset.DefaultGitResetBuilder;
import com.atlassian.bitbucket.mesh.git.builder.reset.GitResetBuilder;
import com.atlassian.bitbucket.mesh.git.builder.revert.DefaultGitRevertBuilder;
import com.atlassian.bitbucket.mesh.git.builder.revert.GitRevertBuilder;
import com.atlassian.bitbucket.mesh.git.builder.revlist.DefaultGitRevListBuilder;
import com.atlassian.bitbucket.mesh.git.builder.revlist.GitRevListBuilder;
import com.atlassian.bitbucket.mesh.git.builder.status.DefaultGitStatusBuilder;
import com.atlassian.bitbucket.mesh.git.builder.status.GitStatusBuilder;
import com.atlassian.bitbucket.mesh.git.builder.symbolicref.DefaultGitSymbolicRef;
import com.atlassian.bitbucket.mesh.git.builder.symbolicref.GitSymbolicRef;
import com.atlassian.bitbucket.mesh.git.builder.tag.DefaultGitTag;
import com.atlassian.bitbucket.mesh.git.builder.tag.GitTag;
import com.atlassian.bitbucket.mesh.git.builder.updateref.DefaultGitUpdateRef;
import com.atlassian.bitbucket.mesh.git.builder.updateref.GitUpdateRef;
import com.atlassian.bitbucket.mesh.git.builder.uploadarchive.DefaultGitUploadArchiveBuilder;
import com.atlassian.bitbucket.mesh.git.builder.uploadarchive.GitUploadArchiveBuilder;
import com.atlassian.bitbucket.mesh.git.builder.uploadpack.DefaultGitUploadPackBuilder;
import com.atlassian.bitbucket.mesh.git.builder.uploadpack.GitUploadPackBuilder;
import com.atlassian.bitbucket.mesh.git.builder.version.DefaultGitVersionBuilder;
import com.atlassian.bitbucket.mesh.git.builder.version.GitVersionBuilder;
import com.atlassian.bitbucket.mesh.git.exception.InvalidCommitishException;
import com.atlassian.bitbucket.mesh.git.exception.InvalidTreeishException;
import com.atlassian.bitbucket.mesh.io.MoreFiles;
import com.atlassian.bitbucket.mesh.layout.StorageLayout;
import com.atlassian.bitbucket.mesh.process.*;
import com.atlassian.bitbucket.mesh.repository.Repository;
import com.atlassian.bitbucket.mesh.rpc.v1.RpcGitTimeouts;
import com.atlassian.bitbucket.mesh.throttle.ThrottledResource;
import com.atlassian.bitbucket.mesh.util.Version;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.mutable.MutableInt;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import javax.annotation.concurrent.NotThreadSafe;
import java.io.File;
import java.io.IOException;
import java.io.Writer;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.time.Duration;
import java.time.Instant;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import static com.atlassian.bitbucket.mesh.git.GitConstants.*;
import static java.util.Objects.requireNonNull;
import static java.util.stream.Collectors.toList;

@NotThreadSafe
public class DefaultGitProcessBuilder implements GitProcessBuilder {

    private final Set<Repository> alternates;
    private final Set<Path> alternateObjectsDirs;
    private final LinkedList<String> arguments;
    private final GitBinary binary;
    private final Map<String, String> configuration;
    private final Map<String, String> environment;
    private final ProcessConfig processConfig;
    private final ProcessCreator processCreator;
    private final GitQuarantineRegistry quarantineRegistry;
    private final Repository repository;
    private final StorageLayout storageLayout;

    private String command;
    private ProcessCompletionHandler completionHandler;
    private Instant deadline;
    private Duration executionInterval;
    private boolean hasAuthor;
    private boolean hasCommitter;
    private Duration idleInterval;
    private ThrottledResource throttleResource;
    private Path workDir;

    public DefaultGitProcessBuilder(@Nonnull GitBinary binary, @Nonnull ProcessConfig processConfig,
                                    @Nonnull ProcessCreator processCreator,
                                    @Nonnull GitQuarantineRegistry quarantineRegistry,
                                    @Nonnull StorageLayout storageLayout, Repository repository) {
        this.binary = requireNonNull(binary, "binary");
        this.processConfig = requireNonNull(processConfig, "processConfig");
        this.processCreator = requireNonNull(processCreator, "processCreator");
        this.quarantineRegistry = requireNonNull(quarantineRegistry, "quarantineRegistry");
        this.repository = repository;
        this.storageLayout = requireNonNull(storageLayout, "storageLayout");

        alternateObjectsDirs = new HashSet<>();
        alternates = new HashSet<>();
        arguments = new LinkedList<>();
        configuration = new LinkedHashMap<>();
        environment = new HashMap<>();
        workDir = repository == null ? null : repository.getPath();
    }

    @Nonnull
    @Override
    public GitAddBuilder add() {
        return new DefaultGitAddBuilder(this);
    }

    @Nonnull
    @Override
    public GitProcessBuilder alternate(@Nonnull Repository alternate) {
        alternates.add(requireNonNull(alternate, "alternate"));

        return self();
    }

    @Nonnull
    @Override
    public GitProcessBuilder alternate(@Nonnull Path alternateObjectsDir) {
        alternateObjectsDirs.add(requireNonNull(alternateObjectsDir, "alternateObjectsDir"));

        return self();
    }

    @Nonnull
    @Override
    public GitProcessBuilder alternates(@Nonnull Iterable<Repository> values) {
        Iterator<Repository> iterator = requireNonNull(values, "alternates").iterator();
        for (int i = 0; iterator.hasNext(); ++i) {
            alternates.add(requireNonNull(iterator.next(), "alternates[" + i + "]"));
        }

        return self();
    }

    @Nonnull
    @Override
    public GitProcessBuilder alternates(@Nonnull Repository value, @Nonnull Repository... values) {
        requireNonNull(value, "alternate");
        requireNonNull(values, "alternates");

        alternates.add(value);
        for (int i = 0; i < values.length; ++i) {
            alternates.add(requireNonNull(values[i], "alternates[" + i + "]"));
        }

        return self();
    }

    @Nonnull
    @Override
    public GitArchiveBuilder archive() {
        return new DefaultGitArchiveBuilder(this);
    }

    @Nonnull
    @Override
    public GitProcessBuilder argument(@Nonnull String argument) {
        arguments.add(notBlank(argument));

        return self();
    }

    @Nonnull
    @Override
    public GitProcessBuilder arguments(@Nonnull Iterable<String> values) {
        Iterator<String> iterator = requireNonNull(values, "arguments").iterator();
        for (int i = 0; iterator.hasNext(); ++i) {
            arguments.add(notBlank(iterator.next(), "arguments[" + i + "]"));
        }

        return self();
    }

    @Nonnull
    @Override
    public GitProcessBuilder argumentAfter(@Nonnull String anchor, @Nonnull String argument) {
        int index = arguments.indexOf(notBlank(anchor, "anchor"));
        if (index == -1) {
            return argument(argument);
        }
        return argumentAt(index + 1, argument);
    }

    @Nonnull
    @Override
    public GitProcessBuilder argumentAt(int index, @Nonnull String argument) {
        arguments.add(index, notBlank(argument));

        return self();
    }

    @Nonnull
    @Override
    public GitProcessBuilder argumentBefore(@Nonnull String anchor, @Nonnull String argument) {
        int index = arguments.indexOf(notBlank(anchor, "anchor"));
        if (index == -1) {
            return argument(argument);
        }
        return argumentAt(index, argument);
    }

    @Nonnull
    @Override
    public GitProcessBuilder author(@Nonnull String displayName, @Nonnull String email) {
        withEnvironment(ENV_AUTHOR_EMAIL, email);
        withEnvironment(ENV_AUTHOR_NAME, displayName);
        hasAuthor = true; //Note that an author has been set so that setting a committer won't overwrite it

        if (!hasCommitter) {
            //If the committer has not been set, set it to the same values as the author
            committer(displayName, email);
        }

        return self();
    }

    @Nonnull
    @Override
    public GitBlame blame() {
        return new DefaultGitBlame(this);
    }

    @Nonnull
    @Override
    public GitBranch branch() {
        return new DefaultGitBranch(this);
    }

    @Nonnull
    @Override
    public <T> GitProcess<T> build(@Nonnull ProcessStdioHandler<T> stdioHandler) {
        requireNonNull(stdioHandler, "stdioHandler");

        Map<String, String> effectiveEnvironment = new HashMap<>(environment);
        //Make the git command being executed available to git hooks. This is used by GitReferenceTransactionHookHandler
        //to determine when to suppress 'fake' packed-backend reference-transaction hooks.
        Optional<String> effectiveCommand = command == null ? arguments.stream().findFirst() : Optional.of(command);
        effectiveCommand.ifPresent(command -> effectiveEnvironment.put(ENV_BB_GIT_COMMAND, command));
        applyConfiguration(effectiveEnvironment);
        if (repository != null) {
            //Configure the quarantine environment, if this builder is running in a pre-receive hook. This must be
            //done before any alternates are set to ensure quarantine alternates are applied correctly
            quarantineRegistry.get(repository).ifPresent(env -> applyQuarantine(env, effectiveEnvironment));
        }

        //Configure alternates if specified, which may return a delegating GitStdioHandler to ensure alternates
        //are cleaned up when the process completes
        ProcessCompletionHandler effectiveCompletionHandler =
                applyAlternates(effectiveEnvironment, chooseCompletionHandler(stdioHandler));

        //Cap the execution interval with the time remaining if a deadline has been set, or set the execution interval
        //to the remaining time until the deadline if no execution interval has been configured.
        Duration effectiveExecutionInterval = executionInterval;
        if (deadline != null) {
            Duration remaining = Duration.between(Instant.now(), deadline);
            if (effectiveExecutionInterval == null || effectiveExecutionInterval.compareTo(remaining) > 0) {
                effectiveExecutionInterval = remaining;
            }
        }

        ProcessParameters<T> parameters = new ProcessParameters.Builder<>(stdioHandler, effectiveCompletionHandler)
                .arguments(mergeArguments())
                .environment(effectiveEnvironment)
                .executionInterval(effectiveExecutionInterval)
                .idleInterval(idleInterval)
                .throttled(throttleResource)
                .workDir(workDir)
                .build();
        return new DefaultGitProcess<>(processCreator, parameters);
    }

    @Nonnull
    @Override
    public GitCatFile catFile() {
        return new DefaultGitCatFile(this);
    }

    @Nonnull
    @Override
    public GitCheckoutBuilder checkout() {
        return new DefaultGitCheckoutBuilder(this);
    }

    @Nonnull
    @Override
    public GitCheckRefFormat checkRefFormat() {
        return new DefaultGitCheckRefFormat(this);
    }

    @Nonnull
    @Override
    public GitProcessBuilder clearArguments() {
        arguments.clear();

        return self();
    }

    @Nonnull
    @Override
    public GitProcessBuilder clearEnvironment() {
        environment.clear();

        return self();
    }

    @Nonnull
    @Override
    @SuppressWarnings("MethodDoesntCallSuperMethod")
    public GitClone clone() {
        return new DefaultGitClone(this, storageLayout);
    }

    @Nonnull
    @Override
    public GitProcessBuilder command(@Nonnull String value) {
        command = notBlank(value, "command");

        return self();
    }

    @Nonnull
    @Override
    public GitCommitBuilder commit() {
        return new DefaultGitCommitBuilder(this);
    }

    @Nonnull
    @Override
    public GitCommitGraph commitGraph() {
        return new DefaultGitCommitGraph(this);
    }

    @Nonnull
    @Override
    public GitProcessBuilder commitish(@Nonnull String value) {
        //notBlank(String) calls trim() on the value before returning it. Since argument(String) uses
        //notBlank(String), we need to use that here too _before_ checking for a leading -; otherwise
        //it's possible to "hide" malicious arguments behind leading whitespace
        value = notBlank(value, "commitish");
        if (value.startsWith("-")) {
            throw new InvalidCommitishException(value);
        }
        //Since the value is passed through notBlank above, we can use rawArgument here; any trimming
        //necessary has already been performed
        rawArgument(value);

        return self();
    }

    @Nonnull
    @Override
    public GitProcessBuilder committer(@Nonnull String displayName, @Nonnull String email) {
        withEnvironment(ENV_COMMITTER_EMAIL, email);
        withEnvironment(ENV_COMMITTER_NAME, displayName);
        hasCommitter = true; //Note that a committer has been set so that setting an author won't overwrite it

        if (!hasAuthor) {
            //If the author has not been set, set it to the same values as the committer
            author(displayName, email);
        }

        return self();
    }

    @Nonnull
    @Override
    public GitProcessBuilder completionHandler(@Nonnull ProcessCompletionHandler value) {
        completionHandler = requireNonNull(value, "completionHandler");

        return self();
    }

    @Nonnull
    @Override
    public GitConfig config() {
        return new DefaultGitConfig(this);
    }

    @Nonnull
    @Override
    public GitCountObjectsBuilder countObjects() {
        return new DefaultGitCountObjectsBuilder(this);
    }

    @Nonnull
    @Override
    public GitProcessBuilder defaultCompletionHandler() {
        completionHandler = null;

        return self();
    }

    @Nonnull
    @Override
    public GitProcessBuilder defaultWorkDir() {
        workDir = null;

        return self();
    }

    @Nonnull
    @Override
    public GitDiffBuilder diff() {
        return new DefaultGitDiffBuilder(this);
    }

    @Nonnull
    @Override
    public GitDiffTreeBuilder diffTree() {
        return new DefaultGitDiffTreeBuilder(this);
    }

    @Nonnull
    @Override
    public GitFetchBuilder fetch() {
        return new DefaultGitFetchBuilder(this, repository);
    }

    @Nonnull
    @Override
    public GitForEachRefBuilder forEachRef() {
        return new DefaultGitForEachRefBuilder(this);
    }

    @Nonnull
    @Override
    public GitFormatPatchBuilder formatPatch() {
        return new DefaultGitFormatPatchBuilder(this);
    }

    @Nonnull
    @Override
    public GitGcBuilder gc() {
        return new DefaultGitGcBuilder(this);
    }

    @Override
    public Repository getRepository() {
        return repository;
    }

    @Nonnull
    @Override
    public GitHttpBackendBuilder httpBackend() {
        return new DefaultGitHttpBackendBuilder(this, workDir);
    }

    @Nonnull
    @Override
    public GitIndexPackBuilder indexPack() {
        return new DefaultGitIndexPackBuilder(this);
    }

    @Nonnull
    @Override
    public GitInitBuilder init() {
        return new DefaultGitInitBuilder(this);
    }

    @Override
    public boolean isGitVersionAtLeast(@Nonnull Version version) {
        return binary.getVersion().compareTo(requireNonNull(version, "version")) >= 0;
    }

    @Nonnull
    @Override
    public GitLogBuilder log() {
        return new DefaultGitLogBuilder(this);
    }

    @Nonnull
    @Override
    public GitLsTreeBuilder lsTree() {
        return new DefaultGitLsTreeBuilder(this);
    }

    @Nonnull
    @Override
    public GitLsRemoteBuilder lsRemote() {
        return new DefaultGitLsRemoteBuilder(this);
    }

    @Nonnull
    @Override
    public GitMerge merge() {
        return new DefaultGitMerge(this);
    }

    @Nonnull
    @Override
    public GitMergeBase mergeBase() {
        return new DefaultGitMergeBase(this);
    }

    @Nonnull
    @Override
    public GitMergeTreeBuilder mergeTree() {
        return new DefaultGitMergeTreeBuilder(this);
    }

    @Nonnull
    @Override
    public GitNotes notes() {
        return new DefaultGitNotes(this);
    }

    @Nonnull
    @Override
    public GitPackObjectsBuilder packObjects() {
        return new DefaultGitPackObjectsBuilder(this);
    }

    @Nonnull
    @Override
    public GitPackRefsBuilder packRefs() {
        return new DefaultGitPackRefsBuilder(this);
    }

    @Nonnull
    @Override
    public GitPruneBuilder prune() {
        return new DefaultGitPruneBuilder(this);
    }

    @Nonnull
    @Override
    public GitPrunePackedBuilder prunePacked() {
        return new DefaultGitPrunePackedBuilder(this);
    }

    @Nonnull
    @Override
    public GitPushBuilder push() {
        return new DefaultGitPushBuilder(this);
    }

    @Nonnull
    @Override
    public GitProcessBuilder rawArgument(@Nonnull String argument) {
        arguments.add(requireNonNull(argument, "argument"));

        return self();
    }

    @Nonnull
    @Override
    public GitProcessBuilder rawArgumentAfter(@Nonnull String anchor, @Nonnull String argument) {
        int index = arguments.indexOf(requireNonNull(anchor, "anchor"));
        if (index == -1) {
            return rawArgument(argument);
        }
        return rawArgumentAt(index + 1, argument);
    }

    @Nonnull
    @Override
    public GitProcessBuilder rawArgumentAt(int index, @Nonnull String argument) {
        arguments.add(index, requireNonNull(argument, "argument"));

        return self();
    }

    @Nonnull
    @Override
    public GitProcessBuilder rawArgumentBefore(@Nonnull String anchor, @Nonnull String argument) {
        int index = arguments.indexOf(requireNonNull(anchor, "anchor"));
        if (index == -1) {
            return rawArgument(argument);
        }
        return rawArgumentAt(index, argument);
    }

    @Nonnull
    @Override
    public GitRebaseBuilder rebase() {
        return new DefaultGitRebaseBuilder(this);
    }

    @Nonnull
    @Override
    public GitReceivePackBuilder receivePack() {
        return new DefaultGitReceivePackBuilder(this, workDir);
    }

    @Nonnull
    @Override
    public GitRemote remote() {
        return new DefaultGitRemote(this);
    }

    @Nonnull
    @Override
    public GitProcessBuilder removeEnvironment(@Nonnull String name) {
        environment.remove(requireNonNull(name, "name"));

        return self();
    }

    @Nonnull
    @Override
    public GitProcessBuilder throttled(ThrottledResource resource) {
        throttleResource = resource;

        return self();
    }

    @Nonnull
    @Override
    public GitRepackBuilder repack() {
        return new DefaultGitRepackBuilder(this);
    }

    @Nonnull
    @Override
    public GitResetBuilder reset() {
        return new DefaultGitResetBuilder(this);
    }

    @Nonnull
    @Override
    public GitRevertBuilder revert() {
        return new DefaultGitRevertBuilder(this);
    }

    @Nonnull
    @Override
    public GitRevListBuilder revList() {
        return new DefaultGitRevListBuilder(this);
    }

    @Nonnull
    @Override
    public GitStatusBuilder status() {
        return new DefaultGitStatusBuilder(this);
    }

    @Nonnull
    @Override
    public GitSymbolicRef symbolicRef() {
        return new DefaultGitSymbolicRef(this);
    }

    @Nonnull
    @Override
    public GitTag tag() {
        return new DefaultGitTag(this);
    }

    @Nonnull
    @Override
    public GitProcessBuilder treeish(@Nonnull String value) {
        //Unlike commitish, above, treeish _must not_ use nonBlank on the value, or otherwise trim it.
        //Tree values may have paths in them, so _callers_ are required to manage trimming explicitly.
        if (requireNonNull(value, "treeish").startsWith("-")) {
            throw new InvalidTreeishException(value);
        }
        //Use rawArgument instead of argument to avoid any incidental trimming
        rawArgument(value);

        return self();
    }

    @Nonnull
    @Override
    public GitUpdateRef updateRef() {
        return new DefaultGitUpdateRef(this);
    }

    @Nonnull
    @Override
    public GitUploadArchiveBuilder uploadArchive() {
        return new DefaultGitUploadArchiveBuilder(this, workDir);
    }

    @Nonnull
    @Override
    public GitUploadPackBuilder uploadPack() {
        return new DefaultGitUploadPackBuilder(this, workDir);
    }

    @Nonnull
    @Override
    public GitVersionBuilder version() {
        return new DefaultGitVersionBuilder(this);
    }

    @Nonnull
    @Override
    public GitProcessBuilder withConfiguration(@Nonnull String key) {
        configuration.put(notBlank(key, "key"), null);

        return self();
    }

    @Nonnull
    @Override
    public GitProcessBuilder withConfiguration(@Nonnull String key, boolean value) {
        configuration.put(notBlank(key, "key"), Boolean.toString(value));

        return self();
    }

    @Nonnull
    @Override
    public GitProcessBuilder withConfiguration(@Nonnull String key, long value) {
        configuration.put(notBlank(key, "key"), Long.toString(value));

        return self();
    }

    @Nonnull
    @Override
    public GitProcessBuilder withConfiguration(@Nonnull String key, @Nullable String value) {
        //If the provided value is null, convert it to "". This simplifies building the command line later,
        //allowing easy differentiation between calls to configure(String) and configure(String, String) so
        //that we can pass -c key or -c key= as appropriate
        configuration.put(notBlank(key, "key"), StringUtils.defaultString(value));

        return self();
    }

    @Nonnull
    @Override
    public GitProcessBuilder withDeadline(Instant value) {
        deadline = value;

        return self();
    }

    @Nonnull
    @Override
    public GitProcessBuilder withEnvironment(@Nonnull String name, @Nonnull String value) {
        environment.put(notBlank(name, "name"), notBlank(value, "value"));

        return self();
    }

    @Nonnull
    @Override
    public GitProcessBuilder withExecutionInterval(@Nonnull Duration value) {
        executionInterval = requireNonNull(value, "executionInterval");

        return self();
    }

    @Nonnull
    @Override
    public GitProcessBuilder withIdleInterval(@Nonnull Duration value) {
        idleInterval = requireNonNull(value, "idleInterval");

        return self();
    }

    @Nonnull
    @Override
    public GitProcessBuilder withTimeout(@Nonnull Duration duration) {
        return withExecutionInterval(duration)
                .withIdleInterval(duration);
    }

    @Nonnull
    @Override
    public GitProcessBuilder withTimeouts(@Nullable RpcGitTimeouts timeouts) {
        if (timeouts != null) {
            return withExecutionInterval(Duration.ofSeconds(timeouts.getExecution()))
                    .withIdleInterval(Duration.ofSeconds(timeouts.getIdle()));
        }
        return self();
    }

    @Nonnull
    @Override
    public GitProcessBuilder workDir(@Nonnull Path value) {
        workDir = existsAndIsDirectory(requireNonNull(value, "workDir"));

        return self();
    }

    private static Path existsAndIsDirectory(Path value) {
        if (Files.isDirectory(value)) {
            return value;
        }
        if (Files.exists(value)) {
            throw new IllegalArgumentException("[" + value.toAbsolutePath() + " is not a directory");
        }
        throw new IllegalArgumentException("[" + value.toAbsolutePath() + "] does not exist");
    }

    /**
     * Applies any {@link #alternates(Iterable) alternates} which were specified when assembling the builder.
     * <p>
     * If the <i>relative</i> paths to the alternate repositories fit in an environment variable, the preferred
     * approach is to pass them that way.
     * <p>
     * If {@link GitConstants#ENV_ALTERNATE_OBJECT_DIRS GIT_ALTERNATE_OBJECT_DIRECTORIES} has already been set,
     * the relative paths for alternate repositories are <i>appended</i>. If the resulting value is too long,
     * the existing environment variable is left unchanged and {@code info/alternates} is used instead.
     * <p>
     * If the assembled relative paths are too long to be set as an environment variable, a custom object directory
     * is created instead and each alternate is added to its {@code info/alternates} file as an absolute path.
     * <p>
     * If {@link GitConstants#ENV_OBJECT_DIR GIT_OBJECT_DIRECTORY} has been set, {@code info/alternates} is created
     * there, rather than creating a custom object directory. If {@code info/alternates} already exists in the
     * {@code GIT_OBJECT_DIRECTORY}, a {@link IllegalStateException} is thrown because alternates cannot be applied.
     * Alternates are <i>not</i> appended to the end of an existing {@code info/alternates} file because it is more
     * difficult to clean them up.
     *
     * @param environment       the effective environment, <i>which may be modified</i> to set up alternates
     * @param completionHandler the completion handler, which may be wrapped to clean up the custom object directory
     *                          if one is used to apply alternates
     * @return the new effective exit handler, which may be the provided handler or not
     * @throws IllegalStateException if alternates cannot be applied
     */
    private ProcessCompletionHandler applyAlternates(Map<String, String> environment,
                                                     ProcessCompletionHandler completionHandler) {
        alternates.remove(repository);
        if (repository == null || (alternates.isEmpty() && alternateObjectsDirs.isEmpty())) {
            return completionHandler;
        }

        String alternateObjectsDirs = createAlternatesEnvironmentVariable(environment.get(ENV_ALTERNATE_OBJECT_DIRS));
        if (alternateObjectsDirs.length() > processConfig.getEnvironmentVariableSize()) {
            //Alternates string is too long to be specified as an environment variable. To work around that,
            //we create a custom object directory with an alternates file
            String objectDir = environment.get(ENV_OBJECT_DIR);

            Path customObjectDir = createObjectsDirForAlternates(objectDir);
            environment.put(ENV_OBJECT_DIR, customObjectDir.toAbsolutePath().toString());

            completionHandler = new CleanupCompletionHandler(completionHandler,
                    customObjectDir, StringUtils.isNotBlank(objectDir));
        } else {
            //This may overwrite a previous value, which is okay; alternates from any previous value will be
            //included in the new value
            environment.put(ENV_ALTERNATE_OBJECT_DIRS, alternateObjectsDirs);
        }

        return completionHandler;
    }

    /**
     * Adds environment variables for any {@link #withConfiguration configuration} values that have been set.
     * Configuration is passed via the environment, rather than {@code -c} arguments, for a few reasons:
     * <ul>
     *     <li>Command lines are often shown to end users, and configuration may leak paths</li>
     *     <li>Command lines are logged, and configuration values like {@code core.hooksPath} make the log
     *     entries significantly harder to scan</li>
     *     <li>Profiling entries have a hard cap on their length, so including configuration values in the
     *     command line can end up truncating more useful arguments</li>
     * </ul>
     * If an app developer has exported {@link GitConstants#ENV_CONFIG_COUNT GIT_CONFIG_COUNT}, configuration
     * values from the builder are <i>appended</i> to the count and the environment variable is updated.
     *
     * @param environment the effective environment
     */
    private void applyConfiguration(Map<String, String> environment) {
        if (configuration.isEmpty()) {
            return;
        }

        //On the off chance an app developer tries to use the environment to pass configuration, this appends
        //our configuration to the end of the list. Otherwise, we'll write from 0
        MutableInt counter = new MutableInt(Integer.parseInt(environment.getOrDefault(ENV_CONFIG_COUNT, "0")));
        configuration.forEach((key, value) -> {
            int index = counter.getAndIncrement();

            //Both KEY and VALUE have to be defined explicitly or Git will die with an error. If no value was
            //set, we default it to "true"--which is how Git handles "-c key" (without an equal sign). If the
            //value is _empty_ (e.g. -c key=), Git handles that as an empty value (not "true")
            environment.put(ENV_CONFIG_KEY + index, key);
            environment.put(ENV_CONFIG_VALUE + index, value == null ? "true" : value);
        });
        environment.put(ENV_CONFIG_COUNT, counter.toString());
    }

    private void applyQuarantine(GitQuarantineEnvironment quarantineEnvironment, Map<String, String> environment) {
        quarantineEnvironment.asMap().forEach((key, value) -> {
            String collision = environment.put(key, value);
            if (!(collision == null || collision.equals(value))) {
                throw new IllegalStateException("bitbucket.git.quarantine.configuration.failed");
            }
        });
    }

    /**
     * Chooses the {@link ProcessCompletionHandler} to use.
     * <ol>
     *     <li>If the provided {@link ProcessStdioHandler} implements {@link ProcessCompletionHandler},
     *     it will be cast and returned</li>
     *     <li>If a specific {@link ProcessCompletionHandler} was set, it will be returned</li>
     *     <li>Otherwise, a new {@link GitProcessCompletionHandler} will be created and returned</li>
     * </ol>
     *
     * @param stdioHandler the I/O handler for the process being built
     * @return the completion handler to use for the process
     */
    private ProcessCompletionHandler chooseCompletionHandler(ProcessStdioHandler<?> stdioHandler) {
        if (stdioHandler instanceof ProcessCompletionHandler) {
            //If the provided ProcessStdioHandler is also a completion handler, that takes precedence
            //over any set completion handler
            return (ProcessCompletionHandler) stdioHandler;
        }

        //Otherwise, if a completion handler was specified use it, or fall back on a default
        return completionHandler == null ? new GitProcessCompletionHandler() : completionHandler;
    }

    /**
     * Assembles <i>relative</i> paths to all of the specified {@link #alternates} for use as an environment
     * variable.
     *
     * @param existing alternates specified via {@code GIT_ALTERNATE_OBJECT_DIRECTORIES}, which may be {@code null}
     *                 if alternates were only specified via the builder
     * @return an environment variable linking relative paths to all alternates
     */
    private String createAlternatesEnvironmentVariable(String existing) {
        //For each alternate, build up a relative path to its objects directory. Using a relative path allows
        //us to fit many more repositories into the environment variable
        Stream<String> stream = streamAlternateObjectDirs(false);
        if (StringUtils.isNotBlank(existing)) {
            //If there are any alternates specified via the environment variable, include them before
            //alternates specified using the builder
            stream = Stream.concat(Stream.of(existing), stream);
        }

        //Happily, the git folks used the normal, OS-specific path separator. : on Unix, ; on Windows
        return stream.collect(Collectors.joining(File.pathSeparator));
    }

    /**
     * Creates a temporary {@code objects} directory containing only an {@code info/alternates} file with absolute
     * paths to all of the specified {@link #alternates} and returns it.
     *
     * @param existing object directory specified via {@code GIT_OBJECT_DIRECTORY}, which may be {@code null}
     * @return a temporary {@code objects} directory with absolute paths to all alternates
     */
    private Path createObjectsDirForAlternates(String existing) {
        Path objDir = null;
        try {
            if (StringUtils.isBlank(existing)) {
                //Create a temporary objects directory with just an info/alternates file in it
                objDir = Files.createTempDirectory(storageLayout.getTempDir(), "git-obj-dir");
            } else {
                //Use the existing objects directory and create an alternates file in it
                objDir = Paths.get(existing);
            }

            Path infoAlternates = Files.createDirectories(objDir.resolve(PATH_INFO)).resolve(PATH_ALTERNATES);
            try (Writer writer = Files.newBufferedWriter(infoAlternates, StandardCharsets.UTF_8, StandardOpenOption.CREATE_NEW)) {
                writer.append(getObjectDir(repository, true)).append('\n');
                List<String> alternatePaths = streamAlternateObjectDirs(true)
                        .collect(toList());

                for (String alternate : alternatePaths) {
                    writer.append(alternate).append('\n');
                }

                writer.flush();
            }

            return objDir;
        } catch (IOException e) {
            //Only delete the object directory if we created it
            if (objDir != null && StringUtils.isBlank(existing)) {
                MoreFiles.deleteQuietly(objDir);
            }

            throw new IllegalStateException("bitbucket.git.alternate.configuration.failed", e);
        }
    }

    /**
     * Retrieves the path to the specified repository's {@code objects} directory, either absolute or relative to
     * the {@link #repository working repository}.
     *
     * @param repository the repository to retrieve the {@code objects} path for
     * @param absolute   {@code true} to retrieve an absolute path; otherwise, {@code false} to retrieve a path
     *                   relative to the {@link #repository working repository}
     * @return the path to the repository's {@code objects} directory
     */
    private String getObjectDir(Repository repository, boolean absolute) {
        Path repositoryDir = absolute ?
                repository.getPath().toAbsolutePath() :
                repository.getRelativePath(this.repository);

        return repositoryDir.resolve(PATH_OBJECTS).toString();
    }

    /**
     * Assembles the command and other arguments to produce a single unified list which comprises the complete
     * command line.
     *
     * @return the unified arguments comprising the complete command line
     */
    private List<String> mergeArguments() {
        List<String> allArguments = new ArrayList<>(arguments.size() + 1);
        allArguments.add(binary.getPath());
        if (command != null) {
            //The command, if one was specified explicitly goes after any configuration flags
            allArguments.add(command);
        }
        //Arguments supplied to the builder go after the command, since they should apply to it
        allArguments.addAll(arguments);

        return allArguments;
    }

    private String notBlank(String argument) {
        return notBlank(argument, "argument");
    }

    private String notBlank(String value, String name) {
        requireNonNull(value, name);

        value = value.trim();
        if (value.isEmpty()) {
            throw new IllegalArgumentException("A non-blank " +
                    StringUtils.defaultString(name, "value") + " is required");
        }

        return value;
    }

    private GitProcessBuilder self() {
        return this;
    }

    private Stream<String> streamAlternateObjectDirs(boolean absolutePath) {
        Stream<String> result = alternates.stream().flatMap(alternate -> streamAlternates(alternate, absolutePath));
        if (alternateObjectsDirs.isEmpty()) {
            return result;
        }
        if (absolutePath) {
            return Stream.concat(result, alternateObjectsDirs.stream()
                    .map(path -> path.toAbsolutePath().toString()));
        }
        Path repoDir = repository.getPath();
        return Stream.concat(result, alternateObjectsDirs.stream()
                .map(path -> repoDir.relativize(path).toString()));
    }

    private Stream<String> streamAlternates(@Nonnull Repository alternate, boolean absolutePath) {
        Stream<String> result = Stream.of(getObjectDir(alternate, absolutePath));
        String quarantinePath = quarantineRegistry.get(alternate)
                .map(GitQuarantineEnvironment::getQuarantinePath)
                .orElse(null);
        return quarantinePath == null ? result : Stream.concat(result, Stream.of(quarantinePath));
    }

    /**
     * A delegating {@link ProcessCompletionHandler} that deletes a directory when the process completes.
     */
    private static class CleanupCompletionHandler implements ProcessCompletionHandler {

        private final ProcessCompletionHandler delegate;
        private final Path toDelete;

        CleanupCompletionHandler(ProcessCompletionHandler delegate, Path directory, boolean onlyAlternates) {
            this.delegate = delegate;

            toDelete = onlyAlternates ? MoreFiles.resolve(directory, PATH_INFO, PATH_ALTERNATES) : directory;
        }

        @Override
        public void onComplete(@Nonnull ProcessSummary summary) {
            try {
                delegate.onComplete(summary);
            } finally {
                MoreFiles.deleteQuietly(toDelete);
            }
        }
    }
}
