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

import com.atlassian.bitbucket.mesh.io.MoreFiles;
import com.atlassian.bitbucket.mesh.io.SyncFileTreeCallback;
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.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.attribute.BasicFileAttributes;
import java.nio.file.attribute.DosFileAttributeView;
import java.nio.file.attribute.DosFileAttributes;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;

public class VacuumObjectsAndPacksCallback implements SyncFileTreeCallback {

    private static final Logger log = LoggerFactory.getLogger(VacuumObjectsAndPacksCallback.class);
    private static final Path PATH_PACK = Paths.get("pack");

    private final Path destObjectsDir;
    private final Set<String> keptPacks;
    private final Repository repository;
    private final Instant safeLastModified;
    private final Path sourceObjectsDir;
    private final Map<Path, BasicFileAttributes> vacuumed;

    public VacuumObjectsAndPacksCallback(Repository repository, Path destObjectsDir) {
        this.destObjectsDir = destObjectsDir;
        this.repository = repository;
        keptPacks = new HashSet<>();
        sourceObjectsDir = repository.getPath().resolve("objects");
        safeLastModified = Instant.now().minus(23, ChronoUnit.HOURS);
        vacuumed = new HashMap<>();
    }

    public int getCount() {
        return vacuumed.size();
    }

    @Override
    public boolean isSourceIncluded(@Nonnull Path source, @Nonnull BasicFileAttributes sourceAttributes) {
        if (!source.startsWith(sourceObjectsDir) || !isObjectsPathIncluded(sourceObjectsDir.relativize(source))) {
            return false;
        }

        if (sourceAttributes.isRegularFile()) {
            //Exclude objects/pack/pack-*.<extension> files if a matching recent pack-*.keep file exists because
            //that indicates that the pack file has just been written and the transaction in question hasn't
            //completed yet
            if (isKeptPackFile(source)) {
                log.debug("[{}] Not vacuuming {} because it is protected by a .keep file",
                        repository, source.getFileName());
                return false;
            }
            try {
                Instant lastModified = sourceAttributes.lastModifiedTime().toInstant();
                if (lastModified.isBefore(safeLastModified)) {
                    //For older files, it's not safe to use hard links because the modification time for the
                    //file is not updated as result of hard-linking. For older files, git prune (running in
                    //shared-objects) could delete the file that we've just hard-linked. If that happens
                    //while we're in the middle of fetching the refs (step 2 below), it could result in an
                    //invalid repository (missing objects). So to prevent that we touch the file before
                    //hard-linking it.
                    MoreFiles.touch(source, safeLastModified);
                }
            } catch (IOException e) {
                //Ignore
            }
        }
        return true;
    }

    @Override
    public boolean isDestinationIncluded(@Nonnull Path destination, @Nonnull BasicFileAttributes destAttributes) {
        return destination.startsWith(destObjectsDir) && isObjectsPathIncluded(destObjectsDir.relativize(destination));
    }

    @Override
    public void onConflict(@Nonnull Path source, @Nonnull BasicFileAttributes sourceAttributes,
                           @Nonnull Path destination) {
        if (Files.isRegularFile(destination) && sourceAttributes.size() == MoreFiles.size(destination)) {
            //An object or pack with the same name and size will be the same file.
            vacuumed.put(source, sourceAttributes);
        }
    }

    @Override
    public void onError(@Nonnull Path source, @Nonnull BasicFileAttributes sourceAttributes,
                        @Nonnull Path destination, @Nonnull IOException exception) {
        log.info("[{}] Could not vacuum {}", repository, source);
    }

    @Override
    public void onSynced(@Nonnull Path source, @Nonnull BasicFileAttributes sourceAttributes,
                         @Nonnull Path destination) {
        vacuumed.put(source, sourceAttributes);
    }

    public void pruneVacuumed() {
        vacuumed.forEach((path, attrs) -> {
            try {
                // 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(path, DosFileAttributeView.class).setReadOnly(false);
                }

                Files.delete(path);
            } catch (IOException e) {
                log.warn("[{}] Could not delete vacuumed file {}", repository, path, e);
            }
        });
    }

    private static boolean isIncludedDirectory(Path relativePath) {
        String name = relativePath.toString();
        return name.isEmpty() || "pack".equals(name) || name.length() == 2;
    }

    private static boolean isObjectsPathIncluded(Path relativePath) {
        switch (relativePath.getNameCount()) {
            case 0:
                //The objects directory itself should be included
                return true;
            case 1:
                //Include the loose objects directories and the packs dir
                return isIncludedDirectory(relativePath);
            case 2:
                //Include any loose object, or any entry under objects/packs other than tmp_pack_*
                if (!isIncludedDirectory(relativePath.getName(0))) {
                    return false;
                }
                //Git usually creates pack files it's writing as tmp_pack_XXXXX, but will sometimes also use
                //.tmp-XXXXX.pack (git repack). Loose files are created as tmp_obj_XXXXXX, so these will also be
                //skipped.
                String filename = relativePath.getFileName().toString();
                return !filename.startsWith("tmp") && !filename.startsWith(".tmp");
            default:
                return false;
        }
    }

    private static boolean isYoungerThan(BasicFileAttributes attrs, int time, ChronoUnit unit) {
        return attrs.creationTime().toInstant().isAfter(Instant.now().minus(time, unit));
    }

    private boolean isKeptPackFile(Path path) {
        if (PATH_PACK.equals(path.getParent().getFileName())) {
            String filename = path.getFileName().toString();
            int index = filename.lastIndexOf('.');
            if (index > 0) {
                String pack = filename.substring(0, index);
                if (keptPacks.contains(pack)) {
                    //We've already encountered the keep file. Even if it no longer exists, we'll treat it as
                    //if it still exists to avoid for instance skipping the .pack file but vacuuming the
                    //matching .bitmap or .idx file. We should vacuum all pack-<xyz>.* files as a unit.
                    return true;
                }
                Path keepFilePath = path.resolveSibling(pack + ".keep");
                try {
                    BasicFileAttributes attrs = Files.readAttributes(keepFilePath, BasicFileAttributes.class);
                    if (attrs.isRegularFile() && isYoungerThan(attrs, 2, ChronoUnit.DAYS)) {
                        keptPacks.add(pack);
                        return true;
                    }
                } catch (NoSuchFileException | FileNotFoundException e) {
                    //.keep file does not exist
                    return false;
                } catch (IOException e) {
                    log.warn("[{}] Failed to check whether {} exists",
                            repository, repository.getPath().relativize(keepFilePath), e);
                }
            }
        }
        return false;
    }
}
