package com.atlassian.stash.internal.home;

import com.atlassian.bitbucket.Product;
import com.atlassian.stash.internal.ApplicationConstants;
import com.atlassian.stash.internal.ApplicationSettings;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.File;
import java.io.IOException;
import java.nio.channels.OverlappingFileLockException;

/**
 * Binds management of the home directory lock to the Spring {@code ApplicationContext}'s lifecycle, acquiring the lock
 * on startup and releasing it on shutdown. This class handles only locking of the local {@code STASH_HOME} directory, as
 * opposed to {@code STASH_SHARED_HOME} used in clustering mode.
 * <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. They should be
 * explicitly wired in XML. Spring is able to perform much smarter (and safer) initialisation for beans with explicit
 * wiring, which alleviates many of these "hoisting" and repeat initialisation concerns. See further documentation in
 * {@code critical-context.xml} and {@code stash-context.xml} for more details.
 */
public class HomeLockAcquirer {

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

    protected final File homeDir;

    private volatile HomeLock lock;

    public HomeLockAcquirer(File homeDir) {
        this.homeDir = homeDir;
    }

    /**
     * Attempts to obtain an exclusive lock on the configured {@link ApplicationSettings#getHomeDir() home 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");
        }

        HomeLock homeLock = createHomeLock(homeDir);
        try {
            if (homeLock.lock()) {
                log.info("Successfully acquired lock on home directory {}", homeDir);
            } else {
                log.error("Home directory {} has already been locked by another instance", homeDir);

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

            onLockFailed(homeLock, 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?",
                    homeLock, homeDir, System.getProperty("user.name"), e);

            onLockFailed(homeLock, e);
        }

        lock = homeLock;
    }

    /**
     * Unlocks the home 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 {}", homeDir);
        try {
            lock.close();
        } finally {
            lock = null;
        }
    }

    /**
     * Create a new {@link HomeLock} for the given home directory.
     * <p>
     * Subclasses of this can override this method to customise its creation
     */
    protected HomeLock createHomeLock(File homeDir) {
        return new HomeLock(homeDir, false);
    }

    protected String getLockDeniedMessage(HomeLock lock) {
        return String.format("Home directory '%2$s' is already locked by another running instance of %1$s.\n\n" +
                        "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 '%3$s' and restart this instance.",
                Product.NAME, homeDir.getAbsolutePath(), lock);
    }

    protected String getFailedMessage(HomeLock lock) {
        return String.format("Unable to create and acquire %4$s lock file '%3$s' for %1$s home directory '%2$s'.\n\n" +
                        "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, homeDir.getAbsolutePath(), lock,
                lock.isShared() ? "shared" : "exclusive");
    }

    protected boolean isReLockRequired() {
        return isReLockRequired(lock);
    }

    protected boolean isReLockRequired(HomeLock currentLock) {
        return currentLock == null;
    }

    /**
     * Throws a {@link HomeLockDeniedException} to indicate that the home 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 home is already locked and it is left as an exercise
     * for the reader to determine by whom.
     *
     * @param lock the home directory lock
     * @see #onLockFailed(HomeLock, Exception)
     */
    private void onLockDenied(HomeLock lock) {
        throw new HomeLockDeniedException(getLockDeniedMessage(lock));
    }

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