package com.atlassian.bitbucket.mesh.app;

import com.atlassian.bitbucket.mesh.Product;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.nio.channels.OverlappingFileLockException;
import java.nio.file.Path;

/**
 * Binds management of the home directory lock to the Spring {@code ApplicationContext}'s lifecycle, acquiring the lock
 * on startup and releasing it on shutdown.
 * <p>
 * <b>Warning:</b> Do not attempt to use component scanning and autowiring for this object. There are some very nasty
 * gremlins lurking. When using autowiring, Spring attempts to resolve types for injection by interrogating all beans
 * in the context. This has the unhappy side effect of causing {@code FactoryBean} instances in the context to be very
 * aggressively instantiated, which can result in substantial "hoisting"--lifting the creation and initialisation for
 * entries in the context far away from the order they're declared in. More seriously, though, when a candidate for
 * autowiring injection to a {@code FactoryBean} fails to initialise (for example, because {@link #lock()} throws an
 * exception), <i>Spring will repeatedly reattempt that initialisation once for each constructor argument</i>. That
 * means {@link #lock()} can potentially be called many, many times where it is expected to only be called once.
 * <p>
 * To avoid this issue, critical components in the {@code ApplicationContext} should not use autowiring.
 */
public class HomeLockAcquirer {

    protected static final Logger log = LoggerFactory.getLogger(HomeLockAcquirer.class);

    protected final Path directory;

    private volatile DirectoryLock lock;

    public HomeLockAcquirer(Path directory) {
        this.directory = directory;
    }

    public boolean isLocked() {
        return lock != null && lock.isLocked();
    }

    /**
     * Attempts to obtain an exclusive lock on the configured directory and, if one cannot be obtained, throws
     * a {@link HomeLockException}.
     *
     * @throws HomeLockDeniedException Thrown if the lock cannot be acquired <i>because another instance holds it</i>.
     * @throws HomeLockFailedException Thrown if the lock cannot be acquired for any other reason.
     */
    public void lock() {
        if (lock != null) {
            throw new IllegalStateException("The lock is already held by this instance and cannot be reacquired");
        }

        DirectoryLock directoryLock = createDirectoryLock(directory);
        try {
            if (directoryLock.lock()) {
                log.info("Successfully acquired lock on home directory {}", directory);
            } else {
                log.error("Directory {} has already been locked by another instance", directory);

                onLockDenied(directoryLock);
            }
        } catch (OverlappingFileLockException e) {
            log.error("The current JVM holds an overlapping lock on directory {}. Are there two {} " +
                    "instances deployed in the same application server?", directory, Product.NAME);

            onLockFailed(directoryLock, e);
        } catch (IOException e) {
            log.error("Lock file {} cannot be obtained in home directory {}. " +
                            "Does {} have write permission on that directory? " +
                            "Is file locking enabled for the filesystem?",
                    directoryLock, directory, System.getProperty("user.name"), e);

            onLockFailed(directoryLock, e);
        }

        lock = directoryLock;
    }

    /**
     * Unlocks the directory, if the current process holds the lock.
     * <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.
     */
    public void unlock() {
        if (lock == null) {
            return;
        }

        log.info("Releasing lock on {}", directory);
        try {
            lock.close();
        } finally {
            lock = null;
        }
    }

    /**
     * Create a new {@link DirectoryLock} for the given directory.
     * <p>
     * Subclasses of this can override this method to customise its creation
     *
     * @param directory the directory to create the lock on
     * @return the {@link DirectoryLock}
     */
    protected DirectoryLock createDirectoryLock(Path directory) {
        return new DirectoryLock(directory, false);
    }

    protected String getLockDeniedAction(DirectoryLock lock) {
        return String.format("Please stop the other instance of %1$s and restart this instance.\n\n" +
                             "If you are absolutely certain that no other instance of %1$s is running, " +
                             "please remove the lock file '%2$s' and restart this instance.",
                Product.NAME, lock);
    }

    protected String getLockDeniedMessage(DirectoryLock lock) {
        return String.format("Directory '%2$s' is already locked by another running instance of %1$s.",
                Product.NAME, directory.toAbsolutePath());
    }

    protected String getLockFailedAction(DirectoryLock lock) {
        return String.format("Please ensure that the user running %1$s has permission to write to this directory.\n\n" +
                             "If this is already the case, please check the logs for more information.",
                Product.NAME);
    }

    protected String getLockFailedMessage(DirectoryLock lock) {
        return String.format("Unable to create and acquire %4$s lock file '%3$s' for %1$s directory '%2$s'.",
                Product.NAME, directory.toAbsolutePath(), lock,
                lock.isShared() ? "shared" : "exclusive");
    }

    /**
     * Throws a {@link HomeLockDeniedException} to indicate that the directory is already locked and the lock
     * cannot be acquired by the current process.
     * <p>
     * Note: Because there is no platform independent way to (reliably) get the process ID for the running instance
     * when the lock is created, we have only a loose idea of who holds the lock. For that reason, we do not attempt
     * to call out the other process. We instead note that the directory is already locked and it is left as an
     * exercise for the reader to determine by whom.
     *
     * @param lock the directory lock
     * @see #onLockFailed(DirectoryLock, Exception)
     */
    private void onLockDenied(DirectoryLock lock) {
        throw new HomeLockDeniedException(getLockDeniedMessage(lock), getLockDeniedAction(lock));
    }

    /**
     * Throws a {@link HomeLockFailedException} to indicate that the attempt to lock the directory failed. This is
     * intentionally a different error to when the directory is already locked.
     *
     * @param lock the directory lock
     * @see #onLockDenied(DirectoryLock)
     */
    private void onLockFailed(DirectoryLock lock, Exception e) {
        throw new HomeLockFailedException(getLockFailedMessage(lock), getLockFailedAction(lock), e);
    }
}
