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

import com.atlassian.bitbucket.mesh.config.GcConfig;
import com.atlassian.bitbucket.mesh.git.GitRefPattern;
import com.atlassian.bitbucket.mesh.git.GitRepositoryLayout;
import com.atlassian.bitbucket.mesh.git.GitTimers;
import com.atlassian.bitbucket.mesh.git.builder.GitProcessBuilderFactory;
import com.atlassian.bitbucket.mesh.git.builder.fetch.GitFetchTagMode;
import com.atlassian.bitbucket.mesh.git.builder.foreachref.AllRefsForEachRefStdioHandler;
import com.atlassian.bitbucket.mesh.io.MoreFiles;
import com.atlassian.bitbucket.mesh.io.SyncFileTreeCallback;
import com.atlassian.bitbucket.mesh.io.SyncFileTreeParameters;
import com.atlassian.bitbucket.mesh.process.handler.NullStdioHandler;
import com.atlassian.bitbucket.mesh.repository.ManagedRepository;
import com.atlassian.bitbucket.mesh.repository.Repository;
import com.atlassian.bitbucket.mesh.repository.SharedObjectsRepository;
import com.atlassian.util.profiling.Ticker;
import com.google.common.collect.ImmutableSet;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.annotation.Nonnull;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.DirectoryNotEmptyException;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.nio.file.attribute.BasicFileAttributes;
import java.time.Duration;
import java.time.Instant;
import java.util.Set;
import java.util.stream.Stream;

import static com.atlassian.bitbucket.mesh.git.GitConstants.*;
import static java.nio.file.StandardOpenOption.APPEND;
import static java.time.Instant.ofEpochMilli;

class VacuumOperation implements GcOperation {

    /**
     * The name of the marker file to track the last time the repository was fully vacuumed. This file is created in
     * the {@link GitRepositoryLayout#getApplicationInfoDir app-info} directory.
     */
    static final String VACUUM_FULL_TIMESTAMP = "vacuum-full.ts";
    /**
     * The name of the marker file to track the last time the repository was vacuumed. This file is created in the
     * {@link GitRepositoryLayout#getApplicationInfoDir app-info} directory.
     */
    static final String VACUUM_TIMESTAMP = "vacuum.ts";

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

    private final GitProcessBuilderFactory builderFactory;
    private final GcConfig config;
    private final Path dirtyMarker;
    private final ManagedRepository repository;
    private final GitRepositoryLayout repositoryLayout;

    VacuumOperation(GitProcessBuilderFactory builderFactory, GcConfig config, ManagedRepository repository,
                    GitRepositoryLayout repositoryLayout, Path dirtyMarker) {
        this.builderFactory = builderFactory;
        this.config = config;
        this.dirtyMarker = dirtyMarker;
        this.repository = repository;
        this.repositoryLayout = repositoryLayout;
    }

