package com.atlassian.bitbucket.mesh.io;

import com.google.common.base.MoreObjects;
import com.google.common.collect.ImmutableSet;
import org.apache.commons.lang3.mutable.MutableLong;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.annotation.Nonnull;
import java.io.*;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.nio.file.attribute.FileTime;
import java.nio.file.attribute.PosixFilePermission;
import java.time.Duration;
import java.time.Instant;
import java.util.*;
import java.util.regex.Pattern;
import java.util.stream.Stream;

import static java.util.Objects.requireNonNull;

/**
 * Additional utility methods missing from {@link Files}.
 */
public class MoreFiles {

    //Copy options
    private static final CopyOption[] COPY_OPTIONS_ATTRIBUTES = {StandardCopyOption.COPY_ATTRIBUTES};
    private static final CopyOption[] COPY_OPTIONS_ATTRIBUTES_AND_REPLACE =
            {StandardCopyOption.COPY_ATTRIBUTES, StandardCopyOption.REPLACE_EXISTING};

    private static final Pattern PATH_UNSAFE = Pattern.compile("[^\\p{Alnum}\\-._]+");

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

    private MoreFiles() {
        throw new UnsupportedOperationException(getClass().getName() +
                " is a utility class and should not be instantiated");
    }

    /**
     * An NFS-safe version of {@link Files#exists(Path, LinkOption...)}} which actually opens the file to
     * verify its existence.
     * <p>
     * NFS can cache file stat information, which can lead to {@link Files#exists(Path, LinkOption...)}}
     * returning {@code true} for files that no longer exist when files are deleted on other nodes. The only
     * way to <i>ensure</i> the file really exists is to open it, which won't be fooled by cached stat information.
     *
     * @param path the path to the file to test
     * @return {@code true} if the file can be opened; otherwise, {@code false} if not
     */
    public static boolean canOpen(@Nonnull Path path) {
        requireNonNull(path, "path");

        try (InputStream ignored = Files.newInputStream(path)) {
            return true;
        } catch (IOException e) {
            return false;
        }
    }

    /**
     * Deletes any files or subdirectories in the specified directory and leaves the directory empty.
     *
     * @param directory the directory to clean
     * @throws IOException if the path does not denote a directory, or the directory's contents could not
     *                     be deleted
     */
    public static void cleanDirectory(@Nonnull Path directory) throws IOException {
        requireNonNull(directory, "directory");

        try (Stream<Path> entries = Files.list(directory)) {
            for (Path entry : (Iterable<Path>) entries::iterator) {
                BasicFileAttributes attributes = Files.readAttributes(entry, BasicFileAttributes.class);
                if (attributes.isDirectory()) {
                    //If the entry is a directory, recursively delete it
                    deleteRecursively(entry);
                } else {
                    //Otherwise, for files and symbolic links, delete them directly
                    Files.delete(entry);
                }
            }
        }
    }

    /**
     * Atomically copies a file from a {@code source} {@link Path path} to a {@code destination} {@link Path}. The
     * copy is atomic in that {@code source} is first copied to a temporary file (on the same file system). Next, the
     * temporary file is <em>atomically</em> moved to {@code destination}, overwriting any existing file if
     * {@code replace} is set to {@code true}.
     *
     * @param source      the file to copy
     * @param destination the location to copy the file to
     * @param replace     whether an existing file at {@code destination} should be overwritten
     * @return {@code true} if the file was copied successfully; {@code false} if a file existed at {@code destination}
     * that was not overwritten because {@code replace} was set to {@code false}
     * @throws IOException if the file could not be written at destination, or if a directory is in the
     */
    public static boolean copyFileAtomic(@Nonnull Path source, @Nonnull Path destination, boolean replace) throws IOException {
        //We need to do this check up front because ATOMIC_MOVE will just overwrite a conflicting file on most
        //file systems. Note however that the Files.move javadoc states that this is implementation specific.
        if (!replace && Files.isRegularFile(destination)) {
            //There's a file at destination and we shouldn't overwrite it
            return false;
        }
        //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 operation getting the same one.
        Path tmpObject = Files.createTempFile(destination.getParent(), "atl-", null);
        try {
            Files.copy(source, tmpObject, StandardCopyOption.COPY_ATTRIBUTES, StandardCopyOption.REPLACE_EXISTING);

            try {
                Files.move(tmpObject, destination, StandardCopyOption.ATOMIC_MOVE);
            } catch (IOException e) {
                if (!replace && Files.isRegularFile(destination)) {
                    //If a file was written to destination while we were copying to tmpObject and replace is set to
                    //false, we can just delete the tmpObject and return
                    return false;
                }
                throw e;
            }
        } finally {
            try {
                Files.delete(tmpObject);
            } catch (NoSuchFileException e) {
                //If the file doesn't exist, there's nothing to do
            }
        }
        return true;
    }

    /**
     * Creates a hard link from {@code existing} to {@code path}. If {@code path} already exists and {@code replace} is
     * {@code true}, it will be replaced with a hard link. Otherwise, {@code path} will be left as-is.
     *
     * @param path     the <i>target</i> path where the link should be created
     * @param existing the <i>source</i> path to hard link to the the target
     * @param replace  {@code true} if any existing file at {@code path} should be replaced; otherwise, {@code false}
     * @return {@code true} if a hard link was created, or already exists; otherwise, {@code false}
     * @throws IOException if a link could not be created for any reason (underlying failure, hard links unsupported,
     *                     etc.), or if {@code path} exists and is not a file
     */
    public static boolean createHardLink(@Nonnull Path path, @Nonnull Path existing, boolean replace) throws IOException {
        try {
            Files.createLink(path, existing);
        } catch (FileAlreadyExistsException e) {
            if (!Files.isRegularFile(path)) {
                throw e;
            }
            if (!replace) {
                return false;
            }
            if (Files.isSameFile(path, existing)) {
                //This is indistinguishable from the file being overwritten
                return true;
            }
            //There's a file at the destination path, but we cannot force overwrite it while creating a link.
            //So instead, we'll create the link at a temporary path and then atomically move the tmp file to the
            //destination path.
            Path tmpFile = path.resolveSibling("atl-" + path.getFileName() + "-" + System.nanoTime());
            try {
                Files.createLink(tmpFile, existing);
                Files.move(tmpFile, path, StandardCopyOption.ATOMIC_MOVE);
            } catch (IOException innerException) {
                throw e;
            } finally {
                try {
                    Files.delete(tmpFile);
                } catch (IOException innerE) {
                    //If this is most likely a NoSuchFileException, which is fine. Otherwise... we tried but there's not
                    //a lot we can do to improve here
                }
            }
        }
        return true;
    }

