package com.atlassian.bitbucket.mesh.io;

import com.atlassian.bitbucket.mesh.io.UnderLock.LockedFile;
import com.google.common.collect.ImmutableList;
import org.hamcrest.Matcher;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.channels.FileChannel;
import java.nio.channels.FileLock;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.Charset;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.List;
import java.util.concurrent.BrokenBarrierException;
import java.util.concurrent.CyclicBarrier;

import static java.nio.charset.StandardCharsets.UTF_8;
import static java.nio.file.StandardOpenOption.CREATE_NEW;
import static java.nio.file.StandardOpenOption.WRITE;
import static org.hamcrest.CoreMatchers.allOf;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.greaterThanOrEqualTo;
import static org.hamcrest.Matchers.lessThanOrEqualTo;
import static org.junit.Assert.*;

public class UnderLockTest {

    @Rule
    public final TemporaryFolder temporaryDirectory = new TemporaryFolder();

    private Path file;
    private Path lockFile;

    @Before
    public void setUp() {
        file = temporaryDirectory.getRoot().toPath().resolve("file.txt");
        lockFile = file.resolveSibling(file.getFileName() + ".lock");
    }

    @Test
    public void testCreationWithoutEditDoesNotCreateFile() {
        assertNotNull(UnderLock.forFile(file));

        assertFalse(Files.exists(file));
        assertFalse(Files.exists(lockFile));
    }

    @Test
    public void testCreationWithoutEditDoesNotUpdateFile() throws IOException {
        MoreFiles.write(file, "testing", UTF_8);

        assertNotNull(UnderLock.forFile(file));
        assertFalse(Files.exists(lockFile));
        assertEquals("testing", MoreFiles.toString(file, UTF_8));
    }

    @Test(timeout = 2000L)
    public void testEditBlocksUntilLockAvailable() throws Exception {
        CyclicBarrier barrier = new CyclicBarrier(2);

        //Simulate a thread holding the lock for some time
        LockingOperationThread thread = new LockingOperationThread(barrier, file, 100);
        thread.start();

        try {
            barrier.await(); // waits for the thread to acquire the lock and start the simulated 50ms task
            long start = System.currentTimeMillis();
            // editRaw should block until the simulated task finishes
            UnderLock.forFile(file, Duration.ofMillis(150))
                    .edit((reader, writer) -> writer.write("this is a test"), false);
            long duration = System.currentTimeMillis() - start;
            //allow 100ms margin beyond the deadline to harden the test
            assertThat(duration, between(100, 250));
        } finally {
            thread.join();
        }
        assertFalse(Files.isRegularFile(lockFile));
    }

    @Test
    public void testEditExistingFileWithBackup() throws IOException {
        MoreFiles.write(file, "line1\nline2\n", UTF_8);
        UnderLock.forFile(file).edit((reader, writer) -> {
            assertNotNull(reader);
            assertEquals(ImmutableList.of("line1", "line2"), readLines(reader));
            writer.write("this is a test\n");
        }, true);

        assertEquals("this is a test\n", MoreFiles.toString(file, UTF_8));
        assertEquals("line1\nline2\n", MoreFiles.toString(file.resolveSibling("file.txt.bak"), UTF_8));
    }

    @Test
    public void testEditExistingFileWithoutBackup() throws IOException {
        MoreFiles.write(file, "line1\nline2\n", UTF_8);
        UnderLock.forFile(file).edit((reader, writer) -> {
            assertNotNull(reader);
            assertEquals(ImmutableList.of("line1", "line2"), readLines(reader));
            writer.write("this is a test\n");
        }, false);

        assertEquals("this is a test\n", MoreFiles.toString(file, UTF_8));
        assertFalse(Files.exists(file.resolveSibling("file.txt.bak")));
    }

    @Test
    public void testEditFileThatDoesNotExist() throws IOException {
        UnderLock.forFile(file).edit((reader, writer) -> {
            assertNull(reader);
            writer.write("this is a test\n");
        }, true);

        assertEquals("this is a test\n", MoreFiles.toString(file, UTF_8));
        assertFalse(Files.exists(file.resolveSibling(file.getFileName() + ".bak")));
    }

