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

import com.atlassian.bitbucket.mesh.git.GitAgent;
import com.atlassian.bitbucket.mesh.git.GitRefPattern;
import com.atlassian.bitbucket.mesh.git.GitTreeNode;
import com.atlassian.bitbucket.mesh.git.Reflog;
import com.atlassian.bitbucket.mesh.git.builder.GitProcess;
import com.atlassian.bitbucket.mesh.git.builder.GitProcessBuilderFactory;
import com.atlassian.bitbucket.mesh.git.builder.catfile.AbstractBatchCatFileStdioHandler;
import com.atlassian.bitbucket.mesh.git.builder.catfile.BatchHeader;
import com.atlassian.bitbucket.mesh.git.builder.checkout.GitCheckoutCreateBranchMode;
import com.atlassian.bitbucket.mesh.git.builder.countobjects.RepositorySizeCountObjectsStdioHandler;
import com.atlassian.bitbucket.mesh.git.builder.fetch.FetchCompletionHandler;
import com.atlassian.bitbucket.mesh.git.builder.reset.GitResetBuilder;
import com.atlassian.bitbucket.mesh.git.exception.AbstractGitException;
import com.atlassian.bitbucket.mesh.git.ref.Ref;
import com.atlassian.bitbucket.mesh.git.worktree.WorkTreeWriteException;
import com.atlassian.bitbucket.mesh.io.MoreFiles;
import com.atlassian.bitbucket.mesh.layout.StorageLayout;
import com.atlassian.bitbucket.mesh.process.handler.LoggingStdioHandler;
import com.atlassian.bitbucket.mesh.repository.Repository;
import com.atlassian.bitbucket.mesh.repository.RepositorySize;
import com.atlassian.bitbucket.mesh.util.SatiableConsumer;
import com.atlassian.util.profiling.Ticker;
import com.atlassian.util.profiling.Timers;
import io.grpc.Status;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.annotation.Nonnull;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.ByteBuffer;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.time.Duration;
import java.util.ArrayDeque;
import java.util.Optional;
import java.util.Queue;

import static com.atlassian.bitbucket.mesh.git.GitConstants.*;
import static com.atlassian.bitbucket.mesh.git.GitUtils.NULL_TREE;
import static java.util.Objects.requireNonNull;

public class ShinyGitPorcelain implements GitPorcelain {

    /**
     * Defines a set of characters which cannot exist in the filename used to ensure sparse checkouts are never
     * empty, for use with {@code StringUtils.containsAny}.
     * <p>
     * On Linux, the only character that appears to throw off the checkout is a backslash (likely because it's
     * treated as an escape character).
     */
    private static final String SPARSE_FILTER = "\\";
    /**
     * Configuration we set for every work tree. This does two things:
     * <ol>
     *     <li>Disables the git-lfs filter so that worktree operations are done on the files as LFS pointer files
     *     and not as the binary objects they point to (which we could never resolve automatically). For merges
     *     this ensures merge conflicts are correctly displayed as conflicts between pointer files.
     *     <li>Disables gc so that gc does not delay or impact the operations we perform in the worktree.
     *     Git commands run in the work tree, like merge and rebase, can produce enough loose objects to trigger
     *     auto GC as part of their processing. That can make the command take longer (since it has to wait for
     *     GC to complete), and it can also interfere with other processing (like using ObjectFetchStrategy to
     *     copy loose objects into the real repository). Rather than remembering to manually disable auto GC on
     *     every command run in the work tree, we disable it via configuration instead.
     * </ol>
     */
    private static final String WORKTREE_CONFIG = "\n" +
            "[filter \"lfs\"]\n" +
            "\trequired = false\n" +
            "\tprocess =\n" +
            "\tsmudge = git-lfs smudge --skip\n" +
            "[gc]\n" +
            "\tauto = 0\n";

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

    private final GitAgent agent;
    private final GitProcessBuilderFactory builderFactory;
    private final RefStreamer refStreamer;
    private final StorageLayout storageLayout;

    public ShinyGitPorcelain(GitAgent agent, GitProcessBuilderFactory builderFactory, StorageLayout storageLayout) {
        this.agent = agent;
        this.builderFactory = builderFactory;
        this.storageLayout = storageLayout;

        refStreamer = new RefStreamer(agent, builderFactory);
    }

    @Nonnull
    @Override
    public RepositorySize countObjects(@Nonnull CountObjectsParameters parameters) {
        requireNonNull(parameters, "parameters");

        return parameters.configureAndRun(builderFactory.builder(parameters.getRepository()).countObjects()
                .verbose(true)
                .build(new RepositorySizeCountObjectsStdioHandler()));
    }

