package com.atlassian.bitbucket.mesh.io;

import com.atlassian.bitbucket.mesh.junit.TemporaryDirectory;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import org.hamcrest.Matchers;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;

import java.io.FileNotFoundException;
import java.io.IOException;
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.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Set;

import static java.util.Collections.singletonList;
import static org.hamcrest.CoreMatchers.instanceOf;
import static org.hamcrest.CoreMatchers.startsWith;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.lessThanOrEqualTo;
import static org.junit.Assert.*;
import static org.junit.Assume.assumeTrue;
import static org.mockito.Mockito.*;

@SuppressWarnings("ConstantConditions")
public class MoreFilesTest {

    @Rule
    public final TemporaryDirectory tempDir = new TemporaryDirectory();

    private Path root;
    private SyncFileTreeCallback syncCallback;

    @Before
    public void setup() {
        syncCallback = mock(SyncFileTreeCallback.class);
        when(syncCallback.isSourceIncluded(any(), any())).thenReturn(true);
        when(syncCallback.isDestinationIncluded(any(), any())).thenReturn(true);
        root = tempDir.getRoot();
    }

    @Test
    public void testCopyFileAtomic() throws IOException {
        try {
            Path source = tempDir.newFolder("source");
            Path target = tempDir.newFolder("target");

            Path sourceFile = source.resolve("example.txt");
            Path targetFile = target.resolve("example.txt");
            Files.write(sourceFile, singletonList("example text"));

            //Verify the file is copied if there's nothing in the way
            assertTrue(MoreFiles.copyFileAtomic(sourceFile, targetFile, false));
            assertThat(MoreFiles.toString(targetFile), startsWith("example text"));

            //Verify that targetFile is not overwritten if we set replace=false
            assertFalse(MoreFiles.copyFileAtomic(sourceFile, targetFile, false));
            assertThat(MoreFiles.toString(targetFile), startsWith("example text"));
        } catch (UnsupportedOperationException e) {
            //Ignore - the file system we're testing on does not support hard links
        }
    }

    @Test(expected = IOException.class)
    public void testCopyFileAtomicWithDirInTheWay() throws IOException {
        Path source = tempDir.newFolder("source");
        Path target = tempDir.newFolder("target");

        Path sourceFile = source.resolve("example.txt");
        Path targetFile = target.resolve("example.txt");
        Files.write(sourceFile, singletonList("example text"));
        Files.createDirectories(targetFile);

        MoreFiles.copyFileAtomic(sourceFile, targetFile, true);
    }

    @Test
    public void testCopyFileAtomicWithFileInTheWay() throws IOException {
        Path source = tempDir.newFolder("source");
        Path target = tempDir.newFolder("target");

        Path sourceFile = source.resolve("example.txt");
        Path targetFile = target.resolve("example.txt");
        Files.write(sourceFile, singletonList("example text"));
        Files.write(targetFile, singletonList("in the way"));

        assertFalse(MoreFiles.copyFileAtomic(sourceFile, targetFile, false));
        assertThat(MoreFiles.toString(targetFile), startsWith("in the way"));

        assertTrue(MoreFiles.copyFileAtomic(sourceFile, targetFile, true));
        assertThat(MoreFiles.toString(targetFile), startsWith("example text"));
    }

    @Test
    public void testCreateHardLink() throws IOException {
        try {
            Path source = tempDir.newFolder("source");
            Path target = tempDir.newFolder("target");

            Path sourceFile = source.resolve("example.txt");
            Path targetFile = target.resolve("example.txt");
            Files.write(sourceFile, singletonList("example text"));

            //Verify the link can be created if there's nothing in the way
            assertTrue(MoreFiles.createHardLink(targetFile, sourceFile, false));
            assertThat(MoreFiles.toString(targetFile), startsWith("example text"));
            assertTrue(Files.isSameFile(sourceFile, targetFile));

            //Verify that the link can be overwritten, even if it's the same file
            assertTrue(MoreFiles.createHardLink(targetFile, sourceFile, true));
            assertThat(MoreFiles.toString(targetFile), startsWith("example text"));
            assertTrue(Files.isSameFile(sourceFile, targetFile));

            //Verify that the link is not overwritten if we set replace=false
            assertFalse(MoreFiles.createHardLink(targetFile, sourceFile, false));
            assertThat(MoreFiles.toString(targetFile), startsWith("example text"));
        } catch (UnsupportedOperationException e) {
            //Ignore - the file system we're testing on does not support hard links
        }
    }

    @Test(expected = FileAlreadyExistsException.class)
    public void testCreateHardLinkWithDirInTheWay() throws IOException {
        try {
            Path source = tempDir.newFolder("source");
            Path target = tempDir.newFolder("target");

            Path sourceFile = source.resolve("example.txt");
            Path targetFile = target.resolve("example.txt");
            Files.write(sourceFile, singletonList("example text"));
            Files.createDirectories(targetFile);

            MoreFiles.createHardLink(targetFile, sourceFile, true);
        } catch (UnsupportedOperationException e) {
            //Ignore - the file system we're testing on does not support hard links
        }
    }