    @Test
    public void testEditFileThatThrowsIsNotPublished() throws IOException {
        MoreFiles.write(file, "line1\nline2\n");
        IOException ex = assertThrows(IOException.class, () -> UnderLock.forFile(file).edit((reader, writer) -> {
            writer.write("this is a test\n");
            writer.flush();
            writer.write("unflushed line\n");
            throw new IOException("Simulating I/O failure");
        }, true));

        //File should not be published
        assertEquals("line1\nline2\n", MoreFiles.toString(file, UTF_8));
        //Lock file should be cleaned up after failure
        assertFalse(Files.exists(lockFile));
        assertEquals("Simulating I/O failure", ex.getMessage());
    }

    @Test
    public void testEditLockedFileThrows() throws IOException {
        UnderLock.forFile(file).edit((reader, writer) -> {
            writer.write("more testing");
            assertThrows(OverlappingFileLockException.class,
                    () -> UnderLock.forFile(file).edit((reader2, writer2) -> writer2.write("testing"), false));
        }, false);

        assertEquals("more testing", MoreFiles.toString(file, UTF_8));
    }

    @Test(timeout = 2000L)
    public void testEditRawBlocksUntilLockAvailable() throws Exception {
        CyclicBarrier barrier = new CyclicBarrier(2);

        //Simulate a thread holding the lock for some time
        LockingOperationThread thread = new LockingOperationThread(barrier, file, 100);
        thread.start();

        try {
            barrier.await(); // waits for the thread to acquire the lock and start the simulated 50ms task
            long start = System.currentTimeMillis();
            // editRaw should block until the simulated task finishes
            UnderLock.forFile(file, Duration.ofMillis(150)).editRaw(
                    (input, output) -> output.write("this is a test".getBytes(UTF_8)), false);
            long duration = System.currentTimeMillis() - start;
            //allow 100ms margin beyond the deadline to harden the test
            assertThat(duration, between(100, 250));
        } finally {
            thread.join();
        }
        assertFalse(Files.isRegularFile(lockFile));
    }

    @Test
    public void testEditRawExistingFileWithBackup() throws IOException {
        MoreFiles.write(file, "line1\nline2\n", UTF_8);
        UnderLock.forFile(file).editRaw((input, output) -> {
            assertNotNull(input);
            assertEquals(ImmutableList.of("line1", "line2"), readLines(input, UTF_8));
            output.write("this is a test\n".getBytes(UTF_8));
        }, true);

        assertEquals("this is a test\n", MoreFiles.toString(file, UTF_8));
        assertEquals("line1\nline2\n", MoreFiles.toString(file.resolveSibling(file.getFileName() + ".bak"), UTF_8));
    }

    @Test
    public void testEditRawExistingFileWithoutBackup() throws IOException {
        MoreFiles.write(file, "line1\nline2\n", UTF_8);
        UnderLock.forFile(file).editRaw((input, output) -> {
            assertNotNull(input);
            assertEquals(ImmutableList.of("line1", "line2"), readLines(input, UTF_8));
            output.write("this is a test\n".getBytes(UTF_8));
        }, false);

        assertEquals("this is a test\n", MoreFiles.toString(file, UTF_8));
        assertFalse(Files.exists(file.resolveSibling("file.txt.bak")));
    }

    @Test
    public void testEditRawFileThatDoesNotExist() throws IOException {
        UnderLock.forFile(file).editRaw((input, output) -> {
            assertNull(input);
            output.write("this is a test\n".getBytes(UTF_8));
        }, true);

        assertEquals("this is a test\n", MoreFiles.toString(file, UTF_8));
        assertFalse(Files.exists(file.resolveSibling(file.getFileName() + ".bak")));
    }

