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.GitProcessBuilderFactory;
import com.atlassian.bitbucket.mesh.git.builder.config.GetConfigCompletionHandler;
import com.atlassian.bitbucket.mesh.git.builder.prune.GitPruneBuilder;
import com.atlassian.bitbucket.mesh.git.builder.repack.GitRepackAll;
import com.atlassian.bitbucket.mesh.git.builder.repack.GitRepackBuilder;
import com.atlassian.bitbucket.mesh.io.MoreFiles;
import com.atlassian.bitbucket.mesh.process.handler.StringStdioHandler;
import com.atlassian.bitbucket.mesh.repository.Repository;

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

import static com.atlassian.bitbucket.mesh.git.GitConstants.CONFIG_GC_PRUNE_EXPIRE;
import static com.atlassian.bitbucket.mesh.git.GitConstants.PATH_PACK;
import static com.atlassian.bitbucket.mesh.io.MoreFiles.getDurationSinceLastModified;

class RepackOperation extends AbstractGcOperation {

    /**
     * The name of the marker file to track the last time {@code repack} was run. This file is created in the
     * {@link GitRepositoryLayout#getApplicationInfoDir app-info} directory.
     */
    static final String REPACK_TIMESTAMP = "repack.ts";

    private final GitProcessBuilderFactory builderFactory;
    private final GcConfig config;

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

    @Override
    public void run(@Nonnull GcContext context) {
        if (!shouldRun() || !isDirty()) {
            return;
        }

        GcSettings settings = getGcSettings(repository);

        runRepack(context, settings);
        if (settings.isPruneEnabled()) {
            runPrune(context, settings);
        }

        Path markerFile = getRepackMarkerFile();
        try {
            MoreFiles.touch(markerFile);
        } catch (IOException e) {
            log.warn("[{}] Could not update {} ({}). Repack will run more frequently",
                    repository, markerFile.getFileName(), e.getMessage(), log.isDebugEnabled() ? e : null);
        }
    }

    @Override
    public boolean shouldRun() {
        Path repackMarker = getRepackMarkerFile();
        return getDurationSinceLastModified(repackMarker).compareTo(config.getRepackInterval()) >= 0;
    }

    /**
     * By default, Git prunes unreachable objects after 2 weeks. It's desirable to have <i>some</i> delay between
     * an object becoming unreachable and its pruning. For example, it helps prevent race conditions between pushes
     * writing new objects and pruning deleting "unreachable" objects--some of which may be new objects associated
     * with ref updates that haven't yet been applied.
     * <p>
     * To allow unreachable objects to be cleaned up faster, but still help prevent race conditions, we calculate
     * our own default {@code gc.pruneexpire} value based on the hosting and repack timeouts, whichever is longer.
     *
     * @return the default {@code gc.pruneexpire} to apply, based on the configured hosting and repack timeouts
     */
    private String getDefaultPruneExpire(Repository repository) {
        // If the hosting or repack timeout (whichever is longer) is less than one hour (it shouldn't
        // be!), this will be set to 24.hours.ago, which is still more than 24x as long as the timeout
        return config.getPruneExpire(repository).toHours() + ".hours.ago";
    }

    private GcSettings getGcSettings(Repository repository) {
        String pruneExpire = null;
        try {
            pruneExpire = builderFactory.builder(repository).config()
                    .get(CONFIG_GC_PRUNE_EXPIRE)
                    .completionHandler(new GetConfigCompletionHandler())
                    .build(new StringStdioHandler())
                    .run();
        } catch (Exception e) {
            boolean logDebug = log.isDebugEnabled();
            log.warn("[{}] Error checking \"{}\"; will assume pruning is not disabled" + (logDebug ? "" : ": {}"),
                    repository, CONFIG_GC_PRUNE_EXPIRE, logDebug ? e : e.getMessage());
        }

        return new GcSettings(pruneExpire, getDefaultPruneExpire(repository));
    }