    @Test
    public void testCreateHardLinkWithFileInTheWay() throws IOException {
        Path source = tempDir.newFolder("source");
        Path target = tempDir.newFolder("target");

        Path sourceFile = source.resolve("example.txt");
        Path targetFile = target.resolve("example.txt");
        Files.write(sourceFile, singletonList("example text"));
        Files.write(targetFile, singletonList("in the way"));

        assertFalse(MoreFiles.createHardLink(targetFile, sourceFile, false));
        assertThat(MoreFiles.toString(targetFile), startsWith("in the way"));

        assertTrue(MoreFiles.createHardLink(targetFile, sourceFile, true));
        assertThat(MoreFiles.toString(targetFile), startsWith("example text"));
    }

    @Test
    public void testCreateHardLinkOrCopy() throws IOException {
        Path source = tempDir.newFolder("source");
        Path target = tempDir.newFolder("target");

        Path sourceFile = source.resolve("example.txt");
        Path targetFile = target.resolve("example.txt");
        Files.write(sourceFile, singletonList("example text"));

        assertTrue(MoreFiles.createHardLinkOrCopy(targetFile, sourceFile, false));
        assertThat(MoreFiles.toString(targetFile), startsWith("example text"));

        //Should not overwrite the file when false is passed
        assertFalse(MoreFiles.createHardLinkOrCopy(targetFile, sourceFile, false));

        //We don't assert the return value here because it depends on whether hard links are supported. If hard links
        //are supported, this would return false because targetFile and sourceFile are already the same file. If
        //hard links are not supported, targetFile would be overwritten by the copy operation.
        MoreFiles.createHardLinkOrCopy(targetFile, sourceFile, true);
        assertThat(MoreFiles.toString(targetFile), startsWith("example text"));

        Files.delete(sourceFile);
        assertThat(MoreFiles.toString(targetFile), startsWith("example text"));
    }

    @Test(expected = FileAlreadyExistsException.class)
    public void testCreateHardLinkOrCopyWhenDirInTheWay() throws IOException {
        Path source = tempDir.newFolder("source");
        Path target = tempDir.newFolder("target");

        Path sourceFile = source.resolve("example.txt");
        Files.write(sourceFile, singletonList("example text"));

        assertFalse(MoreFiles.createHardLinkOrCopy(target, sourceFile, true));
    }

    @Test
    public void testCreateHardLinkOrCopyWhenFileInTheWay() throws IOException {
        Path source = tempDir.newFolder("source");
        Path target = tempDir.newFolder("target");

        Path sourceFile = source.resolve("example.txt");
        Path targetFile = target.resolve("example.txt");
        Files.write(sourceFile, singletonList("example text"));
        Files.write(targetFile, singletonList("in the way"));

        assertFalse(MoreFiles.createHardLinkOrCopy(targetFile, sourceFile, false));
        assertThat(MoreFiles.toString(targetFile), startsWith("in the way"));

        assertTrue(MoreFiles.createHardLinkOrCopy(targetFile, sourceFile, true));
        assertThat(MoreFiles.toString(targetFile), startsWith("example text"));
    }

    @Test
    public void testGetFileSystemTime() throws IOException {
        Instant fileTime = MoreFiles.getFileSystemTime(tempDir.getRoot());
        Instant javaTime = Instant.now();

        long deltaMillis = Math.abs(fileTime.toEpochMilli() - javaTime.toEpochMilli());
        //A small difference is expected between the java and file times due to differences in precision
        //and the time it took to create (and delete) the tmp file used to get the file server time
        assertThat(deltaMillis, lessThanOrEqualTo(5000L));
    }

    @Test
    public void testGetFileSystemTimeThrowsWhenDirectoryDoesNotExist() {
        IOException exception = assertThrows(IOException.class,
                () -> MoreFiles.getFileSystemTime(tempDir.getRoot().resolve("does-not-exist")));
        assertThat(exception, Matchers.either(instanceOf(NoSuchFileException.class))
                .or(instanceOf(FileNotFoundException.class)));
    }

    @Test
    public void testGetFileSystemTimeThrowsWhenFileProvided() throws IOException {
        Path tmpFile = tempDir.newFile();
        assertThrows(IOException.class, () -> MoreFiles.getFileSystemTime(tmpFile));
    }

    @Test
    public void testIsPosixSupported() throws Exception {
        assertTrue(MoreFiles.isPosixSupported(tempDir.getRoot()));
    }

    @Test
    public void testIsWithin() throws Exception {
        assertTrue(MoreFiles.isWithin(MoreFiles.resolve(root, "subDir"), root));
        assertTrue(MoreFiles.isWithin(MoreFiles.resolve(root, "subDir", "subSubDir"), root));
        assertTrue(MoreFiles.isWithin(MoreFiles.resolve(root, "subDir", "..", "siblingSubDir"), root));
    }

    @Test(expected = NullPointerException.class)
    public void testIsWithinThrowsIfParentIsNull() throws Exception {
        MoreFiles.isWithin(Paths.get("dir"), null);
    }