    @Test(timeout = 2000L)
    public void testEditRawThrowsIfLockCannotBeAcquiredBeforeTimeout() throws Exception {
        CyclicBarrier barrier = new CyclicBarrier(2);

        //Simulate a thread holding the lock for some time
        LockingOperationThread thread = new LockingOperationThread(barrier, file, 100);
        thread.start();

        try {
            barrier.await(); // waits for the thread to hold the lock and start the simulated 100ms task
            long start = System.currentTimeMillis();
            assertThrows(OverlappingFileLockException.class, () -> UnderLock.forFile(file, Duration.ofMillis(50))
                    .editRaw((input, output) -> output.write("this is a test".getBytes(UTF_8)), false));
            long duration = System.currentTimeMillis() - start;
            assertThat(duration, between(50, 150));
        } finally {
            thread.join();
        }
        assertFalse(Files.isRegularFile(lockFile));
    }

    @Test
    public void testEditRawThrowsWhenAlreadyLocked() throws IOException {
        UnderLock.forFile(file).editRaw((input, output) -> {
            output.write("more testing".getBytes(UTF_8));
            assertThrows(OverlappingFileLockException.class,
                    () -> UnderLock.forFile(file).edit((reader2, writer2) -> writer2.write("testing"), false));
        }, false);

        assertEquals("more testing", MoreFiles.toString(file, UTF_8));
    }

    @Test(timeout = 2000L)
    public void testEditThrowsIfLockCannotBeAcquiredBeforeTimeout() throws Exception {
        CyclicBarrier barrier = new CyclicBarrier(2);

        //Simulate a thread holding the lock for some time
        LockingOperationThread thread = new LockingOperationThread(barrier, file, 100);
        thread.start();

        try {
            barrier.await(); // waits for the thread to hold the lock and start the simulated 100ms task
            long start = System.currentTimeMillis();
            assertThrows(OverlappingFileLockException.class, () -> UnderLock.forFile(file, Duration.ofMillis(50))
                    .edit((reader, writer) -> writer.write("this is a test"), false));
            long duration = System.currentTimeMillis() - start;
            assertThat(duration, between(50, 150));
        } finally {
            thread.join();
        }
        assertFalse(Files.isRegularFile(lockFile));
    }

    @Test
    public void testLock() throws IOException {
        UnderLock underLock = UnderLock.forFile(file);
        try (LockedFile ignored = underLock.lock()) {
            //verify that the lock has been created
            assertTrue(Files.isRegularFile(lockFile));
            //verify that holding the lock blocks edit operations. Note that the lock is not reentrant
            assertThrows(OverlappingFileLockException.class, underLock::lock);
            assertThrows(OverlappingFileLockException.class,
                    () -> underLock.editRaw((in, out) -> out.write("hi".getBytes(UTF_8)), false));
            assertThrows(OverlappingFileLockException.class,
                    () -> underLock.edit((reader, writer) -> writer.write("hi"), false));
        }

        assertFalse(Files.isRegularFile(file));
        assertFalse(Files.isRegularFile(lockFile));

        //Closing the LockedFile should release the lock
        underLock.edit((reader, writer) -> writer.write("after lock"), false);
        assertEquals("after lock", MoreFiles.toString(file, UTF_8));
    }

    @Test
    public void testLockAndEditPublishesWhenLockIsReleased() throws IOException {
        MoreFiles.write(file, "old contents");
        UnderLock underLock = UnderLock.forFile(file);
        Path backup = file.resolveSibling(file.getFileName() + ".bak");
        try (LockedFile locked = underLock.lock()) {
            locked.edit((reader, writer) -> writer.write("new contents"), true);

            //verify that the updated contents have not yet been published
            assertEquals("old contents", MoreFiles.toString(file));
            assertFalse(MoreFiles.canOpen(backup));
        }

        //verify that the contents have been published when the lock was released
        assertEquals("new contents", MoreFiles.toString(file));
        assertEquals("old contents", MoreFiles.toString(backup));
    }

