package com.atlassian.stash.internal.config;

import com.atlassian.bitbucket.Product;
import com.atlassian.bitbucket.auth.AuthenticationContext;
import com.atlassian.bitbucket.i18n.I18nService;
import com.atlassian.bitbucket.i18n.KeyedMessage;
import com.atlassian.bitbucket.user.ApplicationUser;
import com.atlassian.stash.internal.ApplicationConstants;
import com.atlassian.stash.internal.db.DatabaseConstants;
import com.atlassian.stash.internal.jdbc.DataSourceConfiguration;
import com.atlassian.stash.internal.jdbc.SimpleDataSourceConfiguration;
import com.atlassian.stash.internal.spring.TransactionSynchronizer;
import com.atlassian.stash.internal.util.FilePermissionUtils;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Charsets;
import com.google.common.io.Files;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.core.io.support.PropertiesLoaderUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationAdapter;

import javax.annotation.Nonnull;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.util.Properties;

import static java.util.Objects.requireNonNull;

/**
 * A service for updating {@code bitbucket.properties}.
 */
@Service("configurationService")
public class DefaultConfigurationService implements ConfigurationService {

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

    private final AuthenticationContext authenticationContext;
    private final Clock clock;
    private final File sharedHomeDir;
    private final I18nService i18nService;
    private final TransactionSynchronizer synchronizer;

    /**
     * Constructs a {@code DefaultConfigurationService} using the specified shared home directory and clock.
     *
     * @param sharedHomeDir the shared home directory, where the {@link #getConfigFile configuration file} lives
     * @param clock         the clock used to generate timestamps in the configuration file
     */
    @Autowired
    public DefaultConfigurationService(@Value("#{applicationSettings.sharedHomeDir}") File sharedHomeDir,
                                       AuthenticationContext authenticationContext,
                                       Clock clock, I18nService i18nService, TransactionSynchronizer synchronizer) {
        this.authenticationContext = requireNonNull(authenticationContext, "authenticationContext");
        this.clock = requireNonNull(clock, "clock");
        this.sharedHomeDir = requireNonNull(sharedHomeDir, "sharedHomeDir");
        this.i18nService = requireNonNull(i18nService, "i18nService");
        this.synchronizer = synchronizer;
    }

    @Nonnull
    @Override
    public DataSourceConfiguration loadDataSourceConfiguration() throws IOException {

        Properties properties = loadCurrentConfig();

        return new SimpleDataSourceConfiguration(
                properties.getProperty(DatabaseConstants.PROP_JDBC_DRIVER),
                properties.getProperty(DatabaseConstants.PROP_JDBC_URL),
                properties.getProperty(DatabaseConstants.PROP_JDBC_USER),
                properties.getProperty(DatabaseConstants.PROP_JDBC_PASSWORD));
    }

    @Override
    public void removeMergeStrategyProperties(@Nonnull DeprecateConfigurationRequest request) {
        requireNonNull(request, "request");

        try {
            applyAmendment(new DeprecatePropertiesAmendment(clock, request.getProperties(),
                    request.getDeprecationMessage()));
        } catch (IOException e) {
            throw newFileOperationException("bitbucket.configuration.merge.strategies.removefailed", e);
        }
    }

    @Override
    public void removeSetupProperties(@Nonnull RemoveSetupConfigurationRequest request) {
        requireNonNull(request, "request");

        try {
            applyAmendment(new RemovePropertiesAmendment(clock, request.toProperties()));
        } catch (IOException e) {
            throw newFileOperationException("bitbucket.configuration.setup.removefailed", e);
        }
    }

    @Override
    public void saveDataSourceConfiguration(@Nonnull DataSourceConfiguration dataSourceConfig,
                                            @Nonnull String message) {
        requireNonNull(dataSourceConfig, "dataSourceConfig");
        requireNonNull(message, "message");

        // Note: During setup, for example, the context user will be null. But we still need to make it possible to save
        //       the configuration out, otherwise setup itself can't work.
        ApplicationUser currentUser = authenticationContext.getCurrentUser();

        DataSourceConfigurationAmendment amendment = new DataSourceConfigurationAmendment(
                dataSourceConfig, message, clock, currentUser);
        try {
            applyAmendment(amendment);
            //We don't want clear text database passwords to be world readable so we set permissions appropriate
            //to the current operating system.
            File configFile = getConfigFile();
            try {
                FilePermissionUtils.setRestrictedFilePermissions(configFile);
            } catch (IOException e) {
                log.warn("Could not restrict file permissions on config file " + configFile.getAbsolutePath(), e);
            }
        } catch (IOException e) {
            throw newFileOperationException("bitbucket.configuration.setup.datasourcefailed", e);
        }
    }

