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

import com.atlassian.bitbucket.commit.Commit;
import com.atlassian.bitbucket.commit.LastModifiedCallback;
import com.atlassian.bitbucket.commit.LastModifiedContext;
import com.atlassian.bitbucket.commit.LastModifiedSummary;
import com.atlassian.bitbucket.i18n.I18nService;
import com.atlassian.bitbucket.i18n.KeyedMessage;
import com.atlassian.bitbucket.io.LineInputHandler;
import com.atlassian.bitbucket.io.LineReader;
import com.atlassian.bitbucket.io.LineReaderOutputHandler;
import com.atlassian.bitbucket.repository.Repository;
import com.atlassian.bitbucket.scm.*;
import com.atlassian.bitbucket.scm.git.GitUtils;
import com.atlassian.bitbucket.scm.git.command.*;
import com.atlassian.stash.internal.scm.git.command.revlist.RevListOutputHandler;
import com.atlassian.stash.internal.scm.git.command.revlist.SingleCommitRevListOutputHandler;
import com.atlassian.utils.process.ProcessException;
import com.google.common.base.Throwables;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Iterables;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.annotation.Nonnull;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Future;
import java.util.concurrent.TimeoutException;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * A composite {@link GitCommand command} which uses {@code git ls-tree}, {@code git rev-list} and {@code git log}
 * to stream the last commit to modify files in a given path starting from a specified commit.
 * <p>
 * Note: Git 1.8 and 1.8.1 on Windows (not on Linux) have an issue where {@code %x00} ({@code NUL}) can't be used
 * in their format strings. That means {@code git ls-tree} and {@code git log} <i>cannot</i> use {@code -z}. The
 * {@code log} command can't use it because the {@link CommitReader} format must use newlines. If part of the output
 * uses newlines, it all should. {@code ls-tree}, then, can't use {@code -z} <i>because {@code log} can't</i>. The
 * results of reading {@code -z} paths in UTF-8 mode and using {@link GitUtils#unescapeQuoted} to parse quoted paths
 * <i>are not always the same</i>. That means using different approaches for the {@code ls-tree} and the {@code log}
 * could result in some paths not matching, despite actually being the same path, because of how they were parsed.
 *
 * @since 4.6
 */
//TODO in 5.0: Revisit -z if the minimum Git version is raised to 1.8.2+.
public class LastModifiedCommand extends SimpleGitCommand<Void> {

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

    private final GitCommandBuilderFactory builderFactory;
    private final LastModifiedCallback callback;
    private final String commitId;
    private final LastModifiedContext context;
    private final I18nService i18nService;
    private final int maxMessageLength;
    private final String path;
    private final Repository repository;

    private boolean pendingOnEnd;

    public LastModifiedCommand(ExecutorService executorService, GitCommandBuilderFactory builderFactory,
                               I18nService i18nService, Repository repository,
                               LastModifiedCommandParameters parameters, LastModifiedCallback callback) {
        super(executorService);

        this.builderFactory = builderFactory;
        this.callback = callback;
        this.i18nService = i18nService;
        this.repository = repository;

        commitId = parameters.getCommitId();
        context = parameters.toContext();                       //Preserve the path as it was specified
        maxMessageLength = parameters.getMaxMessageLength();
        path = StringUtils.defaultString(parameters.getPath()); //Normalize the path for the commands we run
    }

    @Override
    public Void call() {
        try {
            stream();
        } catch (IOException e) {
            KeyedMessage message = path.isEmpty() ?
                    i18nService.createKeyedMessage("bitbucket.git.lastmodified.streamfailed.forroot", commitId) :
                    i18nService.createKeyedMessage("bitbucket.git.lastmodified.streamfailed.forpath", commitId, path);

            throw new CommandFailedException(message, e);
        }

        return null;
    }

    /**
     * Constructs a {@code git rev-list} command to retrieve the last commit to modify the specified
     * {@code path}.
     *
     * @param path the path to search commits for
     * @return a command to retrieve the last commit to modify the specified path
     */
    private GitCommand<Commit> buildCommitCommand(String path) {
        RevListOutputHandler<Commit> outputHandler = new SingleCommitRevListOutputHandler(
                i18nService, repository, commitId, path, true, false, maxMessageLength);

        return builderFactory.builder(repository).revList()
                .file(path)
                .format(outputHandler.getFormat())
                .limit(1)
                .rev(commitId)
                .build(outputHandler);
    }

    /**
     * {@link #get Gets} the result of the provided {@code Future}, logging any exceptions thrown.
     *
     * @param futureCommit the future {@link LastModifiedSummary#getLatestCommit latest commit}
     * @return the latest commit, which may be {@code null} if an exception was thrown
     */
    private Commit getLatestCommit(Future<Commit> futureCommit) {
        try {
            return get(futureCommit, 120L);
        } catch (ExecutionException e) {
            log.warn("{}: Could not retrieve latest commit at {}:{}", repository, commitId, path, e.getCause());
        } catch (InterruptedException e) {
            futureCommit.cancel(true);

            log.debug("{}: Interrupted while retrieving latest commit to {}:{}", repository, commitId, path);
            Thread.currentThread().interrupt();
        } catch (TimeoutException e) {
            futureCommit.cancel(true);

            log.debug("{}: Timed out retrieving latest commit to {}:{}", repository, commitId, path);
        }

        return null;
    }

    /**
     * Lists the files in the requested path at the specified commit. If the specified commit does not exist,
     * or does not contain the requested path, an exception is thrown.
     *
     * @return a list of <i>zero or more</i> files in the requested path
     * @throws com.atlassian.bitbucket.commit.NoSuchCommitException if the specified commit does not exist
     * @throws com.atlassian.bitbucket.content.NoSuchPathException if the requested path does not exist at the
     *                                                             specified commit
     */
    private List<FilePath> listFiles() {
        return configureAndCall(builderFactory.builder(repository).lsTree()
                .exitHandler(new GitCommandExitHandler(i18nService, repository) {

                    @Override
                    protected void evaluateStdErr(String stdErr, String command) {
                        //This replaces the handling for this pattern in the GitCommandExitHandler. That throws a
                        //NoSuchObjectException and we want a NoSuchCommitException
                        Matcher matcher = PATTERN_NOT_FOUND.matcher(stdErr);
                        if (matcher.matches()) {
                            if (StringUtils.isEmpty(matcher.group(2))) {
                                throw newNoSuchCommitException(matcher.group(1));
                            }
                            throw newNoSuchPathException(matcher.group(1), matcher.group(2));
                        }

                        super.evaluateStdErr(stdErr, command);
                    }
                })
                .tree(commitId, path)
                .build(new BlobLsTreeOutputHandler(path)));
    }

    /**
     * Searches commits which affect <i>any</i> of the listed files to determine the last commit to modify each.
     * The resulting {@code (file, commit)} pairs are streamed to the {@link LastModifiedCallback callback} as
     * they are found.
     * <p>
     * Note that, while this method streams to the callback, it does not throw {@code IOException}s. Streaming
     * happens in {@link LastModifiedLogOutputHandler}, so any {@code IOException}s trigger the {@code git log}
     * command to fail and are thrown as some form of {@code RuntimeException}.
     *
     * @param files files to search for commits affecting
     * @return the result of searching and streaming commits, which may be {@code null} in certain edge cases
     *         due to how external processes are handled
     */
    private CommandResult searchCommits(List<FilePath> files) {
        RevListOutputHandler<CommandResult> outputHandler = new LastModifiedLogOutputHandler(
                repository, callback, maxMessageLength, files);

        //--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.
        GitScmCommandBuilder builder = builderFactory.builder(repository).command("log")
                .argument("-c")
                .argument("--format=" + outputHandler.getFormat())
                .argument("--name-status")
                .argument("--stdin")
                .argument("--topo-order");
        //Restrict the output to only commits that touch one of the paths we care about. This only
        //optimizes the processing when the "files" list doesn't include directories
        builder.inputHandler(new LineInputHandler(
                ImmutableList.<String>builder()
                        .add(commitId)
                        .add("--")
                        .addAll(Iterables.transform(files, FilePath::getAbsolutePath))
                        .build()));

        //Block here retrieving history for individual files
        return configureAndCall(builder.exitHandler(new GitCommandExitHandler(i18nService, repository) {

            @Override
            public void onCancel(@Nonnull String command, int exitCode, String stdErr, Throwable thrown) {
                if (thrown != null) {
                    //noinspection ThrowableResultOfMethodCallIgnored
                    Throwable rootCause = Throwables.getRootCause(thrown);
                    if (rootCause instanceof IOException && "Broken pipe".equals(rootCause.getMessage())) {
                        return;
                    }
                }

                super.onCancel(command, exitCode, stdErr, thrown);
            }
        }).build(outputHandler));
    }

    /**
     * Lists the files in the requested path at the specified commit, and then streams their last modification.
     * If listing files fails, the {@link LastModifiedCallback callback} is <i>never invoked</i> and, instead,
     * its exception is propagated to the {@link #call() caller}.
     *
     * @throws IOException if the callback throws
     */
    private void stream() throws IOException {
        //Run an ls-tree to determine which files are in the directory, if any. Running this command also verifies
        //the commit exists, and the path exists in that commit. If either of those are not true, running the list
        //will fail with the appropriate exception.
        List<FilePath> files = listFiles();

        //If we make it here, whether there are any files or not, kick off a git rev-list to the newest commit
        //for the requested path and let that run asynchronously while we search for the newest commit against
        //individual files within the requested path. Retrieving the newest top-level commit should be faster,
        //so the data should be waiting by the time we're done streaming individual file commits and need it.
        Future<Commit> futureCommit = buildCommitCommand(path).start();

        callback.onStart(context);
        pendingOnEnd = true; //If we call onStart, we need to call onEnd

        try {
            summarize(futureCommit, streamFiles(files));
        } catch (Error | IOException | RuntimeException e) {
            try {
                //If streaming files failed, for some reason, we may not have tried to summarize the command.
                //This summarize(...) catches that case. If onEnd has already been called, pendingOnEnd will
                //be false and summarize will return without calling onEnd again
                summarize(futureCommit, CommandResult.FAILED);
            } catch (Throwable suppressed) {
                //However, we don't want to supersede the exception that made streaming fail. So if summarizing
                //throws an exception, we suppress it in favor of the primary exception
                e.addSuppressed(suppressed);
            }

            throw e;
        }
    }

    /**
     * Streams file modifications to {@link LastModifiedCallback#onFile(String, Commit)}, returning the result.
     *
     * @param files files to stream modifications for
     * @return the result of streaming modifications
     * @throws IOException if the callback throws
     */
    private CommandResult streamFiles(List<FilePath> files) throws IOException {
        if (files == null || files.isEmpty()) {
            //If there are no _files_ in the directory, skip the git log command. The directory can't be
            //empty, since Git won't version such directories, but if it only contains other directories
            //then the returned list will be empty.
            return CommandResult.SUCCEEDED;
        }
        if (files.size() == 1) {
            //If the directory contains only a single file, prefer a single git rev-list against an explicit
            //path over running an unbounded git log and parsing paths from the output
            FilePath file = files.get(0);

            Commit commit = buildCommitCommand(file.getAbsolutePath()).call();
            if (commit != null) {
                //It might also seem like this would be the newest commit at request.getAbsolutePath(), essentially
                //meaning the commit(...) command we started earlier was unnecessary, but unfortunately we
                //can't be assured of that. For example, it's possible there were two files in the directory
                //and the most recent commit to the directory removes one. In such a case, there _is_ a newer
                //commit to the request path than this commit
                callback.onFile(file.path, commit);
            }

            return CommandResult.SUCCEEDED;
        }

        //If the directory contains multiple files, rather than forking off N git rev-list commands, use
        //a single git log with --name-status and parse paths. Once we've found a commit touching each of
        //the files in the directory, cancel the log command and return the results.
        //
        //There's likely a breakover point where N git rev-list's is actually "faster" still than doing
        //this, but that breakover point is unlikely to be a constant number. As a result, for 2 or more
        //files, we just always run git log.
        return searchCommits(files);
    }

    /**
     * Summarizes the command and invokes {@link LastModifiedCallback#onEnd(LastModifiedSummary)}. If {@code onEnd}
     * has already been invoked, calling this method does nothing.
     *
     * @param futureCommit the future {@link LastModifiedSummary#getLatestCommit() latest commit}
     * @param result       the result of streaming file modifications
     * @throws IOException if the callback throws
     */
    private void summarize(Future<Commit> futureCommit, CommandResult result) throws IOException {
        if (pendingOnEnd) {
            pendingOnEnd = false; //onEnd should be called exactly once, if onStart was called

            callback.onEnd(new LastModifiedSummary.Builder(result == null ? CommandResult.SUCCEEDED : result)
                    .latestCommit(getLatestCommit(futureCommit))
                    .build());
        }
    }

    private static class BlobLsTreeOutputHandler
            extends LineReaderOutputHandler
            implements CommandOutputHandler<List<FilePath>> {

        //Only match blobs (files). This matches Bitbucket Cloud, which doesn't provide the last modification
        //for directories and submodules. It also makes the subsequent git log substantially faster
        private static final Pattern PATTERN = Pattern.compile("\\d+ blob [0-9a-fA-F]{40}\\t(.*)");

        private final List<FilePath> files = new ArrayList<>();
        private final String path;

        BlobLsTreeOutputHandler(String path) {
            super(StandardCharsets.UTF_8);

            this.path = path;
        }

        @Override
        public List<FilePath> getOutput() {
            return files;
        }

        @Override
        protected void processReader(LineReader reader) throws IOException {
            String line;
            while ((line = resetWatchdogAndReadLine(reader)) != null) {
                Matcher matcher = PATTERN.matcher(line);
                if (matcher.matches()) {
                    String file = GitUtils.unescapeQuoted(matcher.group(1));

                    files.add(new FilePath(path, file));
                }
            }
        }
    }

    private static class FilePath {

        private final String absolutePath;
        private final String path;

        FilePath(String baseDir, String path) {
            this.path = path;

            absolutePath = StringUtils.isEmpty(baseDir) ? path : baseDir + "/" + path;
        }

        String getAbsolutePath() {
            return absolutePath;
        }

        boolean matches(String path) {
            return absolutePath.equals(path);
        }
    }

    private static class LastModifiedLogOutputHandler
            extends LineReaderOutputHandler
            implements RevListOutputHandler<CommandResult>, CommandSummaryHandler {

        private static final String FORMAT_START = "%x03START%x04%ncommit %H%n"; //commit %H is to mimic rev-list
        private static final String OBJECT_START = "\u0003START\u0004";

        private final LastModifiedCallback callback;
        private final CommitReader commitReader;
        private final List<FilePath> files;
        private final Repository repository;

        private CommandResult result;

        LastModifiedLogOutputHandler(Repository repository, LastModifiedCallback callback,
                                     int maxMessageLength, List<FilePath> files) {
            super(StandardCharsets.UTF_8);

            this.callback = callback;
            this.files = files;
            this.repository = repository;

            commitReader = new CommitReader(new CommitReaderSettings.Builder()
                    .maxMessageLength(maxMessageLength)
                    .messageMode(CommitMessageMode.SUBJECT)
                    .repository(repository)
                    .withMessages(true)
                    .build()) {

                @Override
                protected void ping() {
                    resetWatchdog();
                }
            };
        }

        @Nonnull
        @Override
        public String getFormat() {
            return FORMAT_START + commitReader.getFormat();
        }

        @Override
        public void onComplete(@Nonnull CommandSummary summary) throws ProcessException {
            result = summary.getResult() == CommandResult.FAILED ?
                    CommandResult.FAILED :
                    CommandResult.SUCCEEDED; //If we canceled the process, the overall operation succeeded
        }

        @Override
        public CommandResult getOutput() {
            return result;
        }

        @Override
        protected void processReader(LineReader reader) throws IOException {
            String line = resetWatchdogAndReadLine(reader);
            while (OBJECT_START.equals(line)) {
                Commit commit = commitReader.readCommit(reader);
                if (commit == null) {
                    //That's not expected; we should have read a commit if we read our START marker...
                    break;
                }

                //After the commit there should be an empty line. If it's not empty, push it back on the
                //reader and try to parse it as a status and path
                line = resetWatchdogAndReadLine(reader);
                if (!line.isEmpty()) {
                    reader.pushLineBack(line);
                }

                //Then the diff-tree, indicating which files were modified in this commit. We use this to decide
                //if _this_ was the newest commit to modify any paths
                for (line = resetWatchdogAndReadLine(reader);
                     line != null && !line.equals(OBJECT_START);
                     line = resetWatchdogAndReadLine(reader)) {
                    int split = line.indexOf('\t');
                    String status = line.substring(0, split);
                    String path = GitUtils.unescapeQuoted(line.substring(split + 1));

                    if ("D".equals(status)) {
                        //Follow my logic here. git ls-tree only returns files that are _in the tree_. That means if
                        //we see a deletion for a given path, we can't possibly be interested in that change. Only
                        //changes that add or modify files could possibly influence the tree's _current_ contents
                        continue;
                    }

                    Iterator<FilePath> iterator = files.iterator();
                    while (iterator.hasNext()) {
                        FilePath file = iterator.next();
                        if (file.matches(path)) {
                            //We've found the most recent commit to modify this file. No further commits modifying
                            //this file should be considered, so we remove it from the list
                            iterator.remove();

                            callback.onFile(file.path, commit);
                        }
                    }

                    if (files.isEmpty()) {
                        //If we've found all the files, we're done traversing commits
                        cancelProcess();

                        break;
                    }
                }
            }

            //If we make it here, at least one path never matched. That's strange. And means we did an enormous
            //amount of work for no benefit!
            log.debug("{}: After streaming all commits, modifications for {} files were not found",
                    repository, files.size());
        }
    }
}