    /**
     * Creates a new link (directory entry) for an {@code existing} file. If the underlying file system does not
     * support hard links, the file is copied instead.
     *
     * @param path     the path where the hard link should be created.
     * @param existing the existing file to which a hard link should be created.
     * @param replace  whether to overwrite {@code path} if a different file is already present at {@code path}.
     * @return {@code true} if {@code path} was created or overwritten, otherwise {@code false}
     * @throws IOException if an error occurs
     */
    public static boolean createHardLinkOrCopy(@Nonnull Path path, @Nonnull Path existing, boolean replace) throws IOException {
        try {
            return createHardLink(path, existing, replace);
        } catch (UnsupportedOperationException e) {
            //The FileSystem does not support hard links, fall back to copying
        }

        //Could not create a hard link, fall back to copying
        return copyFileAtomic(existing, path, replace);
    }

    /**
     * {@link #deleteRecursively Recursively deletes} the specified path, and falls back to registering the provided
     * path to be deleted when the JVM exits if that fails.
     *
     * @param path the path to delete, which may be a file or a directory
     */
    public static void deleteNowOrOnExit(@Nonnull Path path) {
        try {
            deleteRecursively(path);
        } catch (IOException ignored) {
            deleteOnExit(path);
        }
    }

    /**
     * Registers the provided path to be deleted when the JVM exits.
     * <p>
     * Delete-on-exit is performed on a <i>best-effort</i> basis, and should not be relied upon as the primary
     * solution for deleting files. Additionally, it only works for files and <i>empty</i> directories.
     *
     * @param path the path to register for deletion when the JVM exits
     * @see File#deleteOnExit()
     */
    public static void deleteOnExit(@Nonnull Path path) {
        requireNonNull(path, "path").toFile().deleteOnExit();
    }

    /**
     * {@link #deleteRecursively Recursively deletes} the specified path, suppressing any {@link IOException}s
     * that are thrown.
     *
     * @param path the path to delete, which may be a file or a directory
     * @return {@code true} if the path was deleted; otherwise, {@code false} if it did not exist or could not
     * be deleted
     */
    public static boolean deleteQuietly(@Nonnull Path path) {
        try {
            deleteRecursively(path);
        } catch (IOException ignored) {
            return false;
        }

        return true;
    }

