/*
 * Licensed to Elasticsearch under one or more contributor
 * license agreements. See the NOTICE file distributed with
 * this work for additional information regarding copyright
 * ownership. Elasticsearch licenses this file to you under
 * the Apache License, Version 2.0 (the "License"); you may
 * not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *    http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */
package org.elasticsearch.watcher;

import org.apache.lucene.util.IOUtils;
import org.apache.lucene.util.LuceneTestCase;
import org.elasticsearch.test.ElasticsearchTestCase;
import org.junit.Test;

import java.io.BufferedWriter;
import java.io.IOException;
import java.nio.charset.Charset;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.List;

import static com.google.common.collect.Lists.newArrayList;
import static org.hamcrest.Matchers.contains;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.hasSize;

@LuceneTestCase.SuppressFileSystems("ExtrasFS")
public class FileWatcherTest extends ElasticsearchTestCase {

    private class RecordingChangeListener extends FileChangesListener {

        private Path rootDir;

        private RecordingChangeListener(Path rootDir) {
            this.rootDir = rootDir;
        }

        private String getRelativeFileName(Path file) {
            return rootDir.toUri().relativize(file.toUri()).getPath();
        }

        private List<String> notifications = newArrayList();

        @Override
        public void onFileInit(Path file) {
            notifications.add("onFileInit: " + getRelativeFileName(file));
        }

        @Override
        public void onDirectoryInit(Path file) {
            notifications.add("onDirectoryInit: " + getRelativeFileName(file));
        }

        @Override
        public void onFileCreated(Path file) {
            notifications.add("onFileCreated: " + getRelativeFileName(file));
        }

        @Override
        public void onFileDeleted(Path file) {
            notifications.add("onFileDeleted: " + getRelativeFileName(file));
        }

        @Override
        public void onFileChanged(Path file) {
            notifications.add("onFileChanged: " + getRelativeFileName(file));
        }

        @Override
        public void onDirectoryCreated(Path file) {
            notifications.add("onDirectoryCreated: " + getRelativeFileName(file));
        }

        @Override
        public void onDirectoryDeleted(Path file) {
            notifications.add("onDirectoryDeleted: " + getRelativeFileName(file));
        }

        public List<String> notifications() {
            return notifications;
        }
    }

    @Test
    public void testSimpleFileOperations() throws IOException {
        Path tempDir = createTempDir();
        RecordingChangeListener changes = new RecordingChangeListener(tempDir);
        Path testFile = tempDir.resolve("test.txt");
        touch(testFile);
        FileWatcher fileWatcher = new FileWatcher(testFile);
        fileWatcher.addListener(changes);
        fileWatcher.init();
        assertThat(changes.notifications(), contains(equalTo("onFileInit: test.txt")));

        changes.notifications().clear();
        fileWatcher.checkAndNotify();
        assertThat(changes.notifications(), hasSize(0));

        append("Test", testFile, Charset.defaultCharset());
        fileWatcher.checkAndNotify();
        assertThat(changes.notifications(), contains(equalTo("onFileChanged: test.txt")));

        changes.notifications().clear();
        fileWatcher.checkAndNotify();
        assertThat(changes.notifications(), hasSize(0));

        Files.delete(testFile);
        fileWatcher.checkAndNotify();
        assertThat(changes.notifications(), contains(equalTo("onFileDeleted: test.txt")));

    }

