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

import com.atlassian.bitbucket.mesh.git.GitAgent;
import com.atlassian.bitbucket.mesh.git.builder.GitProcess;
import com.atlassian.bitbucket.mesh.git.builder.GitProcessBuilder;
import com.atlassian.bitbucket.mesh.git.builder.GitProcessBuilderFactory;
import com.atlassian.bitbucket.mesh.git.builder.updateref.GitUpdateRefSetBuilder;
import com.atlassian.bitbucket.mesh.git.builder.updateref.UpdateRefCompletionHandler;
import com.atlassian.bitbucket.mesh.git.exception.MergeException;
import com.atlassian.bitbucket.mesh.io.MoreFiles;
import com.atlassian.bitbucket.mesh.process.handler.PathStdioHandler;
import com.atlassian.bitbucket.mesh.util.ShaUtils;
import com.atlassian.util.profiling.Ticker;
import com.atlassian.util.profiling.Timers;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.annotation.Nonnull;
import java.io.IOException;
import java.nio.file.*;
import java.util.EnumSet;

import static com.atlassian.bitbucket.mesh.git.GitConstants.PATH_GIT;
import static com.atlassian.bitbucket.mesh.git.GitConstants.PATH_OBJECTS;
import static com.atlassian.bitbucket.mesh.git.fetch.CopyLooseObjectsVisitor.isSameFileStore;

/**
 * A {@link RefFetchStrategy} which fetches refs by copying across objects and then updates the target ref using
 * {@code git update-ref}. This two-step process allows the ref update to be skipped, effectively discarding the
 * merge.
 * <p>
 * For single-repository merges, this strategy uses Java I/O to copy the loose objects from the work tree directly
 * into the destination repository without forking any {@code git} processes. Since most merges are between branches
 * in the same repository, this no-fork approach provides the best performance.
 * <p>
 * For cross-repository merges, this strategy drops below {@code git fetch} and uses plumbing commands directly to
 * produce a more efficient copy:
 * <ul>
 *     <li>{@code git rev-list --objects} is used to determine which objects need to be copied to the destination
 *     repository</li>
 *     <li>{@code git pack-objects} is fed the object list from {@code git rev-list --objects} and used to stream
 *     a pack containing the necessary objects</li>
 *     <li>{@code git unpack-objects} is fed the streamed pack and used to write the necessary objects as loose
 *     files in the destination repository</li>
 * </ul>
 * This closely mirrors what {@code git fetch} does, but it avoids some of the extra processing {@code git fetch}
 * does. Specifically, when {@code git fetch} runs {@code git rev-list --objects}, it uses {@code --not --all} to
 * exclude all of the objects that are currently in the repository. That couples {@code git fetch}'s performance
 * to the number of refs in the repository and makes it progressively heavier as more refs are added. The direct
 * {@code git rev-list --objects} run here uses a fixed set of refs to decouple its performance from the number
 * of refs the repository has.
 */
public class ObjectRefFetchStrategy implements RefFetchStrategy {

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

    private final GitAgent agent;
    private final GitProcessBuilderFactory builderFactory;

    public ObjectRefFetchStrategy(GitAgent agent, GitProcessBuilderFactory builderFactory) {
        this.agent = agent;
        this.builderFactory = builderFactory;
    }

    @Override
    public String fetch(@Nonnull RefFetchParameters parameters, @Nonnull Path workTree) {
        //Resolve the merge commit's hash, so that it can be provided on the cancelable event later. This is done first
        //because, if it fails, there's no point in forking any git processes
        String mergeHash = agent.revParse(workTree.resolve(PATH_GIT), GitAgent.HEAD);
        if (GitAgent.HEAD.equals(mergeHash)) {
            throw new MergeException("The merge commit " + mergeHash + " could not be resolved.");
        }

        //Step 1: Fetch the merge back into the real repository, retrieving the objects but not updating the target ref
        if (parameters.isCrossRepository()) {
            fetchViaPack(workTree, parameters);
        } else {
            fetchViaCopy(workTree, parameters);
        }

        return mergeHash;
    }

    @Override
    public void updateRef(@Nonnull RefFetchParameters parameters, @Nonnull String mergeHash) {
        GitUpdateRefSetBuilder builder = builderFactory.builder(parameters.getToRepository()).updateRef()
                .set(parameters.getTargetRef(), mergeHash) //targetRef, not to.id
                .author(parameters.getAuthor()) //Just in case reflogs are enabled
                .completionHandler(new UpdateRefCompletionHandler());
        if (!parameters.isForced()) {
            //If the fetch is not a forced update, supply an old value to ensure the update fails if the ref
            //does not point to the expected commit
            if (parameters.getTargetRef().equals(parameters.getRef().getId())) {
                //Updating the to-ref, make sure it's pointing to the expected commit
                builder.oldValue(parameters.getRef().getLatestCommit());
            } else {
                //Verify targetRef does not exist yet
                builder.oldValue(ShaUtils.NULL_SHA1);
            }
        }
        parameters.configureAndRun(builder.build());
    }