    @Test(expected = NullPointerException.class)
    public void testIsWithinThrowsIfPathIsNull() throws Exception {
        MoreFiles.isWithin(null, root);
    }

    @Test(expected = IllegalArgumentException.class)
    public void testIsWithinThrowsIfParentIsNotDirectory() throws Exception {
        Path parentFile = tempDir.newFile();
        MoreFiles.isWithin(MoreFiles.resolve(parentFile, "childFile"), parentFile);
    }

    @Test
    public void testIsWithinWithDirectoryOutsideBaseDir() throws Exception {
        assertFalse(MoreFiles.isWithin(Paths.get("dir"), root));
    }

    @Test
    public void testIsWithinWithDirectoryThatStartsWithSameNameButHasDifferentName() throws Exception {
        // Test that this isn't just a String.startsWith. That means "/file/pathx" shouldn't be considered
        // "within" a sibling directory with a similar name like "/file/path"
        assertFalse(MoreFiles.isWithin(root.resolveSibling(root.getFileName() + "x"), root));
    }

    @Test
    public void testIsWithinWithDirectoryThatNavigatesOutsideBaseDir() throws Exception {
        assertFalse(MoreFiles.isWithin(MoreFiles.resolve(root, "..", "outsideDir"), root));
    }

    @Test
    public void testIterateFileTreeSorted() throws IOException {
        Path root = tempDir.getRoot();
        Path testDir = tempDir.newFolder("dir");
        tempDir.newFile("a-distractor.txt");
        tempDir.newFile("z-distractor.txt");

        createTestFileTree(testDir, "test content");
        createTestFileTree(testDir.resolve("nested"), "more content");

        Map<Path, Boolean> expected = ImmutableMap.<Path, Boolean>builder()
                .put(Paths.get("dir"), false)
                .put(Paths.get("dir/empty"), false)
                .put(Paths.get("dir/empty/dir"), false)
                .put(Paths.get("dir/fileOrDir"), true)
                .put(Paths.get("dir/nested"), false)
                .put(Paths.get("dir/nested/empty"), false)
                .put(Paths.get("dir/nested/empty/dir"), false)
                .put(Paths.get("dir/nested/file1.txt"), true)
                .put(Paths.get("dir/nested/file2.txt"), true)
                .put(Paths.get("dir/nested/fileOrDir"), true)
                .put(Paths.get("dir/nested/nested"), false)
                .put(Paths.get("dir/nested/nested/file1.txt"), true)
                .put(Paths.get("dir/nested/nested/file2.txt"), true)
                .build();

        ImmutableMap.Builder<Path, Boolean> builder = ImmutableMap.builder();
        MoreFiles.iterateFileTreeSorted(testDir).forEachRemaining(item ->
                builder.put(root.relativize(item.getPath()), item.getAttributes().isRegularFile()));

        Map<Path, Boolean> actual = builder.build();
        assertEquals(expected, actual);
        assertEquals(ImmutableList.copyOf(expected.keySet()), ImmutableList.copyOf(actual.keySet()));
    }

    @Test
    public void testIterateFileTreeSortedEmptyDir() throws IOException {
        Path path = tempDir.newFolder("the-folder");

        Map<Path, Boolean> expected = ImmutableMap.of(Paths.get("the-folder"), false);

        ImmutableMap.Builder<Path, Boolean> builder = ImmutableMap.builder();
        MoreFiles.iterateFileTreeSorted(path).forEachRemaining(item ->
                builder.put(root.relativize(item.getPath()), item.getAttributes().isRegularFile()));

        Map<Path, Boolean> actual = builder.build();
        assertEquals(expected, actual);
        assertEquals(ImmutableList.copyOf(expected.keySet()), ImmutableList.copyOf(actual.keySet()));
    }

    @Test
    public void testIterateFileTreeSortedSingleFile() throws IOException {
        Path path = tempDir.newFile("the-file.txt");

        Map<Path, Boolean> expected = ImmutableMap.of(Paths.get("the-file.txt"), true);

        ImmutableMap.Builder<Path, Boolean> builder = ImmutableMap.builder();
        MoreFiles.iterateFileTreeSorted(path).forEachRemaining(item ->
                builder.put(root.relativize(item.getPath()), item.getAttributes().isRegularFile()));

        Map<Path, Boolean> actual = builder.build();
        assertEquals(expected, actual);
        assertEquals(ImmutableList.copyOf(expected.keySet()), ImmutableList.copyOf(actual.keySet()));
    }

