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

import com.atlassian.bitbucket.i18n.I18nService;
import com.atlassian.bitbucket.repository.Repository;
import com.atlassian.bitbucket.scm.*;
import com.atlassian.bitbucket.scm.git.command.GitCommand;
import com.atlassian.bitbucket.scm.git.command.GitCommandExitHandler;
import com.atlassian.bitbucket.scm.git.command.GitScmCommandBuilder;
import com.atlassian.bitbucket.scm.git.command.add.GitAddBuilder;
import com.atlassian.bitbucket.scm.git.command.blame.GitBlame;
import com.atlassian.bitbucket.scm.git.command.branch.GitBranch;
import com.atlassian.bitbucket.scm.git.command.catfile.GitCatFile;
import com.atlassian.bitbucket.scm.git.command.checkrefformat.GitCheckRefFormat;
import com.atlassian.bitbucket.scm.git.command.clone.GitClone;
import com.atlassian.bitbucket.scm.git.command.commit.GitCommitBuilder;
import com.atlassian.bitbucket.scm.git.command.config.GitConfig;
import com.atlassian.bitbucket.scm.git.command.diff.GitDiffBuilder;
import com.atlassian.bitbucket.scm.git.command.diff.GitDiffTreeBuilder;
import com.atlassian.bitbucket.scm.git.command.fetch.GitFetchBuilder;
import com.atlassian.bitbucket.scm.git.command.foreachref.GitForEachRefBuilder;
import com.atlassian.bitbucket.scm.git.command.gc.GitGcBuilder;
import com.atlassian.bitbucket.scm.git.command.init.GitInitBuilder;
import com.atlassian.bitbucket.scm.git.command.log.GitLogBuilder;
import com.atlassian.bitbucket.scm.git.command.lsfiles.GitLsFilesBuilder;
import com.atlassian.bitbucket.scm.git.command.lstree.GitLsTreeBuilder;
import com.atlassian.bitbucket.scm.git.command.merge.GitMerge;
import com.atlassian.bitbucket.scm.git.command.merge.GitMergeBase;
import com.atlassian.bitbucket.scm.git.command.notes.GitNotes;
import com.atlassian.bitbucket.scm.git.command.packobjects.GitPackObjectsBuilder;
import com.atlassian.bitbucket.scm.git.command.prunepacked.GitPrunePackedBuilder;
import com.atlassian.bitbucket.scm.git.command.push.GitPushBuilder;
import com.atlassian.bitbucket.scm.git.command.remote.GitRemote;
import com.atlassian.bitbucket.scm.git.command.reset.GitResetBuilder;
import com.atlassian.bitbucket.scm.git.command.revlist.GitRevListBuilder;
import com.atlassian.bitbucket.scm.git.command.rm.GitRmBuilder;
import com.atlassian.bitbucket.scm.git.command.status.GitStatusBuilder;
import com.atlassian.bitbucket.scm.git.command.symbolicref.GitSymbolicRef;
import com.atlassian.bitbucket.scm.git.command.tag.GitTag;
import com.atlassian.bitbucket.scm.git.command.updateref.GitUpdateRef;
import com.atlassian.bitbucket.user.ApplicationUser;
import com.atlassian.bitbucket.user.Person;
import com.atlassian.stash.internal.scm.git.command.GenericGitCommand;
import com.atlassian.stash.internal.scm.git.command.add.DefaultGitAddBuilder;
import com.atlassian.stash.internal.scm.git.command.blame.DefaultGitBlame;
import com.atlassian.stash.internal.scm.git.command.branch.DefaultGitBranch;
import com.atlassian.stash.internal.scm.git.command.catfile.DefaultGitCatFile;
import com.atlassian.stash.internal.scm.git.command.checkrefformat.DefaultGitCheckRefFormat;
import com.atlassian.stash.internal.scm.git.command.clone.DefaultGitClone;
import com.atlassian.stash.internal.scm.git.command.commit.DefaultGitCommitBuilder;
import com.atlassian.stash.internal.scm.git.command.config.DefaultGitConfig;
import com.atlassian.stash.internal.scm.git.command.diff.DefaultGitDiffBuilder;
import com.atlassian.stash.internal.scm.git.command.diff.DefaultGitDiffTreeBuilder;
import com.atlassian.stash.internal.scm.git.command.fetch.DefaultGitFetchBuilder;
import com.atlassian.stash.internal.scm.git.command.foreachref.DefaultGitForEachRefBuilder;
import com.atlassian.stash.internal.scm.git.command.gc.DefaultGitGcBuilder;
import com.atlassian.stash.internal.scm.git.command.init.DefaultGitInitBuilder;
import com.atlassian.stash.internal.scm.git.command.log.DefaultGitLogBuilder;
import com.atlassian.stash.internal.scm.git.command.lsfiles.DefaultGitLsFilesBuilder;
import com.atlassian.stash.internal.scm.git.command.lstree.DefaultGitLsTreeBuilder;
import com.atlassian.stash.internal.scm.git.command.merge.DefaultGitMerge;
import com.atlassian.stash.internal.scm.git.command.merge.DefaultGitMergeBase;
import com.atlassian.stash.internal.scm.git.command.notes.DefaultGitNotes;
import com.atlassian.stash.internal.scm.git.command.packobjects.DefaultGitPackObjectsBuilder;
import com.atlassian.stash.internal.scm.git.command.prunepacked.DefaultGitPrunePackedBuilder;
import com.atlassian.stash.internal.scm.git.command.push.DefaultGitPushBuilder;
import com.atlassian.stash.internal.scm.git.command.remote.DefaultGitRemote;
import com.atlassian.stash.internal.scm.git.command.reset.DefaultGitResetBuilder;
import com.atlassian.stash.internal.scm.git.command.revlist.DefaultGitRevListBuilder;
import com.atlassian.stash.internal.scm.git.command.rm.DefaultGitRmBuilder;
import com.atlassian.stash.internal.scm.git.command.status.DefaultGitStatusBuilder;
import com.atlassian.stash.internal.scm.git.command.symbolicref.DefaultGitSymbolicRef;
import com.atlassian.stash.internal.scm.git.command.tag.DefaultGitTag;
import com.atlassian.stash.internal.scm.git.command.updateref.DefaultGitUpdateRef;
import org.apache.commons.io.FileUtils;
import org.apache.commons.lang3.StringUtils;

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.util.*;
import java.util.stream.Collectors;