    @Override
    public void fetchRef(@Nonnull FetchRefParameters parameters) {
        //Note: Do _not_ use --quiet with fetch, as it eliminates _all_ output, even in error situations. That
        //      in turn, prevents detecting rejected forced updates in the FetchCommandExitHandler
        GitProcess<Void> process = builderFactory.builder(parameters.getRepository()).fetch()
                .author(parameters.getAuthor())
                //Track the stderr of the fetch command to see if it was rejected. Note that hooks are not run for
                //fetches, so it should not be possible for a hook to prevent forced fetches
                .completionHandler(new FetchCompletionHandler(parameters))
                //If any negotiation tips were supplied, use them to avoid a full ref advertisement
                .negotiationTips(parameters.getNegotiationTips())
                .refspec(parameters.buildRefspec())
                .repository(parameters.getRemote())
                //Allow the refspec to include commits by SHA, not just ref ID
                .withConfiguration("uploadpack.allowAnySHA1InWant", true)
                .build(new LoggingStdioHandler());
        parameters.configureAndRun(process);
    }

    @Override
    public void streamReflogs(@Nonnull StreamReflogsParameters parameters,
                              @Nonnull SatiableConsumer<Reflog> callback) throws IOException {
        requireNonNull(parameters, "parameters");
        requireNonNull(callback, "callback");

        Repository repository = parameters.getRepository();
        try {
            repository.accept(new ReflogStreamingVisitor(parameters, callback));
        } catch (UncheckedIOException e) {
            throw e.getCause();
        }
    }

    @Override
    public void streamRefs(@Nonnull StreamRefsParameters parameters,
                           @Nonnull SatiableConsumer<Ref> callback) throws IOException {
        refStreamer.streamRefs(parameters, callback);
    }

    /**
     * Creates a work tree for the requested {@link WorkTreeParameters#getRepository() repository}, allowing the use of
     * Git commands like {@code git merge} which require a work tree.
     * <p>
     * The returned work tree:
     * <ul>
     *     <li>has a ref for the specified branch, or {@code refs/heads/master} if no branch was specified
     *          <ul>
     *              <li>Regardless of what that ref points to upstream, it points to the requested commit in the worktree</li>
     *          </ul>
     *     </li>
     *     <li>has a clean index (no staged changes)</li>
     *     <li>has alternates to the {@link WorkTreeParameters#getRepository upstream repository}, as well as for any
     *     {@link WorkTreeParameters#getAlternates extra repositories} requested</li>
     *     <li>has no checked out files</li>
     * </ul>
     *
     * @param parameters the request describing the work tree to create
     * @return the location of the created work tree on disk
     */
    @Override
    @Nonnull
    public Path workTree(@Nonnull WorkTreeParameters parameters) {
        Repository repository = parameters.getRepository();

        Path workTree = createWorkTree(parameters);

        log.debug("{}: Initializing work tree in {}", repository, workTree);
        try (Ticker ignored = Timers.start("git: init " + repository)) {
            init(workTree, parameters);
        }

        //If no commit was supplied, we assume the upstream repository is empty and we're creating the first commit
        //(which means there's no need to reset). For non-empty repositories, a starting commit is always specified.
        parameters.getCommit().ifPresent(commit -> {
            if (parameters.isPristine()) {
                //If we need a pristine working copy (for example, to run `git cherry-pick` or `git rebase`,
                //we'll need to actually check out the requested commit
                log.debug("{}: Checking out a pristine work tree in {}", repository, workTree);
                try (Ticker ignored = Timers.start("git: sparse checkout " + commit)) {
                    checkout(workTree, commit, parameters);
                }
            } else {
                log.debug("{}: Resetting {} to clean the index", repository, workTree);
                try (Ticker ignored = Timers.start("git: reset " + commit)) {
                    reset(workTree, commit, parameters);
                }
            }
        });

        return workTree;
    }

    /**
     * Appends an alternate for the specified {@link Repository} in the work tree, allowing it to access objects
     * from that repository. This method can be called multiple times to append alternates, allowing the created
     * work tree to borrow objects from several repositories at once.
     *
     * @param gitDir     the .git directory for the work tree where the alternate should be added
     * @param repository the repository the alternate should borrow from
     */
    private void addAlternateIn(Path gitDir, Repository repository) {
        String alternate = repository.getPath().resolve(PATH_OBJECTS).toAbsolutePath().toString();
        try {
            agent.addAlternate(gitDir, alternate);
        } catch (AbstractGitException e) {
            log.error("Could not add an alternate for {} to work tree in {}. A cross-repository work tree " +
                    "could not be prepared", alternate, gitDir.toAbsolutePath(), e);

            throw e;
        }
    }