    @Test
    public void testIterateFileTreeSortedWithComparator() throws IOException {
        Path root = tempDir.getRoot();
        Path testDir = tempDir.newFolder("dir");
        tempDir.newFile("a-distractor.txt");
        tempDir.newFile("z-distractor.txt");

        createTestFileTree(testDir, "test content");
        createTestFileTree(testDir.resolve("nested"), "more content");

        //Note that directory contents are ordered using the Comparator, but we still emit the directories before
        //their contents to be consistent with walkFileTreeSorted. As a result, the total ordering does not have to
        //match the the comparator order. In this example, according to the comparator 'dir' should be listed last
        Map<Path, Boolean> expected = ImmutableMap.<Path, Boolean>builder()
                .put(Paths.get("dir"), false)
                .put(Paths.get("dir/nested"), false)
                .put(Paths.get("dir/nested/nested"), false)
                .put(Paths.get("dir/nested/nested/file2.txt"), true)
                .put(Paths.get("dir/nested/nested/file1.txt"), true)
                .put(Paths.get("dir/nested/fileOrDir"), true)
                .put(Paths.get("dir/nested/file2.txt"), true)
                .put(Paths.get("dir/nested/file1.txt"), true)
                .put(Paths.get("dir/nested/empty"), false)
                .put(Paths.get("dir/nested/empty/dir"), false)
                .put(Paths.get("dir/fileOrDir"), true)
                .put(Paths.get("dir/empty"), false)
                .put(Paths.get("dir/empty/dir"), false)
                .build();

        ImmutableMap.Builder<Path, Boolean> builder = ImmutableMap.builder();
        MoreFiles.iterateFileTreeSorted(testDir, Comparator.reverseOrder()).forEachRemaining(item ->
                builder.put(root.relativize(item.getPath()), item.getAttributes().isRegularFile()));

        Map<Path, Boolean> actual = builder.build();
        assertEquals(expected, actual);
        assertEquals(ImmutableList.copyOf(expected.keySet()), ImmutableList.copyOf(actual.keySet()));
    }

    @Test
    public void testPruneEmptyDirectories() throws IOException {
        Path root = tempDir.getRoot();
        Path emptyFolder1 = MoreFiles.mkdir(root.resolve("a/nested/folder/that/is/empty"));
        Path emptyFolder2 = MoreFiles.mkdir(root.resolve("a/nested/folder/that/is-also/empty"));
        Path emptyFolder3 = MoreFiles.mkdir(root.resolve("a/nested/empty-folder"));
        Path content = MoreFiles.mkdir(root.resolve("a/nested/folder/with/content"));
        Path file1 = content.resolve("file");
        Path file2 = root.resolve("a/nested/file");
        MoreFiles.write(file1, "Some file");
        MoreFiles.write(file2, "Another file");

        MoreFiles.pruneEmptyDirectories(root.resolve("a/nested"));

        //Verify that the files still exist
        assertEquals("Some file", MoreFiles.toString(file1));
        assertEquals("Another file", MoreFiles.toString(file2));
        //Verify that the empty dirs have been pruned
        assertFalse(Files.exists(emptyFolder1));
        assertFalse(Files.exists(emptyFolder2));
        assertFalse(Files.exists(emptyFolder3));
        assertFalse(Files.exists(root.resolve("a/nested/folder/that")));
    }

    @Test
    public void testPruneEmptyDirectoriesNoSuchFile() throws IOException {
        MoreFiles.pruneEmptyDirectories(root.resolve("does/not/exist"));
    }

    @Test
    public void testRequireWithin() throws Exception {
        // validateFileWithin defers to isWithin, so leave the edge cases to that method's tests
        MoreFiles.requireWithin(MoreFiles.resolve(root, "subDir"), root);
    }

    @Test(expected = IllegalArgumentException.class)
    public void testRequireWithinThrowsForDirectoryThatNavigatesOutsideBaseDir() throws Exception {
        // validateFileWithin defers to isWithin, so leave the edge cases to that method's tests
        MoreFiles.requireWithin(MoreFiles.resolve(root, "..", "outsideDir"), root);
    }

    @Test(expected = IllegalArgumentException.class)
    public void testSetFilePermissionWithDirectory() throws IOException {
        MoreFiles.setPermissions(new SetFilePermissionRequest.Builder(root).build());
    }

    @Test(expected = IllegalArgumentException.class)
    public void testSetPermissionsForNonexistentFile() throws IOException {
        MoreFiles.setPermissions(new SetFilePermissionRequest.Builder(root.resolve("garbage")).build());
    }

    @Test
    public void testSetPermissionsViaPosix() throws IOException {
        assumeTrue("Test is only valid for POSIX-compliant filesystems (e.g. APFS, ext4, others)",
                MoreFiles.isPosixSupported(tempDir.getRoot()));

        Path testFile = tempDir.newFile("testSetPermissionsViaPosix");

        MoreFiles.setPermissions(new SetFilePermissionRequest.Builder(testFile)
                .worldPermission(FilePermission.EXECUTE)
                .groupPermission(FilePermission.WRITE)
                .ownerPermission(FilePermission.READ)
                .ownerPermission(FilePermission.EXECUTE)
                .build());

        Set<PosixFilePermission> actualPermissions = Files.getPosixFilePermissions(testFile);
        assertEquals(4, actualPermissions.size());
        assertEquals(ImmutableSet.of(PosixFilePermission.OTHERS_EXECUTE, PosixFilePermission.GROUP_WRITE,
                PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_EXECUTE), actualPermissions);
    }