    @Test(timeout = 2000L)
    public void testLockBlocksUntilLockAvailable() throws Exception {
        CyclicBarrier barrier = new CyclicBarrier(2);

        //Simulate a thread holding the lock for some time
        LockingOperationThread thread = new LockingOperationThread(barrier, file, 100);
        thread.start();

        try {
            barrier.await(); // waits for the thread to acquire the lock and start the simulated 50ms task
            long start = System.currentTimeMillis();
            // lock should block until the simulated task finishes
            try (LockedFile ignored = UnderLock.forFile(file, Duration.ofMillis(150)).lock()) {
                long duration = System.currentTimeMillis() - start;

                //verify that the lock has been created
                assertTrue(Files.isRegularFile(lockFile));

                //allow 25ms margin beyond the deadline to harden the test
                assertThat(duration, between(100, 175));
            }
        } finally {
            thread.join();
        }
        assertFalse(Files.isRegularFile(lockFile));
    }

    @Test
    public void testLockEditAndRollback() throws IOException {
        MoreFiles.write(file, "old contents");
        UnderLock underLock = UnderLock.forFile(file);
        Path backup = file.resolveSibling(file.getFileName() + ".bak");
        try (LockedFile locked = underLock.lock()) {
            locked.edit((reader, writer) -> writer.write("new contents"), true);

            //verify that the updated contents have not yet been published
            assertEquals("old contents", MoreFiles.toString(file));
            assertFalse(MoreFiles.canOpen(backup));

            locked.rollBack();
        }

        //verify that the contents have NOT been published when the lock was released
        assertEquals("old contents", MoreFiles.toString(file));
        assertFalse(MoreFiles.canOpen(backup));
    }

    @Test
    public void testReleaseIfNotHeldDeletesLockFileIfNotLocked() throws IOException {
        MoreFiles.touch(lockFile);

        UnderLock.forFile(file).releaseIfNotHeld();

        assertFalse(Files.exists(lockFile));
    }

    @Test
    public void testReleaseIfNotHeldLockDoesNotThrowIfThereIsNoLockFile() throws IOException {
        UnderLock.forFile(file).releaseIfNotHeld();
    }

    @Test
    public void testReleaseIfNotHeldThrowsIfLockFileIsLocked() throws IOException {
        try (FileChannel channel = FileChannel.open(lockFile, CREATE_NEW, WRITE);
             FileLock lock = channel.tryLock()) {

            //Verify that release does not delete a lock file that is exclusively locked
            assertNotNull(lock);
            assertThrows(OverlappingFileLockException.class, () -> UnderLock.forFile(file).releaseIfNotHeld());

            //Once the exclusive lock is released, it should be possible to release the lock
            lock.close();
            UnderLock.forFile(file).releaseIfNotHeld();
            assertFalse(Files.exists(lockFile));
        }
    }

    private static Matcher<Long> between(long lower, long upper) {
        return allOf(greaterThanOrEqualTo(lower), lessThanOrEqualTo(upper));
    }

    private static List<String> readLines(InputStream input, Charset charset) throws IOException {
        return readLines(new BufferedReader(new InputStreamReader(input, charset)));
    }

    private static List<String> readLines(BufferedReader reader) throws IOException {
        ImmutableList.Builder<String> builder = ImmutableList.builder();
        String line;
        while ((line = reader.readLine()) != null) {
            builder.add(line);
        }
        return builder.build();
    }

    private static class LockingOperationThread extends Thread {

        LockingOperationThread(CyclicBarrier barrier, Path pathToLockOn, long durationMillis) {
            super(() -> {
                try {
                    MoreFiles.mkdir(pathToLockOn.getParent());
                    UnderLock.forFile(pathToLockOn).edit((reader, writer) -> {
                        try {
                            barrier.await(); // Wait for the test thread to hit the barrier as well
                            Thread.sleep(durationMillis); //Simulated task takes durationMillis after acquiring the lock
                        } catch (BrokenBarrierException | InterruptedException e) {
                            e.printStackTrace();
                            fail();
                        }
                    }, false);
                } catch (IOException e) {
                    e.printStackTrace();
                    fail(e.getMessage());
                }
            });
        }
    }
}
