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

import com.atlassian.bitbucket.mesh.io.MoreFiles;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.regex.Pattern;

import static com.atlassian.bitbucket.mesh.git.GitConstants.PATH_OBJECTS;

/**
 * A {@code FileVisitor} implementation which copies loose object files from the work tree to the
 * {@code objects} directory of the destination repository.
 */
public class CopyLooseObjectsVisitor extends SimpleFileVisitor<Path> {

    private static final Logger log = LoggerFactory.getLogger(CopyLooseObjectsVisitor.class);
    private static final Pattern PATTERN_OBJECT_DIR = Pattern.compile("[a-f0-9]{2}");
    private static final Pattern PATTERN_OBJECT_FILE = Pattern.compile("[a-f0-9]{38}");

    private final Path objectsDir;

    private boolean tryMove;

    public CopyLooseObjectsVisitor(Path repositoryDir, boolean tryMove) {
        this.tryMove = tryMove;

        objectsDir = repositoryDir.resolve(PATH_OBJECTS);
    }

    /**
     * Retrieves a flag indicating whether the provided {@code repositoryDir} and {@code workTree} are on the same
     * {@code FileStore}. If they're not, atomic moves between them will not be possible.
     *
     * @return {@code true} if the {@code repositoryDir} and {@code workTree} are on the same {@code FileStore},
     * meaning atomic moves should be possible; otherwise, {@code false} if they're on separate stores
     */
    public static boolean isSameFileStore(Path repositoryDir, Path workTree) {
        try {
            return MoreFiles.isSameFileStore(repositoryDir, workTree);
        } catch (IOException e) {
            log.warn("Could not check FileStores for {} and {}", repositoryDir, workTree, e);

            return false;
        }
    }

    /**
     * Limits the traversal to the work tree's {@code objects} directory and its immediate object directory
     * descendants. Object directories are identified by having names containing exactly two hex characters.
     *
     * @param dir   the directory to filter on
     * @param attrs ignored
     * @return {@code CONTINUE} for object directories, otherwise {@code SKIP_SUBTREE}
     */
    @Override
    public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) {
        String dirName = dir.getFileName().toString();

        return PATH_OBJECTS.equals(dirName) || PATTERN_OBJECT_DIR.matcher(dirName).matches() ?
                FileVisitResult.CONTINUE :
                FileVisitResult.SKIP_SUBTREE; //Most likely "info" or "pack" directories
    }

    /**
     * Copies the file to the destination repository's {@code objects} directory if it is an object file.
     * Object files are identified by having names containing exactly 38 hex characters.
     *
     * @param file  the file to potentially copy
     * @param attrs ignored
     * @return {@code CONTINUE}
     * @throws IOException if copying an object file fails
     */
    @Override
    public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
        Path fileName = file.getFileName();
        if (PATTERN_OBJECT_FILE.matcher(fileName.toString()).matches()) {
            Path objectDir = objectsDir.resolve(file.getParent().getFileName());
            Path objectFile = objectDir.resolve(fileName);

            //Checking for existence here is racy, but the move and copy strategies both include handling
            //for the case where the loose object appears after this check but before the move/copy. This
            //check is just intended to avoid the exception overhead in the simple case.
            if (!Files.exists(objectFile)) {
                Files.createDirectories(objectDir);

                if (!(tryMove && moveObject(file, objectFile))) {
                    copyObject(file, objectFile);
                }
            }
        }

        return FileVisitResult.CONTINUE;
    }

    /**
     * If {@link #moveObject moving} the loose object fails, try to copy it. To ensure concurrent {@code git}
     * processes don't ever see partially-written loose objects, the object is written to a temporary file in
     * the destination repository and then atomically renamed into place. This mirrors how Git itself creates
     * new loose objects.
     *
     * @param srcObject the loose object in the temporary work tree
     * @param dstObject the loose object in the repository
     * @throws IOException if copy to a temporary file, or renaming that temporary file, fails
     */
    private void copyObject(Path srcObject, Path dstObject) throws IOException {
        //The implementation for Files.createTempFile will never return the same temporary file for concurrent
        //requests, so there's no need to worry about another concurrent fetch getting the same one (even when
        //the concurrent fetch is happening on another cluster node)
        Path tmpObject = Files.createTempFile(dstObject.getParent(), "atl-", null);
        try {
            Files.copy(srcObject, tmpObject, StandardCopyOption.COPY_ATTRIBUTES, StandardCopyOption.REPLACE_EXISTING);

            try {
                Files.move(tmpObject, dstObject, StandardCopyOption.ATOMIC_MOVE);
            } catch (AccessDeniedException e) {
                //This will either fall through, if the destination object exists, or rethrow the exception
                //if the loose object couldn't be created
                onAccessDenied(dstObject, e);
            } catch (FileAlreadyExistsException e) {
                //If the loose object has appeared in the repository since we started copying, that's fine
            }
        } finally {
            Files.deleteIfExists(tmpObject);
        }
    }

    /**
     * As an optimization for standalone instances, which represent the majority of installs, try to atomically
     * move the object from the temporary clone to the target repository. This pairs the best performance with
     * concurrency safety (meaning other {@code git} processes will never see partially-written objects).
     *
     * @param srcObject the loose object in the temporary work tree
     * @param dstObject the loose object in the repository
     * @return {@code true} if the object was moved successfully; otherwise, {@code false} if it must be copied
     */
    private boolean moveObject(Path srcObject, Path dstObject) throws AccessDeniedException {
        try {
            Files.move(srcObject, dstObject, StandardCopyOption.ATOMIC_MOVE);
        } catch (AccessDeniedException e) {
            //This will either fall through and return true, if the destination object exists, or rethrow
            //the exception if the loose object couldn't be created
            onAccessDenied(dstObject, e);
        } catch (FileAlreadyExistsException e) {
            //If the object is already loose in the destination repository, indicate the move was
            //successful to preempt trying to copy it
        } catch (IOException e) {
            //If atomic moves are not supported between the temporary work tree and the target repository,
            //don't try any more moves. One move failing this way means they likely all will
            tryMove = !(e instanceof AtomicMoveNotSupportedException);

            //If an atomic move can't be performed, or the move fails for any other reason, rather than
            //immediately giving up, we should still try to copy the file.
            return false;
        }

        //If we make it here we either caught a FileAlreadyExistsException or the move succeeded
        return true;
    }

    /**
     * Handles {@code AccessDeniedException}s raised while attempting to write loose objects.
     * <p>
     * Git writes loose objects with {@code r--r--r--} (444) permissions. Especially on Windows this appears to
     * trigger an {@code AccessDeniedException} when the destination loose object already exists, rather than
     * the more desirable {@code FileAlreadyExistsException}. Unfortunately, that {@code AccessDeniedException}
     * can <i>also</i> be triggered if the system lacks the necessary permissions to <i>create</i> the new loose
     * object.
     * <p>
     * Based on that, if the destination object exists the exception is <i>ignored</i>; otherwise the exception
     * is assumed to indicate the object could not be created and is rethrown.
     *
     * @param dstObject the destination object
     * @param e         the {@code AccessDeniedException} to handle
     * @throws AccessDeniedException if the destination object does not exist, indicating the system lacks the
     *                               necessary filesystem permissions to create it
     */
    private void onAccessDenied(Path dstObject, AccessDeniedException e) throws AccessDeniedException {
        if (!Files.exists(dstObject)) {
            throw e;
        }
    }
}