    @Test
    public void testSyncFileTreeDestinationDoesNotExist() throws IOException {
        Path source = tempDir.newFolder("source");
        Path destination = tempDir.getRoot().resolve("destination");
        MoreFiles.write(source.resolve("file"), "test");

        MoreFiles.syncFileTree(new SyncFileTreeParameters.Builder(source, destination)
                .callback(syncCallback)
                .build());

        assertEquals("test", MoreFiles.toString(destination.resolve("file")));
        verify(syncCallback).onSynced(eq(source.resolve("file")), any(), eq(destination.resolve("file")));
    }

    @Test
    public void testSyncFileTreeSourceDoesNotExist() throws IOException {
        Path destination = tempDir.newFolder("destination");

        MoreFiles.syncFileTree(new SyncFileTreeParameters.Builder(tempDir.getRoot().resolve("source"), destination)
                .callback(syncCallback)
                .build());
        verifyNoInteractions(syncCallback);
    }

    @Test
    public void testSyncFileTreeWithConflicts() throws IOException {
        Path destRoot = tempDir.newFolder();
        Path sourceRoot = tempDir.newFolder();
        createTestFileTree(sourceRoot, "src");

        // Create file vs dir conflicts between source and destination
        MoreFiles.write(MoreFiles.mkdir(sourceRoot, "more-nested").resolve("file.txt"), "file");
        MoreFiles.write(destRoot.resolve("more-nested"), "conflicting file");
        MoreFiles.write(MoreFiles.mkdir(destRoot, "empty").resolve("dir"), "conflicting file");
        MoreFiles.write(MoreFiles.mkdir(destRoot, "nested/file1.txt").resolve("file"), "conflicting file");
        MoreFiles.write(MoreFiles.mkdir(destRoot, "fileOrDir").resolve("file"), "file");

        MoreFiles.syncFileTree(new SyncFileTreeParameters.Builder(sourceRoot, destRoot)
                .callback(syncCallback)
                .replaceExisting(true)
                .build());

        verify(syncCallback).onConflict(
                eq(sourceRoot.resolve("empty/dir")), any(), eq(destRoot.resolve("empty/dir")));
        verify(syncCallback).onConflict(
                eq(sourceRoot.resolve("more-nested")), any(), eq(destRoot.resolve("more-nested")));
        verify(syncCallback).onConflict(
                eq(sourceRoot.resolve("nested/file1.txt")), any(), eq(destRoot.resolve("nested/file1.txt")));
        verify(syncCallback).onSynced(
                eq(sourceRoot.resolve("nested/file2.txt")), any(), eq(destRoot.resolve("nested/file2.txt")));
        verify(syncCallback).onConflict(
                eq(sourceRoot.resolve("fileOrDir")), any(), eq(destRoot.resolve("fileOrDir")));
    }

    @Test
    public void testSyncFileTreeWithDetectMissing() throws IOException {
        // Prepare two trees
        Path destRoot = tempDir.newFolder();
        Path sourceRoot = tempDir.newFolder();
        createTestFileTree(destRoot, "dest");
        createTestFileTree(sourceRoot, "src");

        // Add a file and a directory to the destRoot
        MoreFiles.write(MoreFiles.mkdir(destRoot, "other-folder").resolve("stale.txt"), "stale");
        Files.delete(destRoot.resolve("empty/dir"));
        Files.delete(destRoot.resolve("empty"));

        MoreFiles.syncFileTree(new SyncFileTreeParameters.Builder(sourceRoot, destRoot)
                .callback(syncCallback)
                .detectMissing(true)
                .replaceExisting(true)
                .build());

        verify(syncCallback).onMissingInSource(
                sourceRoot.resolve("other-folder/stale.txt"), destRoot.resolve("other-folder/stale.txt"));
        verify(syncCallback).onSynced(
                eq(sourceRoot.resolve("nested/file1.txt")), any(), eq(destRoot.resolve("nested/file1.txt")));
        verify(syncCallback).onSynced(
                eq(sourceRoot.resolve("nested/file2.txt")), any(), eq(destRoot.resolve("nested/file2.txt")));
        verify(syncCallback).onSynced(
                eq(sourceRoot.resolve("fileOrDir")), any(), eq(destRoot.resolve("fileOrDir")));

        assertTrue(Files.isDirectory(destRoot.resolve("empty/dir")));
        assertEquals("src file 1", MoreFiles.toString(destRoot.resolve("nested/file1.txt")));
        assertEquals("src file 2", MoreFiles.toString(destRoot.resolve("nested/file2.txt")));
        assertEquals("src fileOrDir", MoreFiles.toString(destRoot.resolve("fileOrDir")));
    }

