package com.atlassian.bitbucket.mesh.repair;

import com.atlassian.bitbucket.mesh.client.FetchObjectsParameters;
import com.atlassian.bitbucket.mesh.git.GitObjectInfo;
import com.atlassian.bitbucket.mesh.git.builder.GitProcessBuilderFactory;
import com.atlassian.bitbucket.mesh.git.builder.catfile.GitCatFileBatchBuilder;
import com.atlassian.bitbucket.mesh.git.builder.catfile.InteractiveInfoBatchCheckCatFilesStdioHandler;
import com.atlassian.bitbucket.mesh.git.builder.foreachref.AllRefsForEachRefStdioHandler;
import com.atlassian.bitbucket.mesh.git.ref.ProposedRefUpdate;
import com.atlassian.bitbucket.mesh.repository.Repository;
import com.atlassian.bitbucket.mesh.util.ShaUtils;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.io.Closeable;
import java.time.Duration;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.Future;

import static java.util.Objects.requireNonNull;

public class FetchParametersBuilder implements Closeable {

    private final GitProcessBuilderFactory builderFactory;
    private final boolean emptyRepository;
    private final Set<String> excludes;
    private final Set<String> includes;
    private final Object lock;
    private final int maxExcludes;
    private final Repository repository;

    private volatile Future<Void> catFileFuture;
    private volatile InteractiveInfoBatchCheckCatFilesStdioHandler catFileHandler;
    private boolean keep;
    private volatile Duration timeout;

    public FetchParametersBuilder(GitProcessBuilderFactory builderFactory, Repository repository,
                                  boolean emptyRepository, int maxExcludes) {
        this.builderFactory = builderFactory;
        this.emptyRepository = emptyRepository;
        this.maxExcludes = maxExcludes;
        this.repository = repository;

        excludes = new HashSet<>();
        includes = new HashSet<>();
        lock = new Object();
    }

    @Override
    public void close() {
        if (catFileHandler != null) {
            catFileHandler.close();
        }
    }

    public void exclude(@Nonnull String hash) {
        excludes.add(requireNonNull(hash, "hash"));
    }

    public int getExcludeCount() {
        return excludes.size();
    }

    /**
     * Adds the {@link ProposedRefUpdate#getOldHash old} and {@link ProposedRefUpdate#getNewHash new} hashes from
     * the proposed update as includes or excludes depending on whether the local repository already has them.
     *
     * @param refUpdate the ref update to add hashes from
     */
    public void includeCommitsIfMissing(@Nonnull ProposedRefUpdate refUpdate) {
        requireNonNull(refUpdate, "refUpdate");
        maybeStartCatFile();

        internalAdd(refUpdate.getNewHash(), true);
        internalAdd(refUpdate.getOldHash(), true);
    }

    /**
     * Adds the specified {@code rev} as an include if the local repository does not have it. Otherwise, the
     * {@code rev} is added as an exclude, to help reduce the size of the requested pack.
     *
     * @param rev the commit to include or exclude
     * @return {@code true} if the rev is missing and is (now) part of the includes list
     */
    public boolean includeIfMissing(@Nonnull String rev) {
        return includeIfMissing(rev, true);
    }

    /**
     * Adds the specified {@code rev} as an include if the local repository doesn't have it. Otherwise, if
     * {@code orExclude} is {@code true}, {@code rev} is added as an exclude.
     *
     * @param rev       the commit to include or, potentially, exclude
     * @param orExclude {@code true} to add an exclusion for the commit if it already exists; otherwise,
     *                  {@code false} to ignore existing commits
     * @return {@code true} if the rev is missing and is (now) part of the includes list
     */
    public boolean includeIfMissing(@Nonnull String rev, boolean orExclude) {
        requireNonNull(rev, "rev");

        maybeStartCatFile();
        return internalAdd(rev, orExclude);
    }

    public boolean isMissingAnyObjects() {
        return !includes.isEmpty();
    }

    public void keep(boolean value) {
        keep = value;
    }

    /**
     * @return the {@link FetchObjectsParameters} if any objects were missing, otherwise {@code null}
     */
    @Nullable
    public FetchObjectsParameters maybeBuild() {
        try {
            if (includes.isEmpty()) {
                return null;
            }

            if (excludes.isEmpty()) {
                //We don't have _any_ of the objects that were requested, which would result in fetch of nearly
                //the entire history. Use the default branch as a reasonable exclude
                String commitId = resolve("HEAD");
                if (commitId != null) {
                    excludes.add(commitId);
                }
                //Include the 50 last updated branches to get recent commits just in case HEAD is really old.
                //This may be the case in old forks where ref sync is not enabled.
                AllRefsForEachRefStdioHandler handler = new AllRefsForEachRefStdioHandler(
                        ref -> {
                            excludes.add(ref.getLatestCommit());
                            return true;
                        },
                        ""); // the default branch does not matter for our purposes
                builderFactory.builder(repository).forEachRef()
                        .count(50)
                        .format(handler.getFormat())
                        .sort("-committerdate")
                        .patterns("refs/heads")
                        .build(handler)
                        .run();
            }

            return new FetchObjectsParameters.Builder()
                    .excludes(excludes)
                    .includes(includes)
                    .keep(keep)
                    .timeout(timeout == null ? 0L : timeout.getSeconds())
                    .build();
        } finally {
            //Terminate the cat-file batch if  is running
            close();
        }
    }

    public void setTimeout(@Nonnull Duration timeout) {
        this.timeout = requireNonNull(timeout, "timeout");
    }

    private boolean internalAdd(String commitish, boolean orExclude) {
        if (commitish == null || ShaUtils.isNull(commitish) || excludes.contains(commitish)) {
            return false;
        }
        if (includes.contains(commitish)) {
            return true;
        }

        String commitId = resolve(commitish);
        if (commitId == null) {
            includes.add(commitish);
            return true;
        }
        if (orExclude && excludes.size() < maxExcludes) {
            excludes.add(commitish);
        }
        return false;
    }

    private void maybeStartCatFile() {
        if (catFileFuture == null && !emptyRepository) {
            synchronized (lock) {
                if (catFileFuture == null) {
                    catFileHandler = new InteractiveInfoBatchCheckCatFilesStdioHandler();
                    GitCatFileBatchBuilder builder = builderFactory.builder(repository).catFile()
                            .batchCheck();
                    if (timeout != null) {
                        builder.withTimeout(timeout);
                    }
                    catFileFuture = builder.build(catFileHandler).start();
                }
            }
        }
    }

    private String resolve(String commitish) {
        if (emptyRepository) {
            return null;
        }
        try {
            return catFileHandler.getInfo(commitish).map(GitObjectInfo::getObjectId).orElse(null);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException(
                    "Interrupted while checking whether " + commitish + " exists in " + repository, e);
        }
    }
}
