package com.atlassian.bitbucket.mesh.app;

import com.atlassian.bitbucket.mesh.Product;
import com.google.common.io.Closeables;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.annotation.Nonnull;
import java.io.Closeable;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.lang.management.ManagementFactory;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.FileLock;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.EnumSet;
import java.util.Set;

import static java.util.Objects.requireNonNull;

/**
 * A lock which is taken out on a directory, typically the home directory or a data store directory.
 */
public class DirectoryLock implements Closeable {

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

    private final Path lockFile;
    private final boolean shared;

    private volatile FileChannel lockChannel;

    public DirectoryLock(@Nonnull Path directory, boolean shared) {
        this.shared = shared;

        lockFile = requireNonNull(directory, "directory").resolve(".lock");
    }

    /**
     * Unlocks the directory, and delete the lock file if it is locked exclusively
     * <p>
     * Note: If the lock file cannot be deleted for some reason, an error is logged and mark the file for deletion on
     * exit. Because the server may refuse to come up on the subsequent start if the lock file cannot be deleted, we
     * want to do what we can to call attention to that state.
     */
    @Override
    public void close() {
        if (lockChannel == null) {
            return;
        }

        try {
            lockChannel.close();
        } catch (IOException e) {
            log.warn("Failed to close channel for file {}", lockFile, e);
        }

        // When we acquire a shared lock, we don't want to delete the lock file when we exit. Otherwise
        // other processes that are also holding shared locks will lose their lock
        if (!shared) {
            try {
                Files.delete(lockFile);
            } catch (IOException e) {
                // Try a more roundabout approach, in the hopes that the lock file will be deleted when we terminate
                lockFile.toFile().deleteOnExit();

                log.error("Failed to delete lock file {}; it may have to be deleted manually before {} can be started again",
                        lockFile, Product.NAME, e);
            }
        }

        lockChannel = null;
    }

    public boolean isLocked() {
        return lockChannel != null;
    }

    public boolean isShared() {
        return shared;
    }

    /**
     * Acquire the lock on the directory
     *
     * @return {@code true} if the lock was successfully acquired, otherwise {@code false}
     * @throws OverlappingFileLockException when the lock is already acquired by the current JVM
     * @throws IOException                  if the lock file failed to be created
     */
    public boolean lock() throws IOException {
        if (lockChannel != null) {
            throw new OverlappingFileLockException();
        }

        if (isLockable()) {
            return acquireLock();
        }

        throw new FileNotFoundException("Failed to create lock file " + lockFile.toAbsolutePath());
    }

    @Override
    public String toString() {
        return lockFile.toAbsolutePath().toString();
    }

    private boolean acquireLock() throws IOException {
        FileChannel fileChannel = FileChannel.open(lockFile, fileOptionsFor(shared));
        try {
            FileLock lock = fileChannel.tryLock(0L, Long.MAX_VALUE, shared);
            if (lock == null) {
                return false;
            }
            lockChannel = fileChannel;
        } catch (IOException | RuntimeException e) {
            Closeables.close(lockChannel, true);
            throw e;
        }

        // Unfortunately when we acquire a shared lock, we cannot write any information to the file.
        // Luckily we only use this information for debugging purposes
        if (!shared) {
            String whoAmI = ManagementFactory.getRuntimeMXBean().getName();
            try {
                fileChannel.write(ByteBuffer.wrap((whoAmI + "\n").getBytes(StandardCharsets.UTF_8)));
            } catch (IOException e) {
                // We couldn't write an identifier into the lock file. Oh well!
                log.warn("Failed to write process information into the lock file", e);
            }
        }

        return true;
    }

    private static Set<StandardOpenOption> fileOptionsFor(boolean shared) {
        Set<StandardOpenOption> fileOptions = EnumSet.of(StandardOpenOption.READ);
        if (!shared) {
            fileOptions.add(StandardOpenOption.WRITE);
        }
        return fileOptions;
    }

    private boolean isLockable() {
        if (Files.isRegularFile(lockFile)) {
            return true;
        }

        try {
            Files.createFile(lockFile);

            return true;
        } catch (FileAlreadyExistsException e) {
            return true;
        } catch (IOException e) {
            // We check isRegularFile a second time because FileAlreadyExistsException is an "optional specific
            // exception" and filesystem implementations are not required to throw it
            return Files.isRegularFile(lockFile);
        }
    }
}