    @Test
    public void testSyncFileTreeWithHardlinksNotAllowed() throws IOException {
        Path destRoot = tempDir.newFolder();
        Path sourceRoot = tempDir.newFolder();
        createTestFileTree(destRoot, "dest");
        createTestFileTree(sourceRoot, "src");

        MoreFiles.syncFileTree(new SyncFileTreeParameters.Builder(sourceRoot, destRoot)
                .allowHardLinks(false)
                .callback(syncCallback)
                .replaceExisting(true)
                .build());

        verify(syncCallback).onSynced(
                eq(sourceRoot.resolve("nested/file1.txt")), any(), eq(destRoot.resolve("nested/file1.txt")));
        verify(syncCallback).onSynced(
                eq(sourceRoot.resolve("nested/file2.txt")), any(), eq(destRoot.resolve("nested/file2.txt")));
        verify(syncCallback).onSynced(
                eq(sourceRoot.resolve("fileOrDir")), any(), eq(destRoot.resolve("fileOrDir")));

        assertTrue(Files.isDirectory(destRoot.resolve("empty/dir")));
        assertEquals("src file 1", MoreFiles.toString(destRoot.resolve("nested/file1.txt")));
        assertEquals("src file 2", MoreFiles.toString(destRoot.resolve("nested/file2.txt")));
        assertEquals("src fileOrDir", MoreFiles.toString(destRoot.resolve("fileOrDir")));
        assertFalse(Files.isSameFile(sourceRoot.resolve("nested/file1.txt"), destRoot.resolve("nested/file1.txt")));
        assertFalse(Files.isSameFile(sourceRoot.resolve("nested/file2.txt"), destRoot.resolve("nested/file2.txt")));
        assertFalse(Files.isSameFile(sourceRoot.resolve("fileOrDir"), destRoot.resolve("fileOrDir")));
    }

    @Test
    public void testSyncFileTreeWithModifiedSince() throws IOException {
        Path destRoot = tempDir.newFolder();
        Path sourceRoot = tempDir.newFolder();
        createTestFileTree(destRoot, "dest");
        createTestFileTree(sourceRoot, "src");

        //Set the last modified date on directory and a file. The directory should still be traversed, but the file
        //should be skipped
        Instant oneHourAgo = Instant.now().minus(1, ChronoUnit.HOURS);
        Files.setLastModifiedTime(sourceRoot.resolve("nested"), FileTime.from(oneHourAgo));
        Files.setLastModifiedTime(sourceRoot.resolve("fileOrDir"), FileTime.from(oneHourAgo));

        MoreFiles.syncFileTree(new SyncFileTreeParameters.Builder(sourceRoot, destRoot)
                .allowHardLinks(false)
                .callback(syncCallback)
                .modifiedSince(oneHourAgo.plusSeconds(1L))
                .replaceExisting(true)
                .build());

        verify(syncCallback).onSynced(
                eq(sourceRoot.resolve("nested/file1.txt")), any(), eq(destRoot.resolve("nested/file1.txt")));
        verify(syncCallback).onSynced(
                eq(sourceRoot.resolve("nested/file2.txt")), any(), eq(destRoot.resolve("nested/file2.txt")));
        assertEquals("src file 1", MoreFiles.toString(destRoot.resolve("nested/file1.txt")));
        assertEquals("src file 2", MoreFiles.toString(destRoot.resolve("nested/file2.txt")));
        assertEquals("dest fileOrDir", MoreFiles.toString(destRoot.resolve("fileOrDir")));
    }

    @Test
    public void testSyncFileTreeWithPathMatching() throws IOException {
        Path destRoot = tempDir.newFolder();
        Path sourceRoot = tempDir.newFolder();
        createTestFileTree(sourceRoot, "src");

        MoreFiles.write(MoreFiles.mkdir(sourceRoot, "skipped-dir").resolve("file.txt"), "file");
        MoreFiles.write(sourceRoot.resolve("nested/skipped-file"), "file");

        Set<Path> skippedPaths = ImmutableSet.of(
                sourceRoot.resolve("skipped-dir"),
                sourceRoot.resolve("nested/skipped-file"));

        when(syncCallback.isSourceIncluded(any(), any())).thenAnswer(invocation -> {
            Path source = invocation.getArgument(0);
            return !skippedPaths.contains(source);
        });

        MoreFiles.syncFileTree(new SyncFileTreeParameters.Builder(sourceRoot, destRoot)
                .callback(syncCallback)
                .build());

        verify(syncCallback).onSynced(
                eq(sourceRoot.resolve("nested/file1.txt")), any(), eq(destRoot.resolve("nested/file1.txt")));
        verify(syncCallback).onSynced(
                eq(sourceRoot.resolve("nested/file2.txt")), any(), eq(destRoot.resolve("nested/file2.txt")));
        verify(syncCallback).onSynced(
                eq(sourceRoot.resolve("fileOrDir")), any(), eq(destRoot.resolve("fileOrDir")));

        assertTrue(Files.isDirectory(destRoot.resolve("empty/dir")));
        assertEquals("src file 1", MoreFiles.toString(destRoot.resolve("nested/file1.txt")));
        assertEquals("src file 2", MoreFiles.toString(destRoot.resolve("nested/file2.txt")));
        assertEquals("src fileOrDir", MoreFiles.toString(destRoot.resolve("fileOrDir")));
        assertFalse(Files.exists(destRoot.resolve("skipped-dir")));
        assertFalse(Files.exists(destRoot.resolve("nexted/skipped-file")));
    }