    /**
     * Fetches the merge by directly copying loose objects from the work tree to the destination repository.
     * <p>
     * Because the work tree has an alternate back to the origin repository, and has no objects of its own when
     * {@code git clone --shared} completes, any objects in the work tree must belong to the merge. Copying those
     * loose objects to the destination repository is conceptually identical to the {@code git pack-objects} to
     * {@code git unpack-objects} chaining {@code git fetch} has, but avoids the overhead of spawning processes.
     * <p>
     * Note that this means, as objects are copied, the destination repository likely has incomplete objects, like
     * commits referring to trees that don't exist or trees referring to blobs that don't exist. It appears to be
     * natural for Git to handle this case, though, because a normal {@code git fetch} unpacks its new objects into
     * the repository without writing the pack first. That results in the same transient missing objects as copying.
     *
     * @param workTree   the work tree containing the merge to fetch
     * @param parameters describes the merge being fetched
     */
    private void fetchViaCopy(Path workTree, RefFetchParameters parameters) {
        try (Ticker ignored = Timers.start("copy loose objects")) {
            Path repositoryDir = parameters.getToRepository().getPath();
            //Try to eagerly detect whether atomic moves will be possible. This is an optimization for fetch-via-copy
            //so that it doesn't try atomic moves when they won't be supported
            boolean tryMove = isSameFileStore(repositoryDir, workTree);

            log.debug("{}: Fetching single-repository merge by copying new objects", parameters);
            Files.walkFileTree(MoreFiles.resolve(workTree, PATH_GIT, PATH_OBJECTS),
                    EnumSet.noneOf(FileVisitOption.class), 2, new CopyLooseObjectsVisitor(repositoryDir, tryMove));
        } catch (IOException e) {
            throw new MergeException("The merge's objects could not be copied to the target repository.", e);
        }
    }

    /**
     * Fetches the merge by piping {@code git rev-list --objects} into {@code git pack-objects} into {@code git
     * unpack-objects}.
     * <p>
     * This process amounts to dropping to the plumbing commands that underpin the {@code git fetch} porcelain.
     * The goal of this approach is to avoid the ref advertisement and object negotiation work {@code git fetch}
     * does. That processing becomes progressively more expensive as the number of refs in the repository grows,
     * making fetching merges slower in larger repositories.
     * <p>
     * By building our own {@code git rev-list --objects} command, we're able to consume a fixed number of refs,
     * regardless of how many the destination repository has. This should achieve much more stable performance
     * than {@code git fetch} offers.
     *
     * @param workTree   the work tree containing the merge to fetch
     * @param parameters describes the merge being fetched
     */
    private void fetchViaPack(Path workTree, RefFetchParameters parameters) {
        Path mergeObjects = listObjects(workTree, parameters);
        if (mergeObjects == null) {
            log.debug("{}: Merge produced no new objects", parameters);
        } else {
            Path mergePack = packObjects(workTree, mergeObjects, parameters);
            if (mergePack == null) {
                log.debug("{}: Merge produced an empty pack", parameters);
            } else {
                unpackObjects(mergePack, parameters);
            }
        }
    }

    /**
     * Uses {@code git rev-list --objects} to list the new objects created by the merge. If the merge only reused
     * existing objects, {@code null} is returned to indicate there are no new objects.
     *
     * @param workTree   the work tree containing the merge to fetch
     * @param parameters describes the merge being fetched
     * @return a file listing the new objects, or {@code null} if no new objects were produced
     */
    private Path listObjects(Path workTree, RefFetchParameters parameters) {
        Path objects = MoreFiles.resolve(workTree, PATH_GIT, "bb-merge-objects.txt");

        GitProcessBuilder revListBuilder = builderFactory.builder().command("rev-list")
                .argument("--objects")
                .workDir(workTree);

        //Whether the merge is cross-repository or not we can always exclude the tip of the to ref, because
        //it and all of its objects are guaranteed to be in the target repository
        GitProcess<Long> revList = revListBuilder.argument("^" + parameters.getRef().getLatestCommit())
                .argument(GitAgent.HEAD)
                .build(PathStdioHandler.writing(objects));
        log.debug("{}: Streaming {} to {}", parameters, revList, objects.toAbsolutePath());

        return parameters.configureAndRun(revList) > 0L ? objects : null; //Return the file only if we wrote some bytes
    }

    /**
     * Streams the {@code mergeObjects} into {@code git pack-objects} to produce a pack. This new pack may
     * contain new objects created by the merge <i>as well as</i> other objects from the source repository
     * that the destination repository doesn't already have.
     *
     * @param workTree     the work tree containing the merge to fetch
     * @param mergeObjects a list of the objects to pack
     * @param parameters   describes the merge being fetched
     * @return a pack containing the specified objects, or {@code null} if the resulting pack was empty
     */
    private Path packObjects(Path workTree, Path mergeObjects, RefFetchParameters parameters) {
        Path mergePack = MoreFiles.resolve(workTree, PATH_GIT, "bb-merge.pack");

        GitProcess<Long> packObjects = builderFactory.builder().command("pack-objects")
                .argument("--delta-base-offset")
                .argument("-q")
                .argument("--stdout")
                .workDir(workTree)
                .build(PathStdioHandler.readingAndWriting(mergeObjects, mergePack));
        log.debug("{}: Streaming {} to {}", parameters, packObjects, mergePack.toAbsolutePath());

        return parameters.configureAndRun(packObjects) > 0L ? mergePack : null;
    }

    /**
     * Streams the {@code mergePack} into {@code git unpack-objects} to unpack the merge's objects into the
     * destination repository. {@code git unpack-objects} ignores any objects in the pack which are already
     * in the destination repository, so only <i>new</i> objects will actually be written.
     *
     * @param mergePack  a pack containing the merge objects to unpack
     * @param parameters describes the merge being fetched
     */
    private void unpackObjects(Path mergePack, RefFetchParameters parameters) {
        GitProcess<Long> unpackObjects = builderFactory.builder(parameters.getToRepository())
                .command("unpack-objects")
                .argument("-q")
                .build(PathStdioHandler.reading(mergePack));
        log.debug("{}: Using {} to unpack objects from {}", parameters, unpackObjects, mergePack.toAbsolutePath());
        parameters.configureAndRun(unpackObjects);
    }

}