    /**
     * Recursively deletes the specified path.
     * <p>
     * If the specified {@link Path} denotes a directory, the directory's contents are <i>recursively</i>
     * deleted, depth-first, to empty the directory so it can be deleted. If any files or subdirectories
     * can't be deleted, an exception will be thrown. <i>Note that some files and subdirectories may have
     * been deleted prior to the exception being thrown.</i> Additionally, if the directory contains any
     * symbolic links the <i>links themselves</i> are deleted, not their targets.
     * <p>
     * If the specified {@link Path} denotes a symbolic link, the <i>symbolic link itself</i> is deleted,
     * rather than the target of the link. If the path is a symbolic link to a directory, that directory's
     * contents <i>are not removed</i>.
     * <p>
     * If the specified {@link Path} denotes a file, it is deleted.
     * <p>
     * If the specified path does not exist, nothing happens and no exception is thrown.
     *
     * @param path the path to delete, which may be a file or a directory
     * @throws IOException if the specified path cannot be deleted
     */
    public static void deleteRecursively(@Nonnull Path path) throws IOException {
        requireNonNull(path, "path");

        //If "path" is a file, visitFile will be called for it. Otherwise, if it's a directory, it will
        //be recursively traversed, depth-first
        Files.walkFileTree(path, new SimpleFileVisitor<Path>() {

            @Override
            public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException {
                //By postVisitDirectory, any files in the directory should have been deleted
                return deleteIfExists(dir);
            }

            @Override
            public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
                return deleteIfExists(file);
            }

            @Override
            public FileVisitResult visitFileFailed(Path file, IOException exc) throws IOException {
                if (exc instanceof FileNotFoundException || exc instanceof NoSuchFileException) {
                    //If we try to delete a file and it's already been deleted, move on
                    return FileVisitResult.CONTINUE;
                }
                throw exc;
            }

            //Why not Files.deleteIfExists? Because checking existence and deleting aren't atomic, so we
            //need to handle the same exceptions anyway
            private FileVisitResult deleteIfExists(Path path) throws IOException {
                try {
                    Files.delete(path);
                } catch (FileNotFoundException | NoSuchFileException ignored) {
                    //Ignore attempts to delete files that don't exist. This is done instead of calling
                    //Files.deleteIfExists because checking existence and then performing the delete is
                    //not atomic, so we'd need to handle these exceptions anyway
                }

                return FileVisitResult.CONTINUE;
            }
        });
    }

    /**
     * Checks if a directory exists on the filesystem
     *
     * @param dir the {@link Path} to the directory
     * @return {@code true} if the directory exists, {@code false} if not
     */
    public static boolean dirExists(@Nonnull Path dir) {
        requireNonNull(dir, "dir");

        // To avoid any phantom directories on NFS, we open a stream for its files and directories
        try (Stream<Path> ignored = Files.list(dir)) {
            // We don't actually need to read the stream; if we open it, it means the directory exists
            return true;
        } catch (IOException ignored) {
            // This matches what File.list() would have done; any sort of failure is interpreted as
            // the directory not existing
            return false;
        }
    }

    /**
     * Retrieves the duration since the specified {@code file} was last modified.
     *
     * @param file the file to test
     * @return the duration since the file was last modified
     */
    @Nonnull
    public static Duration getDurationSinceLastModified(@Nonnull Path file) {
        return Duration.ofMillis(System.currentTimeMillis() - getLastModified(file));
    }

    /**
     * Checks the file server time used by the file server backing the provided {@code directory}. The file server
     * time is determined by creating a temporary file and returning the file's last modified time. The returned value
     * may differ from the system clock due to differences in precision (e.g. only seconds resolution for files vs
     * millis for the system clock), or due to clock drift between the file server and the local node for NFS volumes.
     * <p>
     * If the temporary file is created successfully, but the file's creation and modification times are both
     * invalid (less than or equal to 0), the <em>Java</em> system time is returned.
     *
     * @param directory the directory
     * @return the time reported by the file server
     * @throws IOException if the provided {@code directory} is not a directory, or no tmp file can be created
     *                     in the directory due to permission restrictions
     */
    @Nonnull
    public static Instant getFileSystemTime(@Nonnull Path directory) throws IOException {
        Path tmpFile = Files.createTempFile(directory, "filetime-check", null);

        try {
            BasicFileAttributes attrs = Files.readAttributes(tmpFile, BasicFileAttributes.class);
            long fileTime = attrs.lastModifiedTime().toMillis();
            if (fileTime <= 0L) {
                //last modified seems invalid. Check the creation time instead.
                fileTime = attrs.creationTime().toMillis();
            }
            return fileTime > 0L ? Instant.ofEpochMilli(fileTime) : Instant.now();
        } finally {
            deleteQuietly(tmpFile);
        }
    }

    /**
     * Gets the {@link Files#getLastModifiedTime last modified time} for the specified {@link Path}, in
     * milliseconds.
     * <p>
     * Like {@link File#lastModified}, if the path does not exist or its attributes cannot be read, {@code 0L}
     * is returned rather than throwing an exception. Use {@link Files#getLastModifiedTime} directly if an
     * exception is desired.
     *
     * @param path the path to retrieve the last modified time for
     * @return the file's last modified time, in milliseconds, or {@code 0L} if the time could not be determined
     */
    public static long getLastModified(@Nonnull Path path) {
        requireNonNull(path, "path");

        try {
            return Files.getLastModifiedTime(path).toMillis();
        } catch (IOException ignored) {
            return 0L;
        }
    }

    /**
     * Gets the {@link Files#getFileStore file store} for the specified {@link Path}, and returns whether the file
     * store supports POSIX. Most Linux filesystems support POSIX, as do APFS and HFS+ on macOS, but, for example,
     * NTFS on Windows does not; it uses ACLs instead.
     *
     * @param path the path to check, which is used to find the appropriate {@link Files#getFileStore file store}
     * @return {@code true} if the path's {@link Files#getFileStore file store} supports POSIX; otherwise, {@code false}
     * @throws IOException if the {@link Files#getFileStore file store} cannot be determined for the specified path
     */
    public static boolean isPosixSupported(@Nonnull Path path) throws IOException {
        return Files.getFileStore(path).supportsFileAttributeView("posix");
    }

    /**
     * Tests whether the two specified paths are on the same underlying {@link FileStore}. Having the same file store
     * is a prerequisite for some operations, like atomic moves and creating hard links.
     *
     * @param path  a path
     * @param other another path
     * @return {@code true} if {@code path} and {@code other} are located on the same {@link FileStore}
     * @throws IOException if an I/O error occurs
     */
    public static boolean isSameFileStore(@Nonnull Path path, @Nonnull Path other) throws IOException {
        requireNonNull(path, "path");
        requireNonNull(other, "other");

        return Files.getFileStore(path).equals(Files.getFileStore(other));
    }

    /**
     * Returns {@code true} if the specified path is contained within the {@code expectedParent}. This should be
     * used to ensure paths created with user-entered data don't "escape" the specified parent, to prevent path
     * traversal attacks.
     *
     * @param path           the path to validate
     * @param expectedParent the required parent directory
     * @return {@code true} if {@code path} is contained within {@code expectedParent}; otherwise, {@code false}
     * @throws IllegalArgumentException if {@code expectedParent} does not exist or is not a directory
     * @throws IOException              if the real path for either of the provided paths cannot be resolved
     */
    public static boolean isWithin(@Nonnull Path path, @Nonnull Path expectedParent) throws IOException {
        requireNonNull(path, "path");
        requireNonNull(expectedParent, "expectedParent");
        if (!Files.isDirectory(expectedParent)) {
            throw new IllegalArgumentException("expectedParent is not a directory");
        }

        return toRealPath(path).startsWith(expectedParent.toRealPath());
    }

    /**
     * Iterates a file tree, similar to {@link #walkFileTreeSorted(Path, FileVisitor)}, but returns an iterator over
     * the {@link PathWithAttributes files} instead of handing them to a visitor.
     *
     * @param start   the starting file
     * @throws UncheckedIOException if an I/O exception is thrown by a visitor method
     * @see Files#walkFileTree(Path, FileVisitor)
     */
    @Nonnull
    public static Iterator<PathWithAttributes> iterateFileTreeSorted(@Nonnull Path start) {
        return new PathWithAttributesIterator(start, Comparator.naturalOrder());
    }

    /**
     * Iterates a file tree, similar to {@link #walkFileTreeSorted(Path, Comparator, FileVisitor)}, but returns an
     * iterator over the {@link PathWithAttributes files} instead of handing them to a visitor.
     *
     * @param start      the starting file
     * @param comparator the comparator used to sort directory contents
     *
     * @throws UncheckedIOException if an I/O exception is thrown by a visitor method
     * @see Files#walkFileTree(Path, FileVisitor)
     */
    @Nonnull
    public static Iterator<PathWithAttributes> iterateFileTreeSorted(@Nonnull Path start,
                                                                     @Nonnull Comparator<Path> comparator) {
        return new PathWithAttributesIterator(start, comparator);
    }

    /**
     * Creates the specified {@code directory}, if it does not already exist. If the path does exist, it is validated
     * that it is a directory and not a file.
     *
     * @param directory the directory to create
     * @return the created directory
     * @throws IllegalStateException if the {@code directory} path already exists and is not a directory, or if the
     *                               directory cannot be created
     * @throws NullPointerException  if the provided {@code directory} is {@code null}
     */
    @Nonnull
    public static Path mkdir(@Nonnull Path directory) {
        requireNonNull(directory, "directory");

        try {
            return Files.createDirectories(directory);
        } catch (IOException e) {
            if (e instanceof FileAlreadyExistsException && Files.isDirectory(directory)) { //Follow links
                //In general File.createDirectories won't throw this, but it will if the target exists and isn't a
                //directory. Unfortunately, Files.createAndCheckIsDirectory doesn't allow symbolic links, which we
                //use, and customers use, to help divide the home directory up
                return directory;
            }

            throw new IllegalStateException("Could not create " + directory.toAbsolutePath(), e);
        }
    }

    /**
     * Creates the specified {@code child} directory beneath the {@code parent}, if it does not already exist. If the
     * path does exist, it is validated that it is a directory and not a file.
     *
     * @param parent the base path for creating the new directory
     * @param child  the path beneath the parent for the new directory
     * @return the created directory
     * @throws IllegalArgumentException if the {@code child} path is blank or empty
     * @throws IllegalStateException    if the {@code child} path already exists and is not a directory, or if the
     *                                  directory cannot be created
     * @throws NullPointerException     if the provided {@code parent} or {@code child} is {@code null}
     */
    @Nonnull
    public static Path mkdir(@Nonnull Path parent, @Nonnull String child) {
        requireNonNull(parent, "parent");
        if (requireNonNull(child, "child").trim().isEmpty()) {
            throw new IllegalArgumentException("A path for the created directory is required");
        }

        return mkdir(parent.resolve(child));
    }

    /**
     * Creates a path-safe version of a string, replacing all potentially unsafe characters with a {@code -} character.
     *
     * @param value the value to transform into a path safe string
     * @return the path safe string
     */
    @Nonnull
    public static String pathSafe(@Nonnull String value) {
        return PATH_UNSAFE.matcher(value).replaceAll("-");
    }

    /**
     * Performs a depth-first traversal of the specified path and removes any empty subdirectories. If removing empty
     * subdirectories leaves the specified {@code path} empty, it is <i>not</i> removed. If the specified {@code path}
     * does not exist, nothing is done.
     *
     * @param path the path beneath which empty subdirectories should be removed
     * @throws IOException if traversing the specified {@code path} fails
     */
    public static void pruneEmptyDirectories(@Nonnull Path path) throws IOException {
        try {
            Files.walkFileTree(path, new SimpleFileVisitor<Path>() {

                private final Set<Path> dirsWithFiles = new HashSet<>();

                @Override
                public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
                    Path parent = file.getParent();
                    while (!parent.equals(path) && dirsWithFiles.add(parent)) {
                        parent = parent.getParent();
                    }
                    return FileVisitResult.CONTINUE;
                }

                @Override
                public FileVisitResult postVisitDirectory(Path dir, IOException exc) {
                    if (!dirsWithFiles.remove(dir) && !path.equals(dir)) {
                        try {
                            Files.delete(dir);
                        } catch (DirectoryNotEmptyException | FileNotFoundException | NoSuchFileException e) {
                            // Nothing to do
                        } catch (IOException e) {
                            log.debug("Could not delete empty directory {}", dir, e);
                        }
                    }
                    return FileVisitResult.CONTINUE;
                }
            });
        } catch (FileNotFoundException | NoSuchFileException e) {
            // Nothing to prune
        }
    }

    /**
     * Deletes all files under the provided {@code path} that have a {@code lastModifiedTime} older than the provided
     * {@code timestamp}. If {@code pruneEmptyDirs} is {@code true} then we will also attempt to delete the directory.
     *
     * @param path           the directory to start the prune from
     * @param timestamp      the timestamp
     * @param pruneEmptyDirs flag to determine whether to delete the folder or not
     * @param callback       the callback when deleting
     */
    public static void pruneStaleFiles(@Nonnull Path path, @Nonnull Instant timestamp, boolean pruneEmptyDirs,
                                       @Nonnull PruneStaleFileCallback callback)
            throws IOException {
        requireNonNull(callback, "callback");
        requireNonNull(path, "path");
        requireNonNull(timestamp, "timestamp");

        //Subtract 1m to cope with time resolution differences between Instant and the file system's lastModifiedTime,
        //and potential differences between the local clock and the file server clock when dealing with NFS volumes.
        long gracePeriod = Math.max(1000L, Long.getLong("atl.mesh.stale.file.grace.millis", 60_000L));
        long timestampMillis = timestamp.toEpochMilli() - gracePeriod;
        try {
            Files.walkFileTree(path, new SimpleFileVisitor<Path>() {

                @Override
                public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException {
                    if (pruneEmptyDirs && !path.equals(dir)) {
                        try {
                            BasicFileAttributes attrs = Files.readAttributes(dir, BasicFileAttributes.class);
                            //Try to delete the directory; we'll fail if it's not empty
                            Files.delete(dir);
                            callback.onDeleted(dir, attrs);
                        } catch (NoSuchFileException | DirectoryNotEmptyException | FileNotFoundException e) {
                            //If the directory doesn't exist, or isn't empty, it's fine to not delete it
                        } catch (IOException e) {
                            callback.onError(path, e);
                        }
                    }
                    return FileVisitResult.CONTINUE;
                }

                @Override
                public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
                    long lastModifiedMillis = attrs.lastModifiedTime().toMillis();
                    //A file is only considered stale if the file system reports a valid modification time
                    //and the file was last modified before the provided timestamp.
                    if (lastModifiedMillis > 0L && lastModifiedMillis < timestampMillis) {
                        try {
                            Files.delete(file);
                            callback.onDeleted(file, attrs);
                        } catch (NoSuchFileException | FileNotFoundException e) {
                            //If the file doesn't exist, there's nothing to delete
                        } catch (IOException e) {
                            callback.onError(path, e);
                        }
                    }
                    return FileVisitResult.CONTINUE;
                }
            });
        } catch (NoSuchFileException e) {
            //We can only get this exception if path does not exist, in which case there's nothing to prune
        }
    }

    /**
     * Validates that the specified path is contained within the {@code expectedParent}. This should be used to
     * ensure paths created with user-entered data don't "escape" the specified parent, to prevent path traversal
     * attacks.
     *
     * @param path           the path to validate
     * @param expectedParent the required parent directory
     * @throws IllegalArgumentException if {@code expectedParent} does not exist or is not a directory, or if
     *                                  {@code path} is not contained within it
     * @throws IOException              if the real path for either of the provided paths cannot be resolved
     */
    public static void requireWithin(@Nonnull Path path, @Nonnull Path expectedParent) throws IOException {
        if (!isWithin(path, expectedParent)) {
            throw new IllegalArgumentException(path + " is not contained within " + expectedParent);
        }
    }

    /**
     * Simplifies {@link Path#resolve(String) resolving} several subpaths in a single call.
     *
     * @param path  the base path, from which subpaths should be resolved
     * @param first the first subpath, used to enforce that at least a single subpath is provided
     * @param more  zero or more additional subpaths
     * @return the resolved path
     */
    @Nonnull
    public static Path resolve(@Nonnull Path path, @Nonnull String first, @Nonnull String... more) {
        return requireNonNull(path, "path").resolve(Paths.get(first, more));
    }

    /**
     * Sets the file permissions according to the specifications provided in the {@link SetFilePermissionRequest},
     * using the corresponding {@link PosixFilePermission}s. If the filesystem doesn't support POSIX permissions
     * an {@link IOException} will be thrown.
     *
     * @param request describes the file permissions to set
     * @throws IllegalArgumentException if the provided {@link SetFilePermissionRequest#getPath path} does not exist,
     *                                  or exists and is not a regular file
     * @throws IOException              if the real path for either of the provided paths cannot be resolved, or if
     *                                  the filesystem does not support POSIX
     */
    public static void setPermissions(@Nonnull SetFilePermissionRequest request) throws IOException {
        Path path = requireNonNull(request, "request").getPath();
        if (!Files.isRegularFile(path)) {
            throw new IllegalArgumentException("The provided file, " + path + ", does not exist or is not a file");
        }

        FileStore fileStore = Files.getFileStore(path);
        if (fileStore.supportsFileAttributeView("posix")) { //Linux, macOS
            Files.setPosixFilePermissions(path, toPosixFilePermissions(request));
        } else {
            // If you're not POSIX compliant then just throw an IOException to tell the caller
            // file permissions could not be set.
            throw new IOException("Could not update file permissions for " + path.toAbsolutePath() +
                    "; the file store (" + fileStore + ") does not support POSIX permissions");
        }
    }

    /**
     * Gets the {@link Files#size size} of the specified {@link Path}.
     * <p>
     * Like {@link File#length}, if the path does not exist or its attributes cannot be read, {@code 0L} is
     * returned rather than throwing an exception. Use {@link Files#size} directly if an exception is desired.
     *
     * @param path the path to retrieve the size of
     * @return the file's size, or {@code 0L} if the size could not be determined
     */
    public static long size(@Nonnull Path path) {
        requireNonNull(path, "path");

        try {
            return Files.size(path);
        } catch (IOException e) {
            return 0L;
        }
    }

    /**
     * Synchronizes (part of) a file tree from {@code source} to {@code destination}.
     *
     * @param parameters describes which parts of the file to synchronize and
     * @throws IOException if an error occurs
     */
    public static void syncFileTree(@Nonnull SyncFileTreeParameters parameters) throws IOException {
        Path source = parameters.getSource();
        Path destination = parameters.getDestination();
        Instant modifiedSince = MoreObjects.firstNonNull(parameters.getModifiedSince(), Instant.MIN);

        SyncFileTreeCallback callback = parameters.getCallback();
        if (parameters.isDetectMissing() && Files.exists(destination)) {
            Files.walkFileTree(destination, new SimpleFileVisitor<Path>() {

                @Override
                public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) {
                    if (callback.isDestinationIncluded(dir, attrs)) {
                        return FileVisitResult.CONTINUE;
                    }
                    return FileVisitResult.SKIP_SUBTREE;
                }

                @Override
                public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
                    Path sourceFile = source.resolve(destination.relativize(file));
                    if (callback.isDestinationIncluded(file, attrs) && !Files.exists(sourceFile)) {
                        callback.onMissingInSource(sourceFile, file);
                    }
                    return FileVisitResult.CONTINUE;
                }

                @Override
                public FileVisitResult visitFileFailed(Path file, IOException exc) throws IOException {
                    if (exc instanceof NoSuchFileException || exc instanceof FileNotFoundException) {
                        log.debug("Concurrent delete detected: {}", exc.getMessage());
                        return FileVisitResult.CONTINUE;
                    }
                    return super.visitFileFailed(file, exc);
                }
            });
        }

        if (Files.exists(source)) {
            //If "source" is a file, visitFile will be called for it. Otherwise, if it's a directory, it will
            //be recursively traversed, depth-first
            Files.walkFileTree(source, new SimpleFileVisitor<Path>() {

                private final boolean replace = parameters.isReplaceExisting();

                private boolean tryAtomicCopy = true;
                private boolean tryHardLinks = parameters.isAllowHardLinks();

                @Override
                public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException {
                    if (callback.isSourceIncluded(dir, attrs)) {
                        Path destDir = destination.resolve(source.relativize(dir));
                        try {
                            mkdir(destDir);
                            return FileVisitResult.CONTINUE;
                        } catch (IllegalStateException e) {
                            if (Files.isRegularFile(destDir)) {
                                callback.onConflict(dir, attrs, destDir);
                            }
                        }
                    }
                    return FileVisitResult.SKIP_SUBTREE;
                }

                @Override
                public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
                    if (attrs.lastModifiedTime().toInstant().isAfter(modifiedSince) &&
                            callback.isSourceIncluded(file, attrs)) {
                        Path destinationFile = destination.resolve(source.relativize(file));
                        try {
                            hardLinkOrCopy(file, attrs, destinationFile);
                        } catch (IOException e) {
                            callback.onError(file, attrs, destinationFile, e);
                        }
                    }
                    return FileVisitResult.CONTINUE;
                }

                @Override
                public FileVisitResult visitFileFailed(Path file, IOException exc) throws IOException {
                    if (exc instanceof NoSuchFileException || exc instanceof FileNotFoundException) {
                        log.debug("Concurrent delete detected: {}", exc.getMessage());
                        return FileVisitResult.CONTINUE;
                    }
                    return super.visitFileFailed(file, exc);
                }

                private void hardLinkOrCopy(Path srcFile, BasicFileAttributes srcAttributes,
                                            Path destFile) throws IOException {
                    if (Files.isDirectory(destFile)) {
                        callback.onConflict(srcFile, srcAttributes, destFile);
                        return;
                    }

                    if (tryHardLinks) {
                        try {
                            if (createHardLink(destFile, srcFile, replace)) {
                                callback.onSynced(srcFile, srcAttributes, destFile);
                            } else {
                                callback.onConflict(srcFile, srcAttributes, destFile);
                            }
                            return;
                        } catch (UnsupportedOperationException e) {
                            //The file system does not support hard links, fall back to copying
                            tryHardLinks = false;
                        }
                    }
                    if (tryAtomicCopy) {
                        try {
                            if (copyFileAtomic(srcFile, destFile, replace)) {
                                callback.onSynced(srcFile, srcAttributes, destFile);
                            } else {
                                callback.onConflict(srcFile, srcAttributes, destFile);
                            }
                            return;
                        } catch (AtomicMoveNotSupportedException e) {
                            tryAtomicCopy = false;
                        }
                    }
                    //No atomic move supported, fall back to a 'plain' copy
                    try {
                        Files.copy(srcFile, destFile,
                                replace ? COPY_OPTIONS_ATTRIBUTES_AND_REPLACE : COPY_OPTIONS_ATTRIBUTES);
                        callback.onSynced(srcFile, srcAttributes, destFile);
                    } catch (FileAlreadyExistsException e) {
                        callback.onConflict(srcFile, srcAttributes, destFile);
                    }
                }
            });
        }
    }

    /**
     * Calculates the <i>total size</i> of the specified path.
     * <p>
     * If the specified {@link Path} denotes a directory, its contents are <i>recursively traversed</i> to
     * compute its overall size. For directories containing a large number of files this can be quite slow,
     * so it should be used with caution.
     * <p>
     * If the specified {@link Path} denotes a symbolic link, its size (generally the length of its target
     * path) is reported. Symbolic links are <i>not</i> followed.
     * <p>
     * If the specified {@link Path} denotes a file, its size is reported.
     *
     * @param path the path to calculate a size for
     * @return the calculated size, or {@code 0L} if the size could not be calculated
     */
    public static long totalSize(@Nonnull Path path) {
        requireNonNull(path, "path");

        try {
            MutableLong size = new MutableLong();

            Files.walkFileTree(path, new SimpleFileVisitor<Path>() {

                @Override
                public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
                    size.add(attrs.size());

                    return FileVisitResult.CONTINUE;
                }
            });

            return size.longValue();
        } catch (IOException ignored) {
            return 0L;
        }
    }

    /**
     * {@link Files#createFile Creates} the specified path, if it doesn't exist, or
     * {@link Files#setLastModifiedTime sets its last modified time} if it does.
     *
     * @param path the path to create or set a last modification time for
     * @throws IOException if a nonexistent file cannot be created, or if the last modification time cannot be
     *                     set for an existing file
     */
    public static void touch(@Nonnull Path path) throws IOException {
        try {
            Files.setLastModifiedTime(path, FileTime.from(Instant.now()));
        } catch (FileNotFoundException | NoSuchFileException e) {
            //If the file doesn't exist, create it. The newly-created file should have the
            //current time for its last modified time, so there's no need to set it again
            try {
                Files.createFile(path);
            } catch (FileAlreadyExistsException innerException) {
                //The file was concurrently created.
            }
        }
    }

    /**
     * {@link Files#createFile Creates} the specified path, if it doesn't exist, or
     * {@link Files#setLastModifiedTime sets its last modified time} if it does.
     *
     * @param path      the path to create or set a last modification time for
     * @param timestamp the value of last modified time to set
     * @throws IOException if a nonexistent file cannot be created, or if the last modification time cannot be
     *                     set for an existing file
     */
    public static void touch(@Nonnull Path path, @Nonnull Instant timestamp) throws IOException {
        FileTime fileTime = FileTime.from(timestamp);
        try {
            Files.setLastModifiedTime(path, fileTime);
        } catch (FileNotFoundException | NoSuchFileException e) {
            //If the file doesn't exist, create it.
            Files.createFile(path);
            Files.setLastModifiedTime(path, fileTime);
        }
    }

    /**
     * Returns the <em>real</em> path of an existing file.
     *
     * @param path the path to resolve
     * @return the real path
     * @throws IOException if an I/O error occurs
     */
    @Nonnull
    public static Path toRealPath(@Nonnull Path path) throws IOException {
        requireNonNull(path, "path");

        //File.getCanonical*() doesn't throw if the file doesn't exist, but Path.toRealPath() does. If that
        //happens, we fall back on using File.getCanonical
        try {
            return path.toRealPath();
        } catch (IOException e) {
            //There's no call, or set of calls, on Path that's identical to what File.getCanonicalPath() is
            //doing. Rather than try to emulate the behavior, it's safer to switch to File and back again.
            return Paths.get(path.toFile().getCanonicalPath());
        }
    }

    /**
     * Reads the entire contents of the specified path using the UTF-8 character set.
     * <p>
     * Callers should exercise caution when using this method, as it may result in reading a significant amount
     * of data into memory. Where possible, callers are encouraged to stream file contents instead.
     *
     * @param path the path to read
     * @return the file's contents
     * @throws IOException if the file cannot be read
     */
    @Nonnull
    public static String toString(@Nonnull Path path) throws IOException {
        return toString(path, StandardCharsets.UTF_8);
    }

    /**
     * Reads the entire contents of the specified path using the provided character set.
     * <p>
     * Callers should exercise caution when using this method, as it may result in reading a significant amount
     * of data into memory. Where possible, callers are encouraged to stream file contents instead.
     *
     * @param path    the path to read
     * @param charset the character set to use to decode the file's contents
     * @return the file's contents
     * @throws IOException if the file cannot be read
     */
    @Nonnull
    public static String toString(@Nonnull Path path, @Nonnull Charset charset) throws IOException {
        requireNonNull(path, "path");
        requireNonNull(charset, "charset");

        return new String(Files.readAllBytes(path), charset);
    }

    /**
     * Walks a file tree, similar to {@link Files#walkFileTree(Path, FileVisitor)}, but with each directory's contents
     * ordered by name (ascending).
     *
     * @param start   the starting file
     * @param visitor the visitor to invoke for each file
     * @throws IOException if an I/O exception is thrown by a visitor method
     * @see Files#walkFileTree(Path, FileVisitor)
     */
    public static void walkFileTreeSorted(@Nonnull Path start,
                                          @Nonnull FileVisitor<? super Path> visitor) throws IOException {
        walkFileTreeSorted(start, Comparator.naturalOrder(), visitor);
    }

    /**
     * Walks a file tree, similar to {@link Files#walkFileTree(Path, FileVisitor)}, but with each directory's contents
     * ordered using the provided {@code comparator}.
     * <p>
     * Note that the comparator is used to sort <em>directory contents</em> and not the complete tree. As a result,
     * directories are always returned before their contents even if the provided {@code comparator} would sort them
     * in a different order. For instance, when walking a tree sorted by name descending, expect the following:
     * <ul>
     *     <li>file2.txt</li>
     *     <li>file1.txt</li>
     *     <li>dir2/</li>
     *     <li>dir2/file2.txt</li>
     *     <li>dir2/file1.txt</li>
     *     <li>dir1/</li>
     *     <li>dir1/file.txt</li>
     *     <li>another-file.txt</li>
     * </ul>
     *
     * @param start      the starting file
     * @param comparator the comparator to use for sorting directory contents
     * @param visitor    the visitor to invoke for each file
     * @throws IOException if an I/O exception is thrown by a visitor method
     * @see Files#walkFileTree(Path, FileVisitor)
     */
    public static void walkFileTreeSorted(@Nonnull Path start, @Nonnull Comparator<Path> comparator,
                                          @Nonnull FileVisitor<? super Path> visitor) throws IOException {
        BasicFileAttributes attributes = Files.readAttributes(start, BasicFileAttributes.class);
        if (attributes.isDirectory()) {
            if (visitor.preVisitDirectory(start, attributes) == FileVisitResult.CONTINUE) {
                walkDirSorted(start, comparator, visitor);
                visitor.postVisitDirectory(start, null);
            }
        } else {
            visitor.visitFile(start, attributes);
        }
    }

    /**
     * Writes the provided value to the specified path using the UTF-8 character set.
     *
     * @param path    the path to write
     * @param value   the value to write
     * @param options {@link OpenOption}s to control how the path is accessed
     * @return the provided path
     * @throws IOException if the file cannot be written
     */
    @Nonnull
    public static Path write(@Nonnull Path path, @Nonnull String value, @Nonnull OpenOption... options)
            throws IOException {
        return write(path, value, StandardCharsets.UTF_8, options);
    }

    /**
     * Writes the provided value to the specified path using the provided character set.
     *
     * @param path    the path to write
     * @param value   the value to write
     * @param charset the character set to use to encode the value to bytes
     * @param options {@link OpenOption}s to control how the path is accessed
     * @return the provided path
     * @throws IOException if the file cannot be written
     */
    @Nonnull
    public static Path write(@Nonnull Path path, @Nonnull String value, @Nonnull Charset charset,
                             @Nonnull OpenOption... options) throws IOException {
        requireNonNull(path, "path");
        requireNonNull(value, "value");
        requireNonNull(charset, "charset");

        return Files.write(path, value.getBytes(charset), options);
    }

    private static List<PathWithAttributes> listDirSorted(Path path, Comparator<Path> comparator) throws IOException {
        List<PathWithAttributes> result = new ArrayList<>();
        try (DirectoryStream<Path> stream = Files.newDirectoryStream(path)) {
            stream.forEach(p -> result.add(new PathWithAttributes(p)));
        }
        result.sort(Comparator.comparing(PathWithAttributes::getPath, comparator));
        return result;
    }

    private static Set<PosixFilePermission> toPosixFilePermissions(SetFilePermissionRequest request) {
        ImmutableSet.Builder<PosixFilePermission> posixPermissions = ImmutableSet.builder();

        request.getOwnerPermissions().stream().map(permission -> {
            switch (permission) {
                case EXECUTE:
                    return PosixFilePermission.OWNER_EXECUTE;
                case READ:
                    return PosixFilePermission.OWNER_READ;
                default:
                    return PosixFilePermission.OWNER_WRITE;
            }
        }).forEach(posixPermissions::add);

        request.getGroupPermissions().stream().map(permission -> {
            switch (permission) {
                case EXECUTE:
                    return PosixFilePermission.GROUP_EXECUTE;
                case READ:
                    return PosixFilePermission.GROUP_READ;
                default:
                    return PosixFilePermission.GROUP_WRITE;
            }
        }).forEach(posixPermissions::add);

        request.getWorldPermissions().stream().map(permission -> {
            switch (permission) {
                case EXECUTE:
                    return PosixFilePermission.OTHERS_EXECUTE;
                case READ:
                    return PosixFilePermission.OTHERS_READ;
                default:
                    return PosixFilePermission.OTHERS_WRITE;
            }
        }).forEach(posixPermissions::add);

        return posixPermissions.build();
    }

    private static boolean walkDirSorted(Path path, Comparator<Path> comparator,
                                         FileVisitor<? super Path> visitor) throws IOException {
        Map<Path, BasicFileAttributes> attributes = new TreeMap<>(comparator); //Use a TreeMap to sort alphabetically
        Files.walkFileTree(path, new SimpleFileVisitor<Path>() {

            @Override
            public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) {
                if (dir.equals(path)) {
                    return FileVisitResult.CONTINUE;
                }
                attributes.put(dir, attrs);
                return FileVisitResult.SKIP_SUBTREE;
            }

            @Override
            public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
                attributes.put(file, attrs);
                return FileVisitResult.CONTINUE;
            }

            @Override
            public FileVisitResult visitFileFailed(Path file, IOException exc) throws IOException {
                return visitor.visitFileFailed(file, exc);
            }
        });

        Iterator<Map.Entry<Path, BasicFileAttributes>> it = attributes.entrySet().iterator();
        while (it.hasNext()) {
            Map.Entry<Path, BasicFileAttributes> entry = it.next();
            it.remove(); // release the memory for entries that have been processed
            Path file = entry.getKey();
            BasicFileAttributes attrs = entry.getValue();
            if (attrs.isDirectory()) {
                switch (visitor.preVisitDirectory(file, attrs)) {
                    case TERMINATE:
                        return false;
                    case SKIP_SIBLINGS:
                        return true;
                    case CONTINUE:
                        if (!walkDirSorted(file, comparator, visitor)) {
                            visitor.postVisitDirectory(file, null);
                            return false;
                        }
                        //falling through intentionally to invoke postVisitDirectory
                    case SKIP_SUBTREE:
                        if (FileVisitResult.TERMINATE == visitor.postVisitDirectory(file, null)) {
                            return false;
                        }
                        break;
                }
            } else {
                switch (visitor.visitFile(file, attrs)) {
                    case SKIP_SIBLINGS:
                        return true;
                    case TERMINATE:
                        return false;
                    default:
                        //Do nothing for CONTINUE and SKIP_SUBTREE
                        break;
                }
            }
        }
        return true;
    }

    private static class PathWithAttributesIterator implements Iterator<PathWithAttributes> {

        private final Comparator<Path> comparator;
        private final Path path;

        private Deque<Iterator<PathWithAttributes>> iteratorStack;
        private PathWithAttributes next;

        PathWithAttributesIterator(Path path, Comparator<Path> comparator) {
            this.comparator = comparator;
            this.path = path;
        }

        @Override
        public boolean hasNext() {
            maybeReadNext();
            return next != null;
        }

        @Override
        public PathWithAttributes next() {
            maybeReadNext();
            if (next == null) {
                throw new NoSuchElementException("iterator is depleted!");
            }
            PathWithAttributes result = next;
            next = null;
            return result;
        }

        private void maybeReadNext() {
            if (next != null) {
                return;
            }

            if (iteratorStack == null) {
                iteratorStack = new LinkedList<>();

                try {
                    BasicFileAttributes attributes = Files.readAttributes(path, BasicFileAttributes.class);
                    next = new PathWithAttributes(path, attributes);
                    if (attributes.isDirectory()) {
                        //Add the directory contents
                        iteratorStack.push(listDirSorted(path, comparator).iterator());
                    }
                } catch (FileNotFoundException | NoSuchFileException e) {
                    //path does not exist
                } catch (IOException e) {
                    throw new UncheckedIOException(e);
                }
            }

            while (next == null && !iteratorStack.isEmpty()) {
                Iterator<PathWithAttributes> it = iteratorStack.peek();
                if (it.hasNext()) {
                    next = it.next();
                    it.remove(); //release the memory
                } else {
                    iteratorStack.pop();
                }

                if (next != null && next.getAttributes().isDirectory()) {
                    try {
                        iteratorStack.push(listDirSorted(next.getPath(), comparator).iterator());
                    } catch (FileNotFoundException | NoSuchFileException e) {
                        //next dir was concurrently deleted
                        next = null;
                    } catch (IOException e) {
                        throw new UncheckedIOException(e);
                    }
                }
            }
        }
    }
}