    @Test
    public void testSyncFileTreeWithReplaceFalse() throws IOException {
        Path destRoot = tempDir.newFolder();
        Path sourceRoot = tempDir.newFolder();
        createTestFileTree(destRoot, "dest");
        createTestFileTree(sourceRoot, "src");

        Files.delete(destRoot.resolve("nested/file2.txt"));
        Files.delete(destRoot.resolve("fileOrDir"));

        MoreFiles.syncFileTree(new SyncFileTreeParameters.Builder(sourceRoot, destRoot)
                .callback(syncCallback)
                .replaceExisting(false)
                .build());

        verify(syncCallback).onConflict(
                eq(sourceRoot.resolve("nested/file1.txt")), any(), eq(destRoot.resolve("nested/file1.txt")));
        verify(syncCallback).onSynced(
                eq(sourceRoot.resolve("nested/file2.txt")), any(), eq(destRoot.resolve("nested/file2.txt")));
        verify(syncCallback).onSynced(
                eq(sourceRoot.resolve("fileOrDir")), any(), eq(destRoot.resolve("fileOrDir")));

        assertTrue(Files.isDirectory(destRoot.resolve("empty/dir")));
        assertEquals("dest file 1", MoreFiles.toString(destRoot.resolve("nested/file1.txt")));
        assertEquals("src file 2", MoreFiles.toString(destRoot.resolve("nested/file2.txt")));
        assertEquals("src fileOrDir", MoreFiles.toString(destRoot.resolve("fileOrDir")));
    }

    @Test
    public void testToRealPathIfPathDoesNotExist() throws IOException {
        Path path = tempDir.getRoot().resolve("no-such.file");
        assertEquals(path, MoreFiles.toRealPath(path));
    }

    @Test
    public void testToRealPathRegularPath() throws IOException {
        Path path = tempDir.getRoot();
        assertEquals(path.toRealPath(), MoreFiles.toRealPath(path));
    }

    @Test(expected = NullPointerException.class)
    public void testToRealPathNull() throws IOException {
        MoreFiles.toRealPath(null);
    }

