package com.atlassian.stash.internal.home;

import com.atlassian.stash.internal.ApplicationConstants;
import com.atlassian.stash.internal.ApplicationSettings;
import com.google.common.collect.ImmutableList;
import org.apache.commons.lang3.SystemUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.File;
import java.io.IOException;
import java.nio.file.FileStore;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;

/**
 * Moves some directories from local {@code stash.home} to shared {@code stash.shared.home} in preparation
 * to support clustered installations.
 * <p>
 * Before Stash 3.1 all main subdirectories were put under {@code stash.home} folder. However since introduction of
 * clustering it was determined that some of these directories need to be shared between all nodes in the cluster.
 * This includes the following:
 * <ul>
 *      <li>/config</li>
 *      <li>/data</li>
 *      <li>/plugins/installed-plugins</li>
 *      <li>/stash-config.properties</li>
 * </ul>
 * This class makes sure that they are put at the appropriate place under {@code stash.shared.home}, which if not
 * defined will default to {@code stash.home/shared}.
 * <p>
 * This migration is for upgrading from versions <i>before 3.2</i> to versions after. Any such version <i>must</i> be
 * using the legacy configuration file. That means this migration class only needs to handle legacy configuration; it
 * doesn't need to handle the new approach. The wiring in {@code critical-context.xml} ensures this migration happens
 * before {@code ConfigurationMigration} is applied. That means if the instance is being upgraded from 3.1 to 4.0, for
 * example, the flow will be:
 * {@code &lt;home&gt;/stash-config.properties} -> {@code &lt;home&gt;/shared/stash-config.properties}
 * {@code &lt;home&gt;/shared/stash-config.properties}  -> {@code &lt;home&gt;/shared/bitbucket.properties}
 *
 * @since 3.2
 */
public class HomeDirectoryMigration {

    private static final List<String> FILES_AND_DIRECTORIES_TO_MOVE = ImmutableList.of(
            // These files and directories are moved into shared/, in the following order:
            ApplicationSettings.CONFIG_DIR_NAME,
            ApplicationSettings.DATA_DIR_NAME,
            ApplicationSettings.PLUGINS_DIR_NAME + "/" + ApplicationSettings.INSTALLED_PLUGINS_DIR_NAME,
            // See class-level Javadocs for an explanation of the use of LEGACY_CONFIG_FILE here
            ApplicationConstants.LEGACY_CONFIG_FILE
    );
    private static final List<String> FILES_AND_DIRECTORIES_TO_VALIDATE = new ImmutableList.Builder<String>()
            // The `FILES_AND_DIRECTORIES_TO_MOVE` MUST be present in this list, do not remove!
            .addAll(FILES_AND_DIRECTORIES_TO_MOVE)
            // This directory is not directly manipulated in the migration, but as a special case we do some validation
            // checks on it before attempting to do anything.  The data/repositories directory is the most likely one
            // for customers to have manually mounted / symbolically linked in a way that can't be migrated atomically,
            // so it's worth handling it as a special case.
            .add(ApplicationSettings.DATA_DIR_NAME + "/" + ApplicationSettings.REPOSITORIES_DIR_NAME)
            .build();
    private static final Logger log = LoggerFactory.getLogger(HomeDirectoryMigration.class);

    private final HomeFileMover homeFileMover;
    private final HomeDirectoryDetails homeDirectoryDetails;
    private final HomeSubdirectoryResolver homeSubdirectoryResolver;

    public HomeDirectoryMigration(HomeDirectoryDetails homeDirectoryDetails) {
        this(homeDirectoryDetails,
                new HomeFileMover(new AtomicFileMover()),
                new DefaultHomeSubdirectoryResolver());
    }

    public HomeDirectoryMigration(HomeDirectoryDetails homeDirectoryDetails,
                                  HomeFileMover homeFileMover,
                                  HomeSubdirectoryResolver homeSubdirectoryResolver) {
        this.homeDirectoryDetails = homeDirectoryDetails;
        this.homeFileMover = homeFileMover;
        this.homeSubdirectoryResolver = homeSubdirectoryResolver;
    }

    public void perform() {
        File homeDir = homeDirectoryDetails.getHome();
        File sharedHomeDir = homeDirectoryDetails.getSharedHome();

        validate(homeDir, sharedHomeDir);

        // Actually do the migration.
        for (String fileToMove : FILES_AND_DIRECTORIES_TO_MOVE) {
            performMove(homeDir, sharedHomeDir, fileToMove);
        }
    }

