package com.atlassian.stash.internal.config;

import com.atlassian.stash.internal.ApplicationConstants;
import com.atlassian.stash.internal.home.HomeDirectoryDetails;
import com.google.common.collect.ImmutableMap;
import com.google.common.io.CharStreams;
import com.google.common.io.LineProcessor;
import org.apache.commons.io.input.ProxyReader;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.annotation.Nonnull;
import java.io.BufferedWriter;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.Reader;
import java.nio.channels.Channels;
import java.nio.channels.FileChannel;
import java.nio.channels.FileLock;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.EnumSet;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * Migrates the legacy (3.x and earlier) configuration file to its new location. As part of this migration, a
 * <i>whitelisted set</i> of properties are rewritten based on known key changes.
 * <p>
 * While the migration is being performed, an <i>exclusive</i> file-level lock is held on the legacy configuration
 * file. This lock is intended to prevent other nodes, in a clustered installation, from attempting to migrate the
 * configuration at the same time. On the off chance two cluster nodes try to migrate concurrently, one will fail
 * and lock itself out, requiring a restart.
 *
 * @since 4.0
 */
public class ConfigurationMigration {

    /**
     * The new name for the {@link ApplicationConstants#LEGACY_CONFIG_FILE legacy configuration}, after it has
     * been migrated.
     * <p>
     * The similarity to the original legacy name here is <i>intentional</i>. The goal is to keep the two files
     * similar enough that an administrator looking at the directory listing will easily be able to surmise the
     * file's origins.
     */
    public static final String MIGRATED_CONFIG = "stash-config.old";

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

    private final HomeDirectoryDetails details;

    public ConfigurationMigration(HomeDirectoryDetails details) {
        this.details = details;
    }

    public void perform() {
        Path sharedHome = details.getSharedHome().toPath();
        Path legacyConfig = sharedHome.resolve(ApplicationConstants.LEGACY_CONFIG_FILE);
        Path newConfig = sharedHome.resolve(ApplicationConstants.CONFIG_FILE);

        boolean migrated = false;
        try (FileChannel legacyChannel = openLegacyConfig(legacyConfig);
             //If legacy configuration exists, try to lock the file. For clustered installations, this lock
             //should prevent other nodes from attempting to migrate the file concurrently.
             FileLock lock = legacyChannel.tryLock()) {
            if (lock == null) {
                log.error("{} could not be locked. In a clustered environment, this likely means another " +
                        "node in the cluster is migrating it. Allow that node to start completely and then " +
                        "restart this node.");

                //If we can't lock the file, assume another node in the cluster is trying to migrate it. Rather
                //than retrying, just fail. This will Johnson the system. An administrator can restart it after
                //the other cluster node comes up fully and it should start without issue
                throw new ConfigurationMigrationException(ApplicationConstants.LEGACY_CONFIG_FILE +
                        " could not be locked for migration.\n\nIn a clustered installation one node should" +
                        " be started and allowed to perform the initial migration, and then the other nodes" +
                        " should be started.");
            }

            //The legacy configuration exists, and we've successfully locked it. The next step is to migrate
            //it to the new location, potentially translating some properties as we go
            try (BufferedWriter newWriter = openNewConfig(newConfig)) {
                translateConfig(legacyChannel, newWriter);
            }
            migrated = true;
        } catch (FileNotFoundException | NoSuchFileException ignored) {
            //Since the shared home might be on an NFS mount, rather than performing an exists() or isFile()
            //check we just try to open it. If we can't open, all's well; there's nothing to migrate
            log.trace("{} does not exist", ApplicationConstants.LEGACY_CONFIG_FILE);
        } catch (IOException e) {
            log.error("{} could not be migrated to {} due an I/O exception. The exception follows.",
                    ApplicationConstants.LEGACY_CONFIG_FILE, ApplicationConstants.CONFIG_FILE, e);

            throw new ConfigurationMigrationException(ApplicationConstants.LEGACY_CONFIG_FILE + " could not be" +
                    " migrated to " + ApplicationConstants.CONFIG_FILE + ". Additional information about the" +
                    " failure is available in the logs.", e);
        }

        if (migrated) {
            try {
                //Lastly, rename the legacy configuration so that subsequent starts won't try to migrate it again.
                //This is outside the try-with-resources to ensure the file isn't still open
                Files.move(legacyConfig, sharedHome.resolve(MIGRATED_CONFIG));
            } catch (IOException e) {
                log.error("{} could not be renamed to {} after migration",
                        ApplicationConstants.LEGACY_CONFIG_FILE, MIGRATED_CONFIG, e);

                throw new ConfigurationMigrationException("After " + ApplicationConstants.LEGACY_CONFIG_FILE +
                        " was migrated to " + ApplicationConstants.CONFIG_FILE + ", it could not be removed.\n\n" +
                        "Subsequent restarts will detect both files and fail to start. An administrator must review " +
                        ApplicationConstants.CONFIG_FILE + " to ensure it has been migrated correctly, remove " +
                        ApplicationConstants.LEGACY_CONFIG_FILE + " and restart.");
            }
        }
    }