    /**
     * Checks out the specified commit in the work tree to produce a pristine working copy.
     * <p>
     * For performance reasons, we don't want to actually check out every file in the repository. However, we still
     * need the working copy to be pristine, meaning {@code git status} reports no changes. To accomplish both goals
     * we use a sparse checkout.
     * <p>
     * Sparse checkouts only write files which match the {@code info/sparse-checkout} rules to the work tree, but,
     * unlike using {@link #reset}, {@code git status} assumes all missing files which do not match the rules are
     * <i>unchanged</i> rather than <i>deleted</i>. That means using a sparse checkout allows writing a minimal
     * number of files (ideally only one) to the work tree, but still reports that there are no staged or unstaged
     * changes. That, in turn, allows using commands like {@code git cherry-pick} and {@code git rebase}.
     *
     * @param workTree   the work tree where the checkout should be performed
     * @param commit     the commit to checkout
     * @param parameters used to apply timeouts to the {@code git reset} command, and (optionally) to specify the
     *                   branch to checkout
     */
    private void checkout(Path workTree, String commit, WorkTreeParameters parameters) {
        Repository repository = parameters.getRepository();

        //Sparse checkouts _must_ write at least one file to the work tree, or the checkout fails. To ensure
        //that doesn't happen, we need to first know what files exist so we can write info/sparse-checkout
        String file = parameters.configureAndRun(builderFactory.builder(repository).catFile()
                .batch()
                .build(new AnyFileBatchCatFileStdioHandler(commit)));
        if (file != null) {
            //If there's at least one file in the repository, configure it for a sparse checkout. Since Git
            //allows empty commits, it's possible (although extremely unlikely), that a repository can have
            //a commit but still have no files. When that happens just run checkout; it'll still be empty.
            Path gitDir = workTree.resolve(PATH_GIT);
            Path infoDir = MoreFiles.mkdir(gitDir, PATH_INFO);
            try {
                MoreFiles.write(gitDir.resolve("config"), "\n[core]\n\tsparseCheckout = true\n",
                        StandardOpenOption.APPEND);
                MoreFiles.write(infoDir.resolve("sparse-checkout"), StringUtils.prependIfMissing(file, "/"));
            } catch (IOException e) {
                throw new WorkTreeWriteException(commit + " could not be checked out. A work tree for " + repository +
                        " could not be created", Status.ABORTED, e);
            }
        }

        GitProcess<Void> checkout = builderFactory.builder().checkout()
                .branch(parameters.getBranch().map(GitRefPattern.HEADS::unqualify).orElse("master"),
                        commit, GitCheckoutCreateBranchMode.CREATE_OR_RESET)
                .quiet(true)
                .workDir(workTree)
                .build(new LoggingStdioHandler(log));
        checkout.setExecutionInterval(Duration.ofSeconds(parameters.getExecutionTimeout()));
        checkout.setIdleInterval(Duration.ofSeconds(parameters.getIdleTimeout()));
        checkout.run();
    }

    /**
     * Creates a work tree for the clone to be written to.
     *
     * @param parameters the parameters for creation
     * @return the created temporary directory
     */
    private Path createWorkTree(WorkTreeParameters parameters) {
        Repository repository = parameters.getRepository();
        try {
            Optional<Path> maybeTargetDir = parameters.getTargetDir();
            if (maybeTargetDir.isPresent()) {
                return Files.createDirectories(maybeTargetDir.get());
            }
            return Files.createTempDirectory(storageLayout.getTempDir(), repository.getSlug() + "-work");
        } catch (IOException e) {
            log.debug("{}: Could not create work tree directory", repository, e);
            throw new WorkTreeWriteException(
                    "A directory to use as a work tree for " + repository + " could not be created", Status.ABORTED, e);
        }
    }