    @VisibleForTesting
    void applyAmendment(ConfigurationAmendment amendment) throws IOException {
        // The current configuration file, which may not exist.
        File original = getConfigFile();

        // We always create a draft configuration file, fill it with stuff, and then rename it.
        // This ensures that the opportunity for screwing up the real configuration file is minimised.
        // It also works for the case where we are creating a new configuration file.
        File draft = createDraftConfigFile();

        File backup = null;
        try {
            try (BufferedWriter draftWriter = openFile(draft)) {
                if (original.exists()) {
                    // Keep a back-up of the current configuration file
                    backup = getBackUpConfigFile(original);

                    ConfigurationLineProcessor lineProcessor = new ConfigurationLineProcessor(draftWriter, amendment);

                    // Work through the configuration file, and maybe inject the payload
                    // (if we encounter the expected properties)
                    Files.readLines(original, Charsets.UTF_8, lineProcessor);
                }
                amendment.finalize(draftWriter);
            }

            swapConfigFile(original, draft, backup);
        } catch (FileOperationException | IOException e) {
            // Don't keep useless files around
            deleteFile(draft);
            if (backup != null) {
                deleteFile(backup);
            }

            throw e;
        }
    }

    private void deleteFile(File file) {
        if (!file.delete()) {
            log.warn("Failed to delete {}. Setting it to delete upon exit", file.getAbsolutePath());
            file.deleteOnExit();
        }
    }

    private FileOperationException newFileOperationException(String i18nKey, IOException ioe) {
        String configurationFilePath = getConfigFile().getAbsolutePath();

        return new FileOperationException(i18nService.createKeyedMessage(i18nKey, configurationFilePath), ioe);
    }

    /**
     * @param original The original configuration file
     * @param draft    The file on which the amendments were applied
     * @param backup   The backup of the original file. Can be null.
     */
    private void swapConfigFile(File original, File draft, File backup) {
        rename(draft, original);

        // Register a TransactionSynchronizationAdapter to restore the original configuration
        // file if the transaction failed.
        // Note: While doing a DB migration there is no transaction
        synchronizer.register(new TransactionSynchronizationAdapter() {

            @Override
            public void afterCompletion(int status) {
                if (status == TransactionSynchronization.STATUS_ROLLED_BACK
                        || status == TransactionSynchronization.STATUS_UNKNOWN) {
                    log.warn("Transaction was rolled back or is in an unknown state. Restoring original {} file (if available)",
                            ApplicationConstants.CONFIG_FILE);
                    if (backup != null) {
                        rename(backup, original);
                    }
                }
            }
        });
    }

    private void rename(File src, File dst) {
        try {
            Files.move(src, dst);
        } catch (IOException e) {
            KeyedMessage message = i18nService.createKeyedMessage("bitbucket.configuration.draft.rename",
                    src, dst);
            throw new FileOperationException(message, e);
        }
    }

    /**
     * Makes a copy of the given file, giving it the copy the name of the original plus a &quot;.bak&quot; extension.
     * <p>
     * If the backup file already exists, it is overridden.
     *
     * @param original the file to make a backup of
     * @throws FileOperationException if there is an IO error
     */
    @VisibleForTesting
    File getBackUpConfigFile(File original) {
        File backupFile = new File(original.getAbsolutePath() + ".bak");
        try {
            Files.copy(original, backupFile);
            return backupFile;
        } catch (IOException e) {
            KeyedMessage message = i18nService.createKeyedMessage("bitbucket.configuration.configCopyFail",
                    original, backupFile, Product.NAME);
            throw new FileOperationException(message, e);
        }
    }

    private File getConfigFile() {
        return new File(sharedHomeDir, ApplicationConstants.CONFIG_FILE);
    }

    /**
     * Creates a temporary file that will hold a draft version of the updated configuration file.
     * When it has been successfully filled, we'll rename this file to bitbucket.properties.
     */
    private File createDraftConfigFile() {
        try {
            return File.createTempFile("draft", "properties", sharedHomeDir);
        } catch (IOException e) {
            KeyedMessage message = i18nService.createKeyedMessage("bitbucket.configuration.tempFile.create");
            throw new FileOperationException(message, e);
        }
    }

    private Properties loadCurrentConfig() {
        Resource[] configResources = {
                new ClassPathResource("application-internal.properties"),
                new ClassPathResource("application-default.properties"),
                new FileSystemResource(getConfigFile())};

        Properties properties = new Properties();
        for (Resource resource : configResources) {
            try {
                PropertiesLoaderUtils.fillProperties(properties, resource);
            } catch (IOException e) {
                log.warn("Problem loading properties from {}", resource.getFilename(), e);
            }
        }

        return properties;
    }

    private BufferedWriter openFile(File file) {
        try {
            return Files.newWriter(file, Charsets.UTF_8);
        } catch (FileNotFoundException e) {
            KeyedMessage message = i18nService.createKeyedMessage("bitbucket.configuration.tempFile.notFound", file);
            throw new FileOperationException(message, e);
        }
    }
}