import static com.atlassian.stash.internal.scm.git.InternalGitConstants.*;
import static com.google.common.base.Preconditions.checkNotNull;

/**
 * @since 1.1
 */
@NotThreadSafe
public class DefaultGitScmCommandBuilder
        extends AbstractScmCommandBuilder<GitScmCommandBuilder>
        implements GitScmCommandBuilder {

    private final Set<Repository> alternates;
    private final InternalGitScmConfig config;
    private final Map<String, String> configuration;
    private final Repository repository;

    private boolean hasAuthor;
    private boolean hasCommitter;

    public DefaultGitScmCommandBuilder(@Nonnull I18nService i18nService, @Nonnull InternalGitScmConfig config,
                                       Repository repository) {
        super(i18nService, checkNotNull(config, "config").getBinary(),
                repository == null ? null : config.getRepositoryDir(repository));

        this.alternates = new HashSet<>();
        this.config = config;
        this.repository = repository;

        configuration = new LinkedHashMap<>();
    }

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

    @Nonnull
    @Override
    public GitScmCommandBuilder alternates(@Nonnull Iterable<Repository> values) {
        for (Repository alternate : values) {
            alternates.add(alternate);
        }

        return self();
    }

    @Nonnull
    @Override
    public GitScmCommandBuilder 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 GitScmCommandBuilder author(@Nonnull Person author) {
        // Because we're using name instead of displayName, and it means something different, it will be easy for
        // a real ApplicationUser to slip through accidentally.
        if (author instanceof ApplicationUser) {
            return author((ApplicationUser) author);
        }
        return author(author.getName(), author.getEmailAddress());
    }

    @Nonnull
    @Override
    public GitScmCommandBuilder author(@Nonnull ApplicationUser author) {
        return author(author.getDisplayName(), author.getEmailAddress());
    }

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

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

    @Nonnull
    @Override
    public <T> GitCommand<T> build(@Nonnull CommandOutputHandler<T> outputHandler) {
        List<String> effectiveArguments = new ArrayList<>(arguments);
        String effectiveCommand = getEffectiveCommand(effectiveArguments);
        Map<String, String> effectiveEnvironment = new HashMap<>(environment);

        //Configure alternates if specified, which may return a special CommandExitHandler to ensure alternates
        //are cleaned up when the command completes
        CommandExitHandler effectiveExitHandler = applyAlternates(effectiveCommand, effectiveEnvironment,
                exitHandler == null ? createExitHandler() : exitHandler);

        return new GenericGitCommand<>(mergeArguments(effectiveCommand, effectiveArguments), effectiveEnvironment,
                workingDirectory, effectiveExitHandler, inputHandler, outputHandler, errorHandler);
    }

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

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

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

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

    @Nonnull
    @Override
    public GitScmCommandBuilder 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 GitScmCommandBuilder committer(@Nonnull ApplicationUser value) {
        committer(value.getDisplayName(), value.getEmailAddress());

        return self();
    }

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

    @Nonnull
    @Override
    public GitScmCommandBuilder defaultExitHandler() {
        return exitHandler(createExitHandler());
    }

    @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);
    }

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

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

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

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

    @Nonnull
    @Override
    public GitLsFilesBuilder lsFiles() {
        return new DefaultGitLsFilesBuilder(this);
    }

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

    @Nonnull
    @Override
    public GitMerge merge() {
        return new DefaultGitMerge(this, config, i18nService, repository);
    }

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

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

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

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

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

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

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

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

    @Nonnull
    @Override
    public GitRmBuilder rm() {
        return new DefaultGitRmBuilder(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, i18nService, repository);
    }

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

    @Nonnull
    @Override
    public <T> Command<T> version(@Nonnull CommandOutputHandler<T> outputHandler) {
        return command("version").build(outputHandler);
    }

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

        return self();
    }

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

        return self();
    }

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

        return self();
    }

    @Nonnull
    @Override
    public GitScmCommandBuilder 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
    protected GitCommandExitHandler createExitHandler() {
        return new GitCommandExitHandler(i18nService, repository);
    }

    @Nonnull
    @Override
    protected DefaultGitScmCommandBuilder self() {
        return this;
    }

    /**
     * Applies any {@link #alternates(Iterable) alternates} which were specified when assembling the builder.
     * <p>
     * If the environment variable has not already been set, the preferred approach here is to assemble the
     * <i>relative</i> paths to the alternate repositories and then set it as an environment variable.
     * <p>
     * If the environment variable has already been used, or 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 the environment variables for both {@link InternalGitConstants#ENV_ALTERNATE_OBJECT_DIRS alternates}
     * and {@link InternalGitConstants#ENV_OBJECT_DIR object} directories have been set, or if the specified
     * alternates are too long for an environment variable and the object directories environment variable is
     * already set, a {@link CommandSetupException} is thrown because the alternates cannot be applied.
     *
     * @param command     the effective command, used for error messaging
     * @param environment the effective environment, <i>which may be modified</i> to set up alternates
     * @param exitHandler the effective exit 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 CommandSetupException if alternates cannot be applied
     */
    private CommandExitHandler applyAlternates(String command, Map<String, String> environment,
                                               CommandExitHandler exitHandler) {
        alternates.remove(repository);
        if (repository == null || alternates.isEmpty()) {
            return exitHandler;
        }

        String alternateObjectsDirs = createAlternatesEnvironmentVariable();
        boolean alternatesTooLong = alternateObjectsDirs.length() > config.getEnvironmentVariableSize();
        boolean altObjectDirsDefined = StringUtils.isNotBlank(environment.get(ENV_ALTERNATE_OBJECT_DIRS));
        boolean objectDirDefined = StringUtils.isNotBlank(environment.get(ENV_OBJECT_DIR));
        if (objectDirDefined && (altObjectDirsDefined || alternatesTooLong)) {
            //If an object dir has already been set:
            //* If the alternate object dirs environment variable has also been set, we can't apply alternates
            //  because both places we might put them have already been taken
            //* If the environment variable is free, but the specified set of alternates is too long to be set
            //  as an environment variable, we can't apply alternates because the only way we can is by creating
            //  an interim object dir and using an alternates file
            throw new CommandSetupException(
                    i18nService.createKeyedMessage("bitbucket.git.alternate.configuration.failed", command));
        }

        if (altObjectDirsDefined || alternatesTooLong) {
            //Alternates string is too long for environment or ENV_ALTERNATE_OBJECTS_DIRS is already defined.
            //To work around that, we create a custom object directory with an alternates file
            File customObjectDir = createObjectsDirForAlternates();
            environment.put(ENV_OBJECT_DIR, customObjectDir.getAbsolutePath());
            exitHandler = new CleanupExitHandler(exitHandler, customObjectDir);
        } else {
            environment.put(ENV_ALTERNATE_OBJECT_DIRS, alternateObjectsDirs);
        }

        return exitHandler;
    }

    /**
     * Assembles <i>relative</i> paths to all of the specified {@link #alternates} for use as an environment
     * variable.
     *
     * @return an environment variable linking relative paths to all alternates
     */
    private String createAlternatesEnvironmentVariable() {
        return alternates.stream()
                //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
                .map(alternate -> getObjectDir(alternate, false))
                //Happily, the git folks used the normal, OS-specific path separator. : on Unix, ; on Windows
                .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.
     *
     * @return a temporary {@code objects} directory with absolute paths to all alternates
     */
    private File createObjectsDirForAlternates() {
        Path objDir = null;
        try {
            //Create a temporary directory to use as the objects directory, with just a info/alternates file in it
            objDir = Files.createTempDirectory(config.getTempDir().toPath(), "git-obj-dir");

            Path infoDir = objDir.resolve(PATH_INFO);
            Files.createDirectory(infoDir);

            try (Writer writer = Files.newBufferedWriter(infoDir.resolve(PATH_ALTERNATES), StandardCharsets.UTF_8)) {
                writer.append(getObjectDir(repository, true)).append('\n');
                for (Repository alternate : alternates) {
                    writer.append(getObjectDir(alternate, true)).append('\n');
                }
                writer.flush();
            }

            return objDir.toFile();
        } catch (IOException e) {
            if (objDir != null) {
                FileUtils.deleteQuietly(objDir.toFile());
            }

            throw new CommandSetupException(
                    i18nService.createKeyedMessage("bitbucket.git.alternate.configuration.failed", command), e);
        }
    }

    /**
     * Chooses the effective command to run. If an explicit {@link #command(String) command} was specified, it will
     * be used. Otherwise, the first {@link #argument(String) argument} is used as the command <i>and removed from
     * the provided {@code arguments} list</i>. If there are no arguments, an exception is thrown.
     * <p>
     * <b>Note</b>: The {@code arguments} name <i>intentionally</i> shadows the class-level field, which should not
     * be used or modified by this method.
     *
     * @param arguments the arguments provided to the builder, <i>which may be modified</i> if no explicit
     *                  {@link #command(String) command} was specified
     * @return the effective command
     * @throws IllegalStateException if no {@link #command(String) command} was specified and no
     *                               {@link #argument(String) arguments} have been provided
     */
    private String getEffectiveCommand(List<String> arguments) {
        if (command == null) {
            if (arguments.isEmpty()) {
                throw new IllegalStateException("No command was provided to run");
            } else {
                //If no explicit command was provided, assume it's the first argument. That's consistent with how git's
                //built-ins work.
                return arguments.remove(0);
            }
        }

        return command;
    }

    /**
     * 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) {
        String repoDir = absolute
                ? config.getRepositoryDir(repository).getAbsolutePath()
                : config.getRelativePath(repository, this.repository);
        return repoDir + File.separator + PATH_OBJECTS;
    }

    /**
     * Assembles the binary, command, and any configuration flags and other arguments to produce a single unified
     * list which comprises the complete command line.
     * <p>
     * <b>Note</b>: The {@code command} and {@code arguments} names <i>intentionally</i> shadow the class-level
     * fields. The contents of the two may not be the same, and the class-level values should not be used here.
     *
     * @param command   the command to run
     * @param arguments command line arguments provided to the builder
     * @return the unified arguments comprising the complete command line
     */
    private List<String> mergeArguments(String command, List<String> arguments) {
        //Attempt to replace combinations like "git http-backend" with "git-http-backend", if core binaries are
        //available, to reduce the fork tree depth for key git processes
        List<String> coreBinary = config.getCoreBinary(command);

        List<String> allArguments = new ArrayList<>(coreBinary.size() + arguments.size() + (configuration.size() * 2));
        //Binary is always first
        allArguments.add(coreBinary.remove(0)); //Removed here to simplify adding the command later
        //Configuration flags go _before_ the command
        configuration.forEach((key, value) -> {
            allArguments.add("-c");
            allArguments.add(value == null ? key : key + "=" + value);
        });
        //The command goes after any configuration flags. If a core binary, like git-http-backend, was found,
        //coreBinary will be empty here.
        allArguments.addAll(coreBinary);
        //Lastly, and command line arguments supplied to the builder go after the command, since they should
        //apply to it
        allArguments.addAll(arguments);

        return allArguments;
    }

    /**
     * {@code CommandExitHandler} that deletes a directory when the command is canceled or finishes
     */
    private static class CleanupExitHandler implements CommandExitHandler {

        private final CommandExitHandler delegate;
        private final File directory;

        private CleanupExitHandler(CommandExitHandler delegate, File directory) {
            this.delegate = delegate;
            this.directory = directory;
        }

        @Override
        public void onCancel(@Nonnull String command, int exitCode, String stdErr, Throwable thrown) {
            try {
                delegate.onCancel(command, exitCode, stdErr, thrown);
            } finally {
                cleanup();
            }
        }

        @Override
        public void onExit(@Nonnull String command, int exitCode, String stdErr, Throwable thrown) {
            try {
                delegate.onExit(command, exitCode, stdErr, thrown);
            } finally {
                cleanup();
            }
        }

        private void cleanup() {
            FileUtils.deleteQuietly(directory);
        }
    }
}