    @Override
    public void run(@Nonnull GcContext context) {
        boolean full = isFullVacuumNeeded();
        try (Ticker ignored = GitTimers.GC.start("vacuum", full, repository)) {
            //Vacuuming moves all objects and packs from the managed repository to the shared-objects repository, and
            //ensures the moved objects are protected by refs and ref logs exactly as they were in the managed
            //repository. This means that vacuuming not only moves the objects into the shared-objects, but also
            //replicates the ref logs and refs exactly.
            SharedObjectsRepository shared = repository.getSharedObjects();
            Path sharedObjectsDir = repositoryLayout.getObjectsDir(shared);
            Path objectsDir = repositoryLayout.getObjectsDir(repository);

            Path vacuumMarker = getVacuumMarkerFile();
            Instant vacuumStart = Instant.now();

            VacuumObjectsAndPacksCallback objectsCallback =
                    new VacuumObjectsAndPacksCallback(repository, sharedObjectsDir);
            //1. Hard link or copy all loose objects and packs from repository into the shared-objects repository
            SyncFileTreeParameters.Builder syncObjectsBuilder =
                    new SyncFileTreeParameters.Builder(objectsDir, sharedObjectsDir)
                            .allowHardLinks(true)
                            .callback(objectsCallback)
                            .replaceExisting(false);
            try (Ticker ignored2 = GitTimers.GC.start("vacuum - sync objects and packs")) {
                MoreFiles.syncFileTree(syncObjectsBuilder.build());
            } catch (IOException e) {
                log.warn("[{}] Error vacuuming objects and packs", repository, e);
                addAction(context, vacuumStart, true);
            }

            //2. Transfer the ref logs to the shared-objects repository
            //   In case of a full vacuum, we sync _all_ ref logs and remove any pull request ref logs from the
            //   repository if the PR is closed and the ref logs have been fully synced.
            Set<Long> openPullRequestIds = full ? getOpenPullRequests() : null;
            Path sourcePath = repository.getPath().resolve(PATH_LOGS);
            Path destinationPath = shared.getReflogDir(repository);
            VacuumRefLogsCallback refLogsCallback =
                    new VacuumRefLogsCallback(repository, openPullRequestIds,
                            sourcePath, destinationPath, config.isConvertStashRefs());
            try (Ticker ignored2 = GitTimers.GC.start("vacuum - ref logs")) {
                Instant modifiedSince = full ? Instant.MIN : ofEpochMilli(MoreFiles.getLastModified(vacuumMarker));

                MoreFiles.syncFileTree(new SyncFileTreeParameters.Builder(sourcePath, destinationPath)
                        //always sync by copying. Hard-linking would lead to invalid ref logs in shared-objects if the
                        //reflog is updated in the managed repository and the new commits have not yet been vacuumed.
                        .allowHardLinks(false)
                        .callback(refLogsCallback)
                        .modifiedSince(modifiedSince)
                        .replaceExisting(false)
                        .build());

                if (full && config.isConvertStashRefs()) {
                    Path stashRefsDirectory = destinationPath.resolve(PATH_STASH_REFS);
                    if (Files.isDirectory(stashRefsDirectory)) {
                        //Also convert existing stash-refs in the shared repository. This should really only happen once,
                        //and after it's been cleaned up there should never again be 'stash-refs' in the shared home.
                        Path darkRefsDirectory = destinationPath.resolve(PATH_DARK_REFS);
                        MoreFiles.syncFileTree(new SyncFileTreeParameters.Builder(stashRefsDirectory, darkRefsDirectory)
                                .callback(new MoveSharedStashRefsToDarkRefsCallback())
                                .modifiedSince(modifiedSince)
                                .replaceExisting(false)
                                .build());
                    }
                }

                if (full) {
                    MoreFiles.pruneEmptyDirectories(destinationPath);
                    MoreFiles.pruneEmptyDirectories(sourcePath);
                }
            } catch (IOException e) {
                log.warn("[{}] Error vacuuming ref logs", repository, e);
            }

            //3. There is a window between the start of vacuuming and here where a concurrent ref update (push) can
            //   cause a reflog to be synced that references a commit that is not present in shared-objects yet.
            //   This causes git repack in shared-objects to log warnings. While not fatal, we prefer to leave
            //   shared-objects in a consistent state, so we'll run another sweep to pick up new objects and packs
            //   that have been added since the start of the vacuuming run.
            try (Ticker ignored2 = GitTimers.GC.start("vacuum - sync objects and packs")) {
                MoreFiles.syncFileTree(syncObjectsBuilder
                        //Use a fresh VacuumObjectsAndPacksCallback because in step 5) we _only_ want to delete
                        //objects that we know are protected by refs and ref logs. Any object or pack that we transfer
                        //in this step is going to be very recent. They _should_ be protected but there's still a
                        //theoretical race condition between syncing objects and packs again and fetching the refs in
                        //step 4.
                        .callback(new VacuumObjectsAndPacksCallback(repository, sharedObjectsDir))
                        .modifiedSince(vacuumStart)
                        .build());
            } catch (IOException e) {
                log.warn("[{}] Error vacuuming objects and packs", repository, e);
                addAction(context, vacuumStart, true);
                return;
            }

            //All vacuumed objects are as good as safe. Delete the dirty marker before the fetch to ensure that if the
            //repository is marked dirty while we're fetching, we don't lose the dirty status
            MoreFiles.deleteQuietly(dirtyMarker);

            //4. Fetch all refs to ensure the copied files are protected in shared-objects
            boolean fetched = false;
            try (Ticker ignored2 = GitTimers.GC.start("vacuum - fetch")) {
                String namespace = "refs/r/" + repository.getLocalId() + "/refs";
                builderFactory.builder(shared)
                        .fetch()
                        .negotiationTips(namespace + "/**")
                        .prune(true)
                        .quiet(true)
                        .refspecs("+refs/*:" + namespace + "/*")
                        .repository(repository)
                        .tags(GitFetchTagMode.NO_TAGS)
                        .withTimeout(config.getRepackTimeout())
                        .build(new NullStdioHandler<>())
                        .run();
                fetched = true;
            } finally {
                if (!fetched) {
                    //Uh oh.. the fetch failed. Recreate the dirty marker
                    try {
                        MoreFiles.touch(dirtyMarker);
                    } catch (IOException e) {
                        if (repository.exists()) {
                            //The repository still exists..
                            log.warn("[{}] Could not recreate the dirty marker!", repository, e);
                        }
                    }
                }
            }

            //5. Delete the objects and packs that we transferred in step 1.
            try (Ticker ignored2 = GitTimers.GC.start("vacuum - prune vacuumed objects/packs")) {
                objectsCallback.pruneVacuumed();
            }

            //6. Prune empty objects directories
            pruneEmptyLooseObjectsDirectories(repository);

            try {
                MoreFiles.touch(vacuumMarker, vacuumStart);
            } catch (IOException e) {
                log.warn("[{}] Could not update vacuuming marker file. Vacuuming will run more often", repository, e);
            }
            if (full) {
                try {
                    MoreFiles.touch(getFullVacuumMarkerFile(), vacuumStart);
                } catch (IOException e) {
                    log.warn("[{}] Could not update full vacuuming marker file. Full vacuuming will run more often",
                            repository, e);
                }
            }

            int objectsSynced = objectsCallback.getCount();
            int refLogsSynced = refLogsCallback.getSyncedCount();
            int refLogsMerged = refLogsCallback.getMergedCount();
            if (objectsSynced > 0 || refLogsSynced > 0 || refLogsMerged > 0) {
                Duration duration = Duration.between(vacuumStart, Instant.now());
                log.info("[{}] Vacuum into shared-objects completed in {} ms ({} objects/packs, {} ref logs, {} ref logs merged)",
                        repository, duration.toMillis(), objectsSynced, refLogsSynced, refLogsMerged);
            }
            addAction(context, vacuumStart, false);
        }
    }