    private static void checkHomeDirectory(File directory, String type) {
        if (!directory.exists()) {
            throw new HomeDirectoryMigrationException(type + " directory [" + directory + "] does not exist.");
        }
        if (!directory.isDirectory()) {
            throw new HomeDirectoryMigrationException(type + " directory [" + directory + "] is not a directory.");
        }
        if (SystemUtils.IS_OS_WINDOWS && directory.getPath().length() > 200) {
            //200 is an arbitrary number, chosen in pull request #724 as "close enough" to the Windows path limit of 260 characters
            log.warn("The {} Directory is close to the Windows max path length ({}). This may prevent merging pull requests in the web UI. See {} for more details.",
                    type, directory.getPath().length(), "https://confluence.atlassian.com/x/04oMEg");
        }
    }

    private static FileStore getFileStore(File file) {
        try {
            return Files.getFileStore(file.toPath());
        } catch (IOException | SecurityException e) {
            return null;
        }
    }

    private void performMove(File sourceParentDir, File targetParentDir, String name) {
        File sourceFile = new File(sourceParentDir, name);
        File targetFile = new File(targetParentDir, name);
        try {

            homeFileMover.move(sourceFile, targetFile);
        } catch (IOException exc) {
            throw new HomeDirectoryMigrationException("Could not move '" + sourceFile.getPath() + "' to '" +
                    targetFile.getPath() + "'.", exc);
        }
    }

    private void raiseValidationErrorsIfAny(List<String> validationErrors) {
        if (validationErrors.size() > 0) {
            throw new HomeDirectoryMigrationException(validationErrors);
        }
    }

    private void validate(File homeDir, File sharedHomeDir) {
        checkHomeDirectory(homeDir, "Home");
        checkHomeDirectory(sharedHomeDir, "Shared Home");

        //Before attempting to move home directories, validate that no overrides are present for them
        homeSubdirectoryResolver.getHomeSubdirectoryOverride(ApplicationSettings.CONFIG_DIR_NAME);
        homeSubdirectoryResolver.getHomeSubdirectoryOverride(ApplicationSettings.DATA_DIR_NAME);
        homeSubdirectoryResolver.getHomeSubdirectoryOverride(ApplicationSettings.PLUGINS_DIR_NAME);

        // Before we attempt to do anything, validate that the files and directories we are about to move can actually
        // be moved atomically.  This is a "best effort" validation, as it's impossible to predict in general whether
        // the moves will fail without actually doing them.  If any part of the migration won't succeed, fail fast and
        // give the user a pointer to our upgrade guide.
        List<String> validationErrors = new ArrayList<>();
        for (String fileToValidate : FILES_AND_DIRECTORIES_TO_VALIDATE) {
            validateMove(homeDir, sharedHomeDir, fileToValidate, validationErrors);
        }
        raiseValidationErrorsIfAny(validationErrors);
    }

    /**
     * Validate that the file {@link String name} (which may contain "/" separators) can be moved from
     * {@link File sourceParentDir} to {@link File targetParentDir}.  If anything would go wrong in such a move,
     * add a message to {@param validationErrors} describing the problem.
     */
    private void validateMove(File sourceParentDir, File targetParentDir, String name, List<String> validationErrors) {
        File source = new File(sourceParentDir, name);
        File target = new File(targetParentDir, name);
        String sourcePath = source.getAbsolutePath();
        String targetPath = target.getAbsolutePath();

        // Check that both the old and new paths don't exist.
        if (source.exists() && target.exists()) {
            validationErrors.add("Both old '" + sourcePath + "' and new '" + targetPath + "' exist.");
        }

        // Check that the existing path and the destination shared/ directory are not on different devices
        FileStore sourceFileStore = getFileStore(source);
        FileStore targetParentFileStore = getFileStore(targetParentDir);
        if (sourceFileStore != null && !sourceFileStore.equals(targetParentFileStore)) {
            validationErrors.add("Can't move '" + sourcePath + "' to '" + targetPath +
                    "' automatically, as it is on a different volume or mount point.\n" +
                    "Volume for '" + sourcePath + "': " + sourceFileStore + "\n" +
                    "Volume for '" + targetParentDir.getAbsolutePath() + "': " + targetParentFileStore);
        }

        // Check that the existing path isn't a relative symbolic link.
        try {
            Path sourceSymbolicLink = Files.readSymbolicLink(source.toPath());
            if (!sourceSymbolicLink.isAbsolute()) {
                validationErrors.add("Can't move '" + sourcePath + "' to '" + targetPath +
                        "' automatically, as it is a relative symbolic link to '" + sourceSymbolicLink.toString()
                        + "'.");
            }
        } catch (IOException | SecurityException | UnsupportedOperationException e) {
            // If it's not a symbolic link or we can't read it, just ignore.
        }
    }
}