    @Test
    public void testWalkFileTreeSorted() throws IOException {
        Path root = tempDir.getRoot();
        Path testDir = tempDir.newFolder("dir");
        tempDir.newFile("a-distractor.txt");
        tempDir.newFile("z-distractor.txt");

        createTestFileTree(testDir, "test content");
        createTestFileTree(testDir.resolve("nested"), "more content");

        Map<Path, Boolean> expected = ImmutableMap.<Path, Boolean>builder()
                .put(Paths.get("dir"), false)
                .put(Paths.get("dir/empty"), false)
                .put(Paths.get("dir/empty/dir"), false)
                .put(Paths.get("dir/fileOrDir"), true)
                .put(Paths.get("dir/nested"), false)
                .put(Paths.get("dir/nested/empty"), false)
                .put(Paths.get("dir/nested/empty/dir"), false)
                .put(Paths.get("dir/nested/file1.txt"), true)
                .put(Paths.get("dir/nested/file2.txt"), true)
                .put(Paths.get("dir/nested/fileOrDir"), true)
                .put(Paths.get("dir/nested/nested"), false)
                .put(Paths.get("dir/nested/nested/file1.txt"), true)
                .put(Paths.get("dir/nested/nested/file2.txt"), true)
                .build();

        ImmutableMap.Builder<Path, Boolean> builder = ImmutableMap.builder();
        MoreFiles.walkFileTreeSorted(testDir, new SimpleFileVisitor<Path>() {

            @Override
            public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) {
                builder.put(root.relativize(dir), attrs.isRegularFile());
                return FileVisitResult.CONTINUE;
            }

            @Override
            public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
                builder.put(root.relativize(file), attrs.isRegularFile());
                return FileVisitResult.CONTINUE;
            }
        });

        Map<Path, Boolean> actual = builder.build();
        assertEquals(expected, actual);
        assertEquals(ImmutableList.copyOf(expected.keySet()), ImmutableList.copyOf(actual.keySet()));
    }

    @Test
    public void testWalkFileTreeSortedThrows() throws IOException {
        Path root = tempDir.getRoot();
        Path testDir = tempDir.newFolder("dir");

        createTestFileTree(testDir, "test content");

        List<String> expected = ImmutableList.of("pre:dir", "pre:dir/empty", "pre:dir/empty/dir", "post:dir/empty/dir",
                "post:dir/empty", "file:dir/fileOrDir", "pre:dir/nested", "file:dir/nested/file1.txt");

        ImmutableList.Builder<String> builder = ImmutableList.builder();
        assertThrows(IOException.class, () -> MoreFiles.walkFileTreeSorted(testDir, new SimpleFileVisitor<Path>() {

            @Override
            public FileVisitResult postVisitDirectory(Path dir, IOException exception) {
                builder.add("post:" + root.relativize(dir));
                return FileVisitResult.CONTINUE;
            }

            @Override
            public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) {
                builder.add("pre:" + root.relativize(dir));
                return FileVisitResult.CONTINUE;
            }

            @Override
            public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
                builder.add("file:" + root.relativize(file));
                if (file.getFileName().toString().equals("file1.txt")) {
                    throw new IOException("Testing IOException");
                }
                return FileVisitResult.CONTINUE;
            }
        }));

        assertEquals(expected, builder.build());
    }

    @Test
    public void testWalkFileTreeSortedWithSkips() throws IOException {
        Path root = tempDir.getRoot();
        Path testDir = tempDir.newFolder("dir");
        tempDir.newFile("a-distractor.txt");
        tempDir.newFile("z-distractor.txt");

        createTestFileTree(testDir, "test content");
        createTestFileTree(testDir.resolve("nested"), "more content");

        Map<Path, Boolean> expected = ImmutableMap.<Path, Boolean>builder()
                .put(Paths.get("dir"), false)
                .put(Paths.get("dir/empty"), false)
                .put(Paths.get("dir/fileOrDir"), true)
                .put(Paths.get("dir/nested"), false)
                .put(Paths.get("dir/nested/empty"), false)
                .put(Paths.get("dir/nested/file1.txt"), true)
                .put(Paths.get("dir/nested/file2.txt"), true)
                .build();

        ImmutableMap.Builder<Path, Boolean> builder = ImmutableMap.builder();
        MoreFiles.walkFileTreeSorted(testDir, new SimpleFileVisitor<Path>() {

            @Override
            public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) {
                builder.put(root.relativize(dir), attrs.isRegularFile());
                return dir.getFileName().toString().equals("empty") ?
                        FileVisitResult.SKIP_SUBTREE : FileVisitResult.CONTINUE;
            }

            @Override
            public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
                builder.put(root.relativize(file), attrs.isRegularFile());
                return file.getFileName().toString().equals("file2.txt") ?
                        FileVisitResult.SKIP_SIBLINGS : FileVisitResult.CONTINUE;
            }
        });

        Map<Path, Boolean> actual = builder.build();
        assertEquals(expected, actual);
        assertEquals(ImmutableList.copyOf(expected.keySet()), ImmutableList.copyOf(actual.keySet()));
    }

    @Test
    public void testWalkFileTreeSortedWithSkipsAndComparator() throws IOException {
        Path root = tempDir.getRoot();
        Path testDir = tempDir.newFolder("dir");
        tempDir.newFile("a-distractor.txt");
        tempDir.newFile("z-distractor.txt");

        createTestFileTree(testDir, "test content");
        createTestFileTree(testDir.resolve("nested"), "more content");

        //Note that directory contents are ordered using the Comparator, but we still have to call
        //preVisitDirectory before entering the directory. As a result, the total ordering does not have to
        //match the the comparator order. In this example, according to the comparator 'dir' should be listed last
        Map<Path, Boolean> expected = ImmutableMap.<Path, Boolean>builder()
                .put(Paths.get("dir"), false)
                .put(Paths.get("dir/nested"), false)
                .put(Paths.get("dir/nested/nested"), false)
                .put(Paths.get("dir/nested/nested/file2.txt"), true)
                .put(Paths.get("dir/nested/fileOrDir"), true)
                .put(Paths.get("dir/nested/file2.txt"), true)
                .put(Paths.get("dir/fileOrDir"), true)
                .put(Paths.get("dir/empty"), false)
                .build();

        ImmutableMap.Builder<Path, Boolean> builder = ImmutableMap.builder();
        MoreFiles.walkFileTreeSorted(testDir, Comparator.reverseOrder(), new SimpleFileVisitor<Path>() {

            @Override
            public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) {
                builder.put(root.relativize(dir), attrs.isRegularFile());
                return dir.getFileName().toString().equals("empty") ?
                        FileVisitResult.SKIP_SUBTREE : FileVisitResult.CONTINUE;
            }

            @Override
            public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
                builder.put(root.relativize(file), attrs.isRegularFile());
                return file.getFileName().toString().equals("file2.txt") ?
                        FileVisitResult.SKIP_SIBLINGS : FileVisitResult.CONTINUE;
            }
        });

        Map<Path, Boolean> actual = builder.build();
        assertEquals(expected, actual);
        assertEquals(ImmutableList.copyOf(expected.keySet()), ImmutableList.copyOf(actual.keySet()));
    }

    private static void createTestFileTree(Path root, String markerContent) throws IOException {
        MoreFiles.mkdir(root, "empty/dir");
        MoreFiles.write(MoreFiles.mkdir(root, "nested").resolve("file1.txt"), markerContent + " file 1");
        MoreFiles.write(MoreFiles.mkdir(root, "nested").resolve("file2.txt"), markerContent + " file 2");
        MoreFiles.write(root.resolve("fileOrDir"), markerContent + " fileOrDir");
    }
}