    private static void pruneEmptyLooseObjectsDirectories(Repository repository) {
        try (Stream<Path> dirs = Files.list(repository.getPath().resolve("objects"))
                .filter(Files::isDirectory)
                .filter(path -> path.getFileName().toString().length() == 2)) {

            dirs.forEach(dir -> {
                try {
                    Files.delete(dir);
                } catch (DirectoryNotEmptyException | FileNotFoundException | NoSuchFileException e) {
                    //Nothing to do
                } catch (IOException e) {
                    log.warn("[{}] Failed to delete empty directory {}", repository, dir, e);
                }
            });
        } catch (IOException e) {
            log.warn("[{}] Error while pruning empty objects directories", repository, e);
        }
    }

    private void addAction(GcContext context, Instant start, boolean failed) {
        context.addAction(GcActionType.VACUUM_REPOSITORY, start.toEpochMilli(), repository.getLocalId(), failed);
    }

    @Nonnull
    private Path getFullVacuumMarkerFile() {
        return repositoryLayout.getApplicationInfoDir(repository).resolve(VACUUM_FULL_TIMESTAMP);
    }

    private Set<Long> getOpenPullRequests() {
        ImmutableSet.Builder<Long> builder = ImmutableSet.builder();
        builderFactory.builder(repository).forEachRef()
                .pattern(GitRefPattern.PULL_REQUESTS.getPath())
                .format(AllRefsForEachRefStdioHandler.FORMAT)
                .build(new AllRefsForEachRefStdioHandler(ref -> {
                    String refId = ref.getId();
                    int index = refId.indexOf('/', 19);
                    try {
                        builder.add(Long.parseLong(refId.substring(19, index)));
                    } catch (NumberFormatException e) {
                        log.warn("[{}] Ref {} is not recognized as a pull request ref", repository, refId);
                    }
                    return false;
                }, "refs/heads/master"))
                .run();
        return builder.build();
    }

    @Nonnull
    private Path getVacuumMarkerFile() {
        return repositoryLayout.getApplicationInfoDir(repository).resolve(VACUUM_TIMESTAMP);
    }

    private boolean isFullVacuumNeeded() {
        return MoreFiles.getDurationSinceLastModified(getFullVacuumMarkerFile())
                .compareTo(config.getFullVacuumInterval()) >= 0;
    }

    private class MoveSharedStashRefsToDarkRefsCallback implements SyncFileTreeCallback {

        @Override
        public void onConflict(@Nonnull Path source, @Nonnull BasicFileAttributes sourceAttributes,
                               @Nonnull Path destination) throws IOException {
            //Destination already exists, append this reflog to the destination and then delete the source
            try (OutputStream out = Files.newOutputStream(destination, APPEND)) {
                Files.copy(source, out);
            } catch (IOException e) {
                Path sharedReflogsDir = repository.getSharedObjects().getReflogDir(repository);
                log.info("[{}] Could not merge reflog {} into {}",
                        repository, sharedReflogsDir.relativize(source), sharedReflogsDir.relativize(destination), e);
                return;
            }
            try {
                Files.delete(source);
            } catch (NoSuchFileException e) {
                //Not a problem
            }
        }

        @Override
        public void onSynced(@Nonnull Path source,
                             @Nonnull BasicFileAttributes sourceAttributes,
                             @Nonnull Path destination) throws IOException {
            //Source and destination are now in sync, delete the source.
            try {
                Files.delete(source);
            } catch (NoSuchFileException e) {
                //Not a problem
            }
        }
    }
}
