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

import com.atlassian.bitbucket.mesh.config.GcConfig;
import com.atlassian.bitbucket.mesh.git.GitRepositoryLayout;
import com.atlassian.bitbucket.mesh.git.builder.GitProcess;
import com.atlassian.bitbucket.mesh.git.builder.GitProcessBuilderFactory;
import com.atlassian.bitbucket.mesh.process.handler.LoggingStdioHandler;
import com.atlassian.bitbucket.mesh.repository.Repository;

import javax.annotation.Nonnull;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.stream.Stream;

/**
 * Garbage collection operation that calls {@code git commit-graph write --reachable} to update the commit graph in
 * a repository. For efficiency, incremental commit graphs are created (using --slice) for most updates. The full
 * commit graph is only (re-)created if {@code git repack} has run or if there are too many incremental commit graph
 * files.
 */
class WriteCommitGraphOperation extends AbstractGcOperation {

    private final GitProcessBuilderFactory builderFactory;
    private final GcConfig config;

    WriteCommitGraphOperation(GitProcessBuilderFactory builderFactory, GcConfig config,
                              Repository repository, GitRepositoryLayout repositoryLayout) {
        super(repository, repositoryLayout);
        this.builderFactory = builderFactory;
        this.config = config;
    }

    @Override
    public void run(@Nonnull GcContext context) {
        boolean repacked = context.isSuccess(GcActionType.REPACK);
        boolean packedRefs = context.isSuccess(GcActionType.PACK_REFS);

        if (packedRefs || repacked) {
            writeCommitGraph(context, repacked);
        }
    }

    private long countCommitGraphs() {
        Path commitGraphsDir = repositoryLayout.getObjectsDir(repository).resolve("info").resolve("commit-graphs");
        if (!Files.isDirectory(commitGraphsDir)) {
            return 0L;
        }

        try (Stream<Path> graphs = Files.walk(commitGraphsDir, 1)
                .filter(path -> path.getFileName().toString().endsWith(".graph"))) {
            return graphs.count();
        } catch (IOException e) {
            log.warn("[{}] Error while counting the number of commit-graph files", repository, e);
            return 0L;
        }
    }

    /**
     * Runs {@code git commit-graph write --reachable}, which mirrors what {@code git gc} does when
     * {@code gc.writeCommitGraph} is {@code true}.
     * <p>
     * The {@code full} flag determines whether to write the full commit graph, which includes all of the
     * reachable commits in the repository, or an incremental commit graph, which includes only new commits
     * since the previous write. Incremental mode allows the commit graph to be written more frequently, to
     * keep it more up to date.
     * <p>
     * Technical documentation: https://github.com/git/git/blob/v2.24.0/Documentation/technical/commit-graph.txt
     *
     * @param context the garbage collection context
     * @param full {@code true} to trigger a write of the full commit graph; otherwise, {@code false} to
     *             do an incremental write for new commits
     */
    private void writeCommitGraph(GcContext context, boolean full) {
        // Even if no full commit graph was requested, create a full commit graph if we have too many
        // incremental commit graphs
        boolean split = !full && countCommitGraphs() <= config.getFullCommitGraphThreshold();

        // git gc's call is here: https://github.com/git/git/blob/v2.24.0/builtin/gc.c#L688-L691
        GitProcess<Void> command = builderFactory.builder(repository).commitGraph()
                .write()
                .reachable(true)
                // Write a split (incremental) graph containing only "new" (not previously graphed) commits.
                // This operation is faster than creating the full commit graph. The next "full" write will
                // remove any incremental graphs as part of producing the new, full graph.
                // Note: This isn't something git gc would do automatically. It's done here to allow keeping
                // the commit graph more up to date while minimizing the resource costs of doing so
                .split(split)
                .build(new LoggingStdioHandler(log));
        command.setTimeout(config.getRepackTimeout());

        long start = System.currentTimeMillis();
        boolean failed;
        try {
            command.run();
            failed = false;
        } catch (Exception e) {
            // If writing the commit graph fails we don't want the fail the overall GC processing,
            // but we want to log it and record the failure in gc.log
            log.warn("[{}] Failed to write commit graph", repository, e);
            failed = true;
        }

        context.addAction(full ? GcActionType.COMMIT_GRAPH_FULL : GcActionType.COMMIT_GRAPH_SPLIT, start, null, failed);
    }
}
