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

import com.atlassian.bitbucket.mesh.repository.Repository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.annotation.Nonnull;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.nio.file.attribute.DosFileAttributeView;
import java.nio.file.attribute.DosFileAttributes;
import java.time.Duration;

import static com.atlassian.bitbucket.mesh.git.GitConstants.PATH_PACK;
import static com.atlassian.bitbucket.mesh.util.NumberUtils.formatSize;
import static java.util.Objects.requireNonNull;

/**
 * A {@link SimpleFileVisitor Filevisitor} which removes stale directories and files in a repository's
 * {@code objects/} directory.
 * <p>
 * Currently, this visitor removes:
 * <ul>
 *     <li>{@code incoming-*} directories, which are sometimes left behind by quarantine handling introduced
 *     in Git 2.11</li>
 *     <li>{@code tmp_pack_*} files in the {@code pack/} directory, which are typically left behind by
 *     {@code git repack} processes (either run directly or via {@code git gc})</li>
 * </ul>
 * These files and directories are not used by {@code git} processes other than the ones that create them. To
 * prevent corruption caused by removing files which are still in-use by their creating process, the timeout
 * for considering a file "stale" must be longer than any GC- or hosting-related timeout.
 */
class GcStaleFileVisitor extends SimpleFileVisitor<Path> {

    // Using DefaultGarbageCollectionManager's logger here is _intentional_, to keep all GC-related logging associated
    // with a single source
    private static final Logger log = LoggerFactory.getLogger(DefaultGarbageCollectionManager.class);

    private final Repository repository;
    private final long threshold;

    private int depth;
    private boolean inIncomingDir;
    private boolean inPackDir;
    private int incomingDirs;
    private int tmpPackFiles;
    private long totalBytes;

    GcStaleFileVisitor(@Nonnull Repository repository, @Nonnull Duration timeout) {
        this.repository = requireNonNull(repository, "repository");

        threshold = System.currentTimeMillis() - requireNonNull(timeout, "timeout").toMillis();
    }

    @Override
    public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException {
        --depth;

        if (depth == 0 && (incomingDirs > 0 || tmpPackFiles > 0)) {
            // When we leave the objects/ directory, summarize what we did
            log.info("[{}]: Deleted {} in {} stale incoming-* director{} and {} tmp_pack_* file{}",
                    repository, formatSize(totalBytes),
                    incomingDirs, incomingDirs == 1 ? "y" : "ies",
                    tmpPackFiles, tmpPackFiles == 1 ? "" : "s");
        } else if (inIncomingDir) {
            // By postVisitDirectory, any files in the directory should have been removed and
            // the directory should be empty, so delete it
            Files.delete(dir);

            if (depth == 1) { //1 is objects/
                inIncomingDir = false;
            }
        } else if (inPackDir) {
            // We don't visit subdirectories for pack/, so postVisitDirectory can only mean
            // we're leaving the pack/ directory
            inPackDir = false;
        }

        return FileVisitResult.CONTINUE;
    }