    @Test
    public void testSimpleDirectoryOperations() throws IOException {
        Path tempDir = createTempDir();
        RecordingChangeListener changes = new RecordingChangeListener(tempDir);
        Path testDir = tempDir.resolve("test-dir");
        Files.createDirectories(testDir);
        touch(testDir.resolve("test.txt"));
        touch(testDir.resolve("test0.txt"));

        FileWatcher fileWatcher = new FileWatcher(testDir);
        fileWatcher.addListener(changes);
        fileWatcher.init();
        assertThat(changes.notifications(), contains(
                equalTo("onDirectoryInit: test-dir/"),
                equalTo("onFileInit: test-dir/test.txt"),
                equalTo("onFileInit: test-dir/test0.txt")
        ));

        changes.notifications().clear();
        fileWatcher.checkAndNotify();
        assertThat(changes.notifications(), hasSize(0));

        for (int i = 0; i < 4; i++) {
            touch(testDir.resolve("test" + i + ".txt"));
        }
        // Make sure that first file is modified
        append("Test", testDir.resolve("test0.txt"), Charset.defaultCharset());

        fileWatcher.checkAndNotify();
        assertThat(changes.notifications(), contains(
                equalTo("onFileChanged: test-dir/test0.txt"),
                equalTo("onFileCreated: test-dir/test1.txt"),
                equalTo("onFileCreated: test-dir/test2.txt"),
                equalTo("onFileCreated: test-dir/test3.txt")
        ));

        changes.notifications().clear();
        fileWatcher.checkAndNotify();
        assertThat(changes.notifications(), hasSize(0));

        Files.delete(testDir.resolve("test1.txt"));
        Files.delete(testDir.resolve("test2.txt"));

        fileWatcher.checkAndNotify();
        assertThat(changes.notifications(), contains(
                equalTo("onFileDeleted: test-dir/test1.txt"),
                equalTo("onFileDeleted: test-dir/test2.txt")
        ));

        changes.notifications().clear();
        fileWatcher.checkAndNotify();
        assertThat(changes.notifications(), hasSize(0));

        Files.delete(testDir.resolve("test0.txt"));
        touch(testDir.resolve("test2.txt"));
        touch(testDir.resolve("test4.txt"));
        fileWatcher.checkAndNotify();

        assertThat(changes.notifications(), contains(
                equalTo("onFileDeleted: test-dir/test0.txt"),
                equalTo("onFileCreated: test-dir/test2.txt"),
                equalTo("onFileCreated: test-dir/test4.txt")
        ));


        changes.notifications().clear();

        Files.delete(testDir.resolve("test3.txt"));
        Files.delete(testDir.resolve("test4.txt"));
        fileWatcher.checkAndNotify();
        assertThat(changes.notifications(), contains(
                equalTo("onFileDeleted: test-dir/test3.txt"),
                equalTo("onFileDeleted: test-dir/test4.txt")
        ));


        changes.notifications().clear();
        if (Files.exists(testDir)) {
            IOUtils.rm(testDir);
        }
        fileWatcher.checkAndNotify();

        assertThat(changes.notifications(), contains(
                equalTo("onFileDeleted: test-dir/test.txt"),
                equalTo("onFileDeleted: test-dir/test2.txt"),
                equalTo("onDirectoryDeleted: test-dir")
        ));

    }

    @Test
    public void testNestedDirectoryOperations() throws IOException {
        Path tempDir = createTempDir();
        RecordingChangeListener changes = new RecordingChangeListener(tempDir);
        Path testDir = tempDir.resolve("test-dir");
        Files.createDirectories(testDir);
        touch(testDir.resolve("test.txt"));
        Files.createDirectories(testDir.resolve("sub-dir"));
        touch(testDir.resolve("sub-dir/test0.txt"));

        FileWatcher fileWatcher = new FileWatcher(testDir);
        fileWatcher.addListener(changes);
        fileWatcher.init();
        assertThat(changes.notifications(), contains(
                equalTo("onDirectoryInit: test-dir/"),
                equalTo("onDirectoryInit: test-dir/sub-dir/"),
                equalTo("onFileInit: test-dir/sub-dir/test0.txt"),
                equalTo("onFileInit: test-dir/test.txt")
        ));

        changes.notifications().clear();
        fileWatcher.checkAndNotify();
        assertThat(changes.notifications(), hasSize(0));

        // Create new file in subdirectory
        touch(testDir.resolve("sub-dir/test1.txt"));
        fileWatcher.checkAndNotify();
        assertThat(changes.notifications(), contains(
                equalTo("onFileCreated: test-dir/sub-dir/test1.txt")
        ));

        changes.notifications().clear();
        fileWatcher.checkAndNotify();
        assertThat(changes.notifications(), hasSize(0));

        // Create new subdirectory in subdirectory
        Files.createDirectories(testDir.resolve("first-level"));
        touch(testDir.resolve("first-level/file1.txt"));
        Files.createDirectories(testDir.resolve("first-level/second-level"));
        touch(testDir.resolve("first-level/second-level/file2.txt"));
        fileWatcher.checkAndNotify();
        assertThat(changes.notifications(), contains(
                equalTo("onDirectoryCreated: test-dir/first-level/"),
                equalTo("onFileCreated: test-dir/first-level/file1.txt"),
                equalTo("onDirectoryCreated: test-dir/first-level/second-level/"),
                equalTo("onFileCreated: test-dir/first-level/second-level/file2.txt")
        ));

        changes.notifications().clear();
        fileWatcher.checkAndNotify();
        assertThat(changes.notifications(), hasSize(0));

        // Delete a directory, check notifications for
        Path path = testDir.resolve("first-level");
        if (Files.exists(path)) {
            IOUtils.rm(path);
        }
        fileWatcher.checkAndNotify();
        assertThat(changes.notifications(), contains(
                equalTo("onFileDeleted: test-dir/first-level/file1.txt"),
                equalTo("onFileDeleted: test-dir/first-level/second-level/file2.txt"),
                equalTo("onDirectoryDeleted: test-dir/first-level/second-level"),
                equalTo("onDirectoryDeleted: test-dir/first-level")
        ));
    }