    /**
     * Use {@code git init} to create a non-bare repository in the specified {@code workTree}. Alternates are added
     * for any additional repositories specified in the {@link WorkTreeParameters parameters}.
     *
     * @param workTree   the work tree the repository should be created in
     * @param parameters describing the repository to create the work tree for, the branch to checkout
     *                   and any alternates that should be added
     */
    private void init(Path workTree, WorkTreeParameters parameters) {
        Repository repository = parameters.getRepository();
        Path gitDir = workTree.resolve(PATH_GIT);

        parameters.configureAndRun(builderFactory.builder().init()
                .directory(workTree)
                .quiet(true)
                .build());
        parameters.getBranch().ifPresent(branch -> {
            //If a branch is provided, set it as HEAD for the work tree. If one's not specified, Git automatically
            //defaults to refs/heads/master.
            agent.setHead(gitDir, GitRefPattern.HEADS.qualify(branch));
        });

        addAlternateIn(gitDir, repository);         //Add the primary alternate for the upstream repository
        for (Repository alternate : parameters.getAlternates()) { //Add any additional alternates requested
            addAlternateIn(gitDir, alternate);
        }

        try {
            MoreFiles.write(gitDir.resolve("config"), WORKTREE_CONFIG, StandardOpenOption.APPEND);
        } catch (IOException e) {
            log.debug("{}: Could not configure work tree", repository, e);
            throw new WorkTreeWriteException("A work tree for " + repository + " could not be configured.",
                    Status.ABORTED, e);
        }
    }

    /**
     * Resets the index in the work tree to the specified commit so that the deletions for all the files we didn't
     * checkout are unstaged. When {@code HEAD} is a symbolic ref, resetting writes the specified commit ID to the
     * target ref.
     *
     * @param workTree   the work tree to reset, unstaging deletions from using {@code --no-checkout}
     * @param commit     the commit to reset to
     * @param parameters used to apply timeouts to the {@code git reset} command
     * @see #workTree(WorkTreeParameters)
     */
    private void reset(Path workTree, String commit, WorkTreeParameters parameters) {
        GitResetBuilder builder = builderFactory.builder().reset()
                .commit(commit)
                .quiet(true) //Suppress the normal status output after the reset
                .workDir(workTree);

        parameters.configureAndRun(builder.build());
    }

    private static class AnyFileBatchCatFileStdioHandler extends AbstractBatchCatFileStdioHandler<String> {

        private final StringBuilder fileBuilder;
        private final Queue<String> nextObjectId;

        private GitTreeNode directory;
        private String file;

        AnyFileBatchCatFileStdioHandler(String commit) {
            fileBuilder = new StringBuilder();

            nextObjectId = new ArrayDeque<>(1);
            nextObjectId.offer(commit + ":"); //Trailing : to request the commit's tree
        }

        @Override
        public String getResult() {
            return file;
        }

        @Override
        protected String getNextObjectId() {
            return nextObjectId.poll();
        }

        @Override
        protected boolean onContent(@Nonnull ByteBuffer content, boolean closed) {
            GitTreeNode node;
            while ((node = GitTreeNode.parse(content)) != null) {
                //Filter directories or files that contain any characters known to result in "git checkout"
                //failing to check out at least one file
                String path = node.getPath();
                if (StringUtils.containsAny(path, SPARSE_FILTER)) {
                    continue;
                }

                if (node.isFile()) {
                    file = fileBuilder.append(path).toString();

                    //We've found a file; no need to read any more trees
                    return false;
                }
                if (directory == null && node.isDirectory()) {
                    directory = node;
                }
            }

            if (closed && directory != null) {
                //If we make it here we didn't find any files in this tree, but we did find a directory. List
                //that directory's contents next and continue searching.
                //
                //Since we only list the contents of the first directory we find, it's possible a *hand-crafted*
                //tree which somehow includes a reference to an *empty* subtree (something porcelain commands
                //would never do), might result in this handler failing to return a file even though there are
                //files in other directories. Since such a tree would have to be manually created, we've chosen
                //not to try and handle such a case since the end result is "only" that every file will end up
                //being checked out (hurting performance), but overall processing should still succeed
                fileBuilder.append(directory.getPath())
                        .append("/");
                nextObjectId.offer(directory.getContentId());
                directory = null;
            }

            return true;
        }

        @Override
        protected void onMissing(@Nonnull String objectId) {
            //The requested object doesn't exist (shouldn't actually be possible). Rather than throwing,
            //let the process complete normally; checkout(...) will fall back on doing a full checkout
        }

        @Override
        protected boolean onObject(@Nonnull BatchHeader header) {
            //If the requested object references the null tree, it's an empty commit. In that case, since
            //there aren't any tree entries, any object content can be ignored. Otherwise, stream it so we
            //can look for a file to use to create a sparse checkout
            return !NULL_TREE.equals(header.getObjectId());
        }
    }
}