    @Override
    public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) {
        boolean enter = true;
        if (inPackDir) {
            // Only enter the pack/ directory itself. In "pure" Git, objects/pack/ has no subdirectories,
            // so any that exist won't contain anything we can prune
            enter = false;
        } else if (depth == 1) { // depth == 1 is listing subdirectories in objects/
            String name = getName(dir);
            if (PATH_PACK.equals(name)) {
                // Traverse the pack/ directory to look for stale tmp_pack_* files
                inPackDir = true;
            } else if (name.startsWith("incoming-") && isStale(attrs)) {
                // incoming-* directories should only be traversed if they're stale, and then they
                // will be fully removed
                inIncomingDir = true;
                ++incomingDirs;
            } else {
                // For any other directory below objects/ skip it
                enter = false;
            }
        }

        if (enter) {
            // If we make it here, one of the following is true:
            // - depth == 0: preVisitDirectory for objects/, so we want to continue
            // - depth == 1 and dir is pack/, or a stale incoming-* directory, so we want to continue
            // - depth > 1: preVisitDirectory for an incoming-* subdirectory, because we don't enter any
            //   other objects/ subdirectories, so we want to continue
            ++depth;

            return FileVisitResult.CONTINUE;
        }
        // If we make it here, we're either:
        // - In pack/, which means we don't care about any subdirectories (because none should exist)
        // - In objects/, which means the directory is:
        //   - an incoming-* directory that is _not_ stale
        //   - info/
        //   - a loose object directory
        //   Whichever it is, it means this isn't a directory we want to traverse
        return FileVisitResult.SKIP_SUBTREE;
    }

    /**
     * For Git 2.11+, traversing {@code incoming-*} directories used to store objects from in-flight pushes can be
     * racy. An {@code incoming-*} directory may exist when the {@code objects/} directory is listed, but be removed
     * (because the push process completed) before its attributes are read or a {@code DirectoryStream} is opened to
     * set up the call to {@link #preVisitDirectory}.
     * <p>
     * For any {@code incoming-*} directory detected as stale, there should <i>not</i> be a risk of racing with an
     * in-flight push; the staleness threshold is intentionally "longer" than push processes are allowed to execute
     * to prevent it. Since the race for non-stale {@code incoming-*} directories is between listing {@code objects/}
     * and preparing to call {@link #preVisitDirectory}, it's not possible to avoid in a similar fashion; it happens
     * entirely in JDK classes ({@code Files} and {@code FileTreeWalker}), where a {@code DirectoryStream} is opened
     * <i>before {@link #preVisitDirectory} is even called</i> to ensure it <i>can</i> be.
     * <p>
     * A similar race condition exists for earlier versions of Git, for temporary packs used to store pushed objects,
     * which may exist when the {@code pack/} directory is listed but be removed before their attributes are read to
     * set up the call to {@link #visitFile}.
     * <p>
     * Since the goal of this visitor is to <i>prune</i> files, ignoring cases where a directory or file about to be
     * traversed has already been removed seems appropriate, and it makes this visitor more robust.
     *
     * @param file a reference to the file
     * @param exc  the I/O exception that prevented the file from being visited
     * @return {@link FileVisitResult#CONTINUE}, if {@code exc} is a {@code FileNotFoundException} or
     *         {@code NoSuchFileException}
     * @throws IOException if {@code exc} is not a {@code FileNotFoundException} or {@code NoSuchFileException}
     */
    @Override
    public FileVisitResult visitFileFailed(Path file, IOException exc) throws IOException {
        if (exc instanceof FileNotFoundException || exc instanceof NoSuchFileException) {
            return FileVisitResult.CONTINUE;
        }

        throw exc;
    }

    @Override
    public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
        if (inIncomingDir) {
            // If we're in a stale incoming-* directory, delete all the files to allow the
            // directory to be removed.
            prune(file, attrs);
        } else if (inPackDir && getName(file).startsWith("tmp_pack_") && isStale(attrs)) {
            // If we're in the pack/ directory and we find a stale tmp_pack_* file, delete it
            ++tmpPackFiles;
            prune(file, attrs);
        }

        return FileVisitResult.CONTINUE;
    }

    private static String getName(Path path) {
        return path.getFileName().toString();
    }

    private boolean isStale(BasicFileAttributes attrs) {
        return threshold > attrs.lastModifiedTime().toMillis();
    }

    private void prune(Path file, BasicFileAttributes attrs) throws IOException {
        totalBytes += attrs.size();

        // If the attributes are DosFileAttributes, the associated view should also work. This way
        // we only try to clear the read-only flag if we detect it's set
        if (attrs instanceof DosFileAttributes && ((DosFileAttributes) attrs).isReadOnly()) {
            Files.getFileAttributeView(file, DosFileAttributeView.class).setReadOnly(false);
        }

        Files.delete(file);
    }
}