    private Path getRepackMarkerFile() {
        return repositoryLayout.getApplicationInfoDir(repository).resolve(REPACK_TIMESTAMP);
    }

    private boolean hasTooManyLooseObjects(Repository repository) {
        int maxFilesIn17 = (255 + config.getRepackThresholdLoose()) / 256;
        long maxSizeIn17 = config.getRepackThresholdLooseSize() / 256L;

        Path object17Dir = repositoryLayout.getObjectsDir(repository)
                .resolve("17");
        GcLooseFileThresholdVisitor dirtyFileVisitor = new GcLooseFileThresholdVisitor(maxFilesIn17, maxSizeIn17);
        try {
            Files.walkFileTree(object17Dir, dirtyFileVisitor);
            return dirtyFileVisitor.isDirty();
        } catch (FileNotFoundException | NoSuchFileException ignored) {
            // If objects/17 doesn't exist, clearly there aren't too many files
        } catch (IOException e) {
            log.warn("Failed to list loose objects; will assume repacking is unnecessary", e);
        }

        return false;
    }

    private boolean hasTooManyPacks(Repository repository) {
        int maxPacks = config.getRepackThresholdPack();

        Path packDir = repositoryLayout.getObjectsDir(repository)
                .resolve(PATH_PACK);
        Set<String> packs = new HashSet<>();
        Set<String> keeps = new HashSet<>();
        try (Stream<Path> stream = Files.list(packDir)) {
            stream.filter(Files::isRegularFile)
                    .forEach(path -> {
                        String filename = path.getFileName().toString();
                        String packName = filename.substring(0, filename.length() - 5);
                        if (filename.endsWith(".pack")) {
                            packs.add(packName);
                        } else if (filename.endsWith(".keep")) {
                            // Why not just remove the file from packs? Because we have no guarantee that
                            // the list will stream in an order such that the pack appears first. If any
                            // keep is streamed before its pack, the pack wouldn't end up removed
                            keeps.add(packName);
                        }
                    });
        } catch (FileNotFoundException | NoSuchFileException e) {
            // If objects/pack doesn't exist, clearly there aren't too many packs
        } catch (IOException e) {
            log.warn("Failed to list packs; will assume repacking is unnecessary", e);
        }
        packs.removeAll(keeps);
        log.debug("[{}] {} packs were detected (threshold = {})", repository, packs.size(), maxPacks);
        return packs.size() > maxPacks;
    }

    private boolean isDirty() {
        return hasTooManyLooseObjects(repository) || hasTooManyPacks(repository);
    }

    private void runPrune(GcContext context, GcSettings settings) {
        GitPruneBuilder builder = builderFactory.builder(repository).prune()
                .expire(settings.getPruneExpire());

        runAction(context, GcActionType.PRUNE, builder, config.getRepackTimeout());
    }

    private void runRepack(GcContext context, GcSettings settings) {
        GitRepackBuilder builder = builderFactory.builder(repository).repack()
                .local(true)              // Don't pack objects borrowed from alternates
                .prune(true)              // Remove redundant packs
                .updateServerInfo(false); // Don't update dumb protocol files
        if (settings.isPruneDisabled()) {
            // If we're not pruning, for performance reasons we want to keep unreachable objects packed.
            // Using -a -k causes repack to add _all_ objects, reachable or not, to a single pack, which
            // allows us to skip "dead" pack generation
            builder.all(GitRepackAll.REACHABLE) // -A is incompatible with -k, so we use -a instead
                    .keepUnreachable(true);
        } else {
            // Otherwise, if pruning is enabled, unpack unreachable objects. Their timestamp is used to
            // control when they can finally be pruned, so they need to be loosened for eventual removal
            builder.all(GitRepackAll.LOOSEN_UNREACHABLE)
                    .unpackUnreachable(settings.getPruneExpire());
        }

        runAction(context, GcActionType.REPACK, builder, config.getRepackTimeout());
    }
}