    private FileChannel openLegacyConfig(Path legacyConfig) throws IOException {
        //Note that the legacy configuration is opened for read _and write_ to allow for a more comprehensive
        //lock. It will not actually be written to
        return FileChannel.open(legacyConfig, EnumSet.of(StandardOpenOption.READ, StandardOpenOption.WRITE));
    }

    private BufferedWriter openNewConfig(Path newConfig) throws IOException {
        try {
            //We use CREATE_NEW here to ensure the new configuration doesn't already exist.
            return Files.newBufferedWriter(newConfig, StandardCharsets.UTF_8,
                    StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE);
        } catch (FileAlreadyExistsException e) {
            log.error("Both {} and {} already exist. These files will need to be manually merged by an " +
                    "administrator.", ApplicationConstants.LEGACY_CONFIG_FILE, ApplicationConstants.CONFIG_FILE);

            //This means both the legacy and new configuration files exist. With this type of hybrid state it's
            //not possible to know what to do next, so we just fail
            throw new ConfigurationMigrationException(
                    ApplicationConstants.CONFIG_FILE + " and " + ApplicationConstants.LEGACY_CONFIG_FILE +
                            " both exist. Their contents cannot be merged automatically. An administrator must" +
                            " manually merge their contents, remove " + ApplicationConstants.LEGACY_CONFIG_FILE +
                            " and restart.", e);
        } catch (FileNotFoundException | NoSuchFileException e) {
            log.error("{} could not be migrated; {} could not be created. Because the legacy settings are " +
                    "no longer honored, the system will be locked rather than starting up without configuration.",
                    ApplicationConstants.LEGACY_CONFIG_FILE, ApplicationConstants.CONFIG_FILE, e);

            //This means the new configuration file does not exist and could not be created
            throw new ConfigurationMigrationException("Settings in " + ApplicationConstants.LEGACY_CONFIG_FILE +
                    " will no longer be honored and must be migrated to " + ApplicationConstants.CONFIG_FILE +
                    ", but that file could not be created. Additional information about the failure is available" +
                    "in the logs.", e);
        }
    }

    private void translateConfig(FileChannel legacyConfig, final BufferedWriter newConfig) throws IOException {
        // Note that, while newReader does not return a BufferedReader, the implementation _is_ buffered
        Reader proxyReader = new ProxyReader(Channels.newReader(legacyConfig, StandardCharsets.UTF_8.newDecoder(), 8192)) {
            @Override
            public void close() throws IOException {
                // The channel is closed later automatically; we don't need it closed here.
            }
        };
        CharStreams.readLines(proxyReader, new TranslatingLineProcessor(newConfig));
    }

    private static class TranslatingLineProcessor implements LineProcessor<Void> {

        private final boolean debug;
        private final Pattern pluginProperty;
        private final Map<String, String> translatedKeys;
        private final BufferedWriter writer;

        private boolean multiLine;

        public TranslatingLineProcessor(BufferedWriter writer) {
            this.writer = writer;

            //These aren't static so they don't take up memory for the lifetime of the application, especially
            //given that most runs they won't even be used
            debug = log.isDebugEnabled();
            pluginProperty = Pattern.compile("plugin\\.stash-([\\w-]+)(.+)");
            translatedKeys = ImmutableMap.<String, String>builder()
                    .put("branch-information", "branch-information")
                    .put("metrics", "metrics")
                    .put("metrics-influxdb", "metrics-influxdb")
                    .put("notification", "notification")
                    .put("readme", "readme")
                    .put("repository-ref-sync", "repository-ref-sync")
                    .put("scm-cache", "scm-cache")
                    .put("scm-git", "git")
                    .put("scm-hg", "hg")
                    .build();
        }

        @Override
        public Void getResult() {
            return null;
        }

        @Override
        public boolean processLine(@Nonnull String line) throws IOException {
            if (!multiLine) {
                //If we're not handling a subsequent line of a multi-line property, check to
                //see if this line's key needs to be translated
                Matcher matcher = pluginProperty.matcher(line);
                if (matcher.matches()) {
                    String oldKey = matcher.group(1);
                    String newKey = translatedKeys.get(oldKey);
                    if (newKey != null) {
                        String newLine = "plugin.bitbucket-" + newKey + matcher.group(2);

                        if (debug) {
                            //Because some lines might contain sensitive data, like usernames or passwords,
                            //only the key part of the line is logged
                            log.debug("Migrated {}->{}:\n\tOld: {}\n\tNew: {}",
                                    oldKey, newKey, toKey(line), toKey(newLine));
                        }
                        line = newLine;
                    }
                }
            }

            //Write the line (which may have been translated) to the new configuration file,
            //followed by a platform-specific newline
            writer.write(line);
            writer.newLine();

            //Check to see if the next line will be part of this same property
            multiLine = line.endsWith("\\");

            return true; //Process all lines in the file
        }

        /**
         * Provides a <i>best-effort</i> mechanism to extract the property key from the provided line.
         *
         * @param line the property line
         * @return the extracted key, or the entire line if a key couldn't be determined
         */
        private static String toKey(String line) {
            int index = line.indexOf('='); //For our usages, this is the dominant key/value separator
            if (index == -1) {
                index = line.indexOf(':'); //The properties format allows using : instead of =
                if (index == -1) {
                    return line;
                }
            }

            return line.substring(0, index);
        }
    }
}