    @Test
    public void testFileReplacingDirectory() throws IOException {
        Path tempDir = createTempDir();
        RecordingChangeListener changes = new RecordingChangeListener(tempDir);
        Path testDir = tempDir.resolve("test-dir");
        Files.createDirectories(testDir);
        Path subDir = testDir.resolve("sub-dir");
        Files.createDirectories(subDir);
        touch(subDir.resolve("test0.txt"));
        touch(subDir.resolve("test1.txt"));

        FileWatcher fileWatcher = new FileWatcher(testDir);
        fileWatcher.addListener(changes);
        fileWatcher.init();
        assertThat(changes.notifications(), contains(
                equalTo("onDirectoryInit: test-dir/"),
                equalTo("onDirectoryInit: test-dir/sub-dir/"),
                equalTo("onFileInit: test-dir/sub-dir/test0.txt"),
                equalTo("onFileInit: test-dir/sub-dir/test1.txt")
        ));

        changes.notifications().clear();

        if (Files.exists(subDir)) {
            IOUtils.rm(subDir);
        }
        touch(subDir);
        fileWatcher.checkAndNotify();
        assertThat(changes.notifications(), contains(
                equalTo("onFileDeleted: test-dir/sub-dir/test0.txt"),
                equalTo("onFileDeleted: test-dir/sub-dir/test1.txt"),
                equalTo("onDirectoryDeleted: test-dir/sub-dir"),
                equalTo("onFileCreated: test-dir/sub-dir")
        ));

        changes.notifications().clear();

        Files.delete(subDir);
        Files.createDirectories(subDir);

        fileWatcher.checkAndNotify();
        assertThat(changes.notifications(), contains(
                equalTo("onFileDeleted: test-dir/sub-dir/"),
                equalTo("onDirectoryCreated: test-dir/sub-dir/")
        ));
    }

    @Test
    public void testEmptyDirectory() throws IOException {
        Path tempDir = createTempDir();
        RecordingChangeListener changes = new RecordingChangeListener(tempDir);
        Path testDir = tempDir.resolve("test-dir");
        Files.createDirectories(testDir);
        touch(testDir.resolve("test0.txt"));
        touch(testDir.resolve("test1.txt"));

        FileWatcher fileWatcher = new FileWatcher(testDir);
        fileWatcher.addListener(changes);
        fileWatcher.init();
        changes.notifications().clear();

        Files.delete(testDir.resolve("test0.txt"));
        Files.delete(testDir.resolve("test1.txt"));
        fileWatcher.checkAndNotify();
        assertThat(changes.notifications(), contains(
                equalTo("onFileDeleted: test-dir/test0.txt"),
                equalTo("onFileDeleted: test-dir/test1.txt")
        ));
    }

    @Test
    public void testNoDirectoryOnInit() throws IOException {
        Path tempDir = createTempDir();
        RecordingChangeListener changes = new RecordingChangeListener(tempDir);
        Path testDir = tempDir.resolve("test-dir");

        FileWatcher fileWatcher = new FileWatcher(testDir);
        fileWatcher.addListener(changes);
        fileWatcher.init();
        assertThat(changes.notifications(), hasSize(0));
        changes.notifications().clear();

        Files.createDirectories(testDir);
        touch(testDir.resolve("test0.txt"));
        touch(testDir.resolve("test1.txt"));

        fileWatcher.checkAndNotify();
        assertThat(changes.notifications(), contains(
                equalTo("onDirectoryCreated: test-dir/"),
                equalTo("onFileCreated: test-dir/test0.txt"),
                equalTo("onFileCreated: test-dir/test1.txt")
        ));
    }

    @Test
    public void testNoFileOnInit() throws IOException {
        Path tempDir = createTempDir();
        RecordingChangeListener changes = new RecordingChangeListener(tempDir);
        Path testFile = tempDir.resolve("testfile.txt");

        FileWatcher fileWatcher = new FileWatcher(testFile);
        fileWatcher.addListener(changes);
        fileWatcher.init();
        assertThat(changes.notifications(), hasSize(0));
        changes.notifications().clear();

        touch(testFile);

        fileWatcher.checkAndNotify();
        assertThat(changes.notifications(), contains(
                equalTo("onFileCreated: testfile.txt")
        ));
    }
    
    static void touch(Path path) throws IOException {
        Files.newOutputStream(path).close();
    }
    
    static void append(String string, Path path, Charset cs) throws IOException {
        try (BufferedWriter writer = Files.newBufferedWriter(path, cs, StandardOpenOption.APPEND)) {
            writer.append(string);
        }
    }
}