package com.atlassian.stash.internal.server;

import com.atlassian.bitbucket.event.server.*;
import com.atlassian.bitbucket.mail.MailHostConfiguration;
import com.atlassian.bitbucket.mail.MailProtocol;
import com.atlassian.bitbucket.repository.Repository;
import com.atlassian.bitbucket.server.ApplicationMode;
import com.atlassian.bitbucket.server.ApplicationPropertiesService;
import com.atlassian.bitbucket.util.CustomPreconditions;
import com.atlassian.bitbucket.util.ValidationUtils;
import com.atlassian.bitbucket.util.ValueWrapper;
import com.atlassian.cache.*;
import com.atlassian.event.api.EventPublisher;
import com.atlassian.plugin.spring.AvailableToPlugins;
import com.atlassian.stash.internal.ApplicationSettings;
import com.atlassian.stash.internal.BuildInfo;
import com.atlassian.stash.internal.jdbc.DataSourceConfiguration;
import com.atlassian.stash.internal.spring.TransactionSynchronizer;
import com.google.common.base.Throwables;
import org.apache.commons.lang3.LocaleUtils;
import org.apache.commons.lang3.ObjectUtils;
import org.apache.commons.lang3.StringUtils;
import org.hibernate.validator.internal.constraintvalidators.hv.EmailValidator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Component;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronizationAdapter;
import org.springframework.transaction.support.TransactionTemplate;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import javax.validation.Validator;
import java.io.File;
import java.net.URI;
import java.sql.Driver;
import java.sql.DriverManager;
import java.time.DateTimeException;
import java.time.ZoneId;
import java.util.*;

import static com.atlassian.bitbucket.mail.MailHostConfiguration.Builder;
import static com.atlassian.bitbucket.util.NumberUtils.parse;
import static com.atlassian.bitbucket.util.UrlUtils.encodeURL;
import static com.atlassian.bitbucket.util.UrlUtils.uncheckedCreateURI;
import static com.atlassian.stash.internal.ApplicationConstants.DISASTER_RECOVERY;
import static com.atlassian.stash.internal.ApplicationConstants.PROP_APPLICATION_MODE;
import static com.atlassian.stash.internal.server.ApplicationProperty.Key.*;
import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkNotNull;
import static java.util.Optional.ofNullable;

/**
 * ApplicationPropertiesServiceImpl uses an atlassian-cache {@link Cache} to cache the {@link ApplicationProperty} lookups instead of
 * relying on Hibernate 2nd level caching. Many application properties will not exist in the database because the defaults apply.
 * In this case, Hibernate caching will not cache the empty query results and without the atlassian-cache {@link Cache}, the
 * app_property table will be frequently queried.
 */
@AvailableToPlugins(ApplicationPropertiesService.class)
@Component("applicationPropertiesService")
public class ApplicationPropertiesServiceImpl implements InternalApplicationPropertiesService {

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

    private final ApplicationPropertyDao propertyDao;
    private final ApplicationSettings settings;
    private final BuildInfo buildInfo;
    private final Cache<ApplicationProperty.Key, ValueWrapper<String>> cache;
    private final DataSourceConfiguration dataSourceConfiguration;
    private final EventPublisher eventPublisher;
    private final Properties properties;
    private final TransactionTemplate readTx;
    private final TransactionSynchronizer transactionSynchronizer;
    private final Validator validator;

    private volatile boolean setup;

    @Autowired
    @SuppressWarnings("SpringJavaAutowiringInspection")
    public ApplicationPropertiesServiceImpl(ApplicationSettings settings,
                                            BuildInfo buildInfo,
                                            CacheManager cacheManager,
                                            DataSourceConfiguration dataSourceConfiguration,
                                            EventPublisher eventPublisher,
                                            @Qualifier("applicationProperties") Properties properties,
                                            ApplicationPropertyDao propertyDao,
                                            PlatformTransactionManager transactionManager,
                                            TransactionSynchronizer transactionSynchronizer,
                                            Validator validator) {

        this.buildInfo = buildInfo;
        this.dataSourceConfiguration = dataSourceConfiguration;
        this.eventPublisher = eventPublisher;
        this.propertyDao = propertyDao;
        this.properties = properties;
        this.settings = settings;
        this.transactionSynchronizer = transactionSynchronizer;
        this.validator = validator;

        cache = createCache(cacheManager);
        readTx = new TransactionTemplate(transactionManager);
        readTx.setReadOnly(true);
    }

    @Override
    @Transactional
    @PreAuthorize("hasGlobalPermission('SYS_ADMIN')")
    public void deleteMailHostConfiguration() {
        deleteOrUpdateMailHostConfiguration(null);
    }

    @Nonnull
    @Override
    public ApplicationMode getMode() {
        // Note: this value is not read from the database but is instead read from properties. This avoids the situation
        // where calling getMode before the mode has been persisted on first boot results in returning a null value.
        // ApplicationModeGuard checks this value is consistent with the persisted value for MODE (which *is* loaded from
        // the DB by getLastMode()) and the instance is Johnsoned if it does not match.
        return ApplicationMode.fromId(properties.getProperty(PROP_APPLICATION_MODE)).orElse(ApplicationMode.DEFAULT);
    }

    @Override
    public String getAvatarSource() {
        return getApplicationProperty(AVATAR_SOURCE);
    }

    @Override
    public URI getBaseUrl() {
        return uncheckedCreateURI(getApplicationProperty(BASE_URL));
    }

    @Nonnull
    @Override
    public File getBinDir() {
        return settings.getBinDir();
    }

    @Nonnull
    @Override
    public String getBuildVersion() {
        return buildInfo.getBuildVersion();
    }

    @Nonnull
    @Override
    public Date getBuildTimestamp() {
        return buildInfo.getBuildTimestamp();
    }

    @Nonnull
    @Override
    public String getBuildNumber() {
        return buildInfo.getBuildNumber();
    }

    @Nonnull
    @Override
    public File getCacheDir() {
        return settings.getCacheDir();
    }

    @Nonnull
    @Override
    public String getCommitHash() {
        return buildInfo.getCommitHash();
    }

    @Nonnull
    @Override
    public File getConfigDir() {
        return settings.getConfigDir();
    }

    @Nonnull
    @Override
    public File getDataDir() {
        return settings.getDataDir();
    }

    @Nonnull
    @Override
    public TimeZone getDefaultTimeZone() {
        return getServerTimeZone().map(TimeZone::getTimeZone).orElseGet(TimeZone::getDefault);
    }

    @Override
    public String getDisplayName() {
        return getApplicationProperty(NAME);
    }

    @Nonnull
    @Override
    public Optional<Long> getGracePeriodStartTime() {
        return ofNullable(getApplicationProperty(LICENSE_EXCEEDED_GRACE_PERIOD_START)).map(Long::valueOf);
    }

    @Nonnull
    @Override
    public File getHomeDir() {
        return settings.getHomeDir();
    }

    @Nonnull
    @Override
    public File getSharedHomeDir() {
        return settings.getSharedHomeDir();
    }

    @Nonnull
    @Override
    public String getJdbcDriver() {
        return dataSourceConfiguration.getDriverClassName();
    }

    @Nonnull
    @Override
    public String getJdbcDriverVersion() {
        String driverClassName = getJdbcDriver();
        Enumeration<Driver> e = DriverManager.getDrivers();
        while (e.hasMoreElements()) {
            Driver current = e.nextElement();
            if (current.getClass().getName().equalsIgnoreCase(driverClassName)) {
                return String.format("%d.%d", current.getMajorVersion(), current.getMinorVersion());
            }
        }
        return "Unknown";
    }

    @Nonnull
    @Override
    public String getJdbcUrl() {
        return dataSourceConfiguration.getUrl();
    }

    @Nonnull
    @Override
    public Optional<ApplicationMode> getLastMode() {
        return ApplicationMode.fromId(getApplicationProperty(MODE));
    }

    @Override
    public String getLastSharedHomeDir() {
        return getApplicationProperty(LAST_HOME);
    }

    @Nonnull
    @Override
    public Locale getLocale() {
        String locale = getApplicationProperty(LOCALE);
        return locale != null ? LocaleUtils.toLocale(locale) : Locale.getDefault();
    }

    @Nonnull
    @Override
    public URI getLoginUri(URI redirectUri) {
        String loginUri = getBaseUrl() + "/login?next=" + encodeURL(redirectUri.toString());

        return uncheckedCreateURI(loginUri);
    }

    @Override
    public MailHostConfiguration getMailHostConfiguration() {
        // mail host configuration is stored as a set of ApplicationProperty values;
        MailHostConfiguration config = new Builder()
                .hostname(getApplicationProperty(MAIL_HOST))
                .port(parse(getApplicationProperty(MAIL_HOST_PORT), "Configured mail host port is not a number. Using default!"))
                .protocol(MailProtocol.fromString(getApplicationProperty(MAIL_PROTOCOL)))
                .useStartTls(Boolean.parseBoolean(getApplicationProperty(MAIL_USE_TLS)))
                .requireStartTls(Boolean.parseBoolean(getApplicationProperty(MAIL_REQUIRE_TLS)))
                .username(getApplicationProperty(MAIL_HOST_USERNAME))
                .password(getApplicationProperty(MAIL_HOST_PASSWORD)).build();

        // return null if host, port and username are undefined
        return config.getHostname() == null && config.getPort() == null && config.getUsername() == null ? null : config;
    }

    @Override
    public int getMaxCaptchaAttempts() {
        //noinspection ConstantConditions
        return parse(getApplicationProperty(MAX_CAPTCHA_ATTEMPTS), ApplicationPropertiesDefaults.DEFAULT_MAX_CAPTCHA_ATTEMPTS,
                "Configured max captcha attempts is not a number. Using default!");
    }

    @Override
    public String getPluginProperty(@Nonnull String propertyName) {
        validatePluginPropertyName(propertyName);
        return getProperty(propertyName);
    }

    @Override
    public String getPluginProperty(@Nonnull String propertyName, String defaultValue) {
        validatePluginPropertyName(propertyName);
        return getProperty(propertyName, defaultValue);
    }

    @Override
    public int getPluginProperty(@Nonnull String propertyName, int defaultValue) throws NumberFormatException {
        validatePluginPropertyName(propertyName);
        return getProperty(propertyName, defaultValue);
    }

    @Override
    public long getPluginProperty(@Nonnull String propertyName, long defaultValue) throws NumberFormatException {
        validatePluginPropertyName(propertyName);
        return getProperty(propertyName, defaultValue);
    }

    @Override
    public boolean getPluginProperty(@Nonnull String propertyName, boolean defaultValue) {
        validatePluginPropertyName(propertyName);
        return getProperty(propertyName, defaultValue);
    }

    @Override
    public double getPluginProperty(@Nonnull String propertyName, double defaultValue) {
        validatePluginPropertyName(propertyName);
        return getProperty(propertyName, defaultValue);
    }

    @Override
    public String getProperty(@Nonnull String propertyName) {
        if (properties == null) {
            return null;
        }
        return properties.getProperty(propertyName);
    }

    @Override
    public String getProperty(@Nonnull String propertyName, String defaultValue) {
        if (properties == null) {
            return defaultValue;
        }
        return properties.getProperty(propertyName, defaultValue);
    }

    @Override
    public int getProperty(@Nonnull String propertyName, int defaultValue) throws NumberFormatException {
        String value = getProperty(propertyName);
        if (value != null) {
            return Integer.parseInt(value);
        }
        return defaultValue;
    }

    @Override
    public long getProperty(@Nonnull String propertyName, long defaultValue) throws NumberFormatException {
        String value = getProperty(propertyName);
        if (value != null) {
            return Long.parseLong(value);
        }
        return defaultValue;
    }

    @Override
    public double getProperty(@Nonnull String propertyName, double defaultValue) throws NumberFormatException {
        String value = getProperty(propertyName);
        if (value != null) {
            return Double.parseDouble(value);
        }
        return defaultValue;
    }

    @Override
    public boolean getProperty(@Nonnull String propertyName, boolean defaultValue) {
        String value = getProperty(propertyName);
        if (value != null) {
            return Boolean.parseBoolean(value);
        }
        return defaultValue;
    }

    @Nonnull
    @Override
    public Optional<ZoneId> getServerTimeZone() {
        String applicationProperty = getApplicationProperty(SERVER_TIMEZONE);
        try {
            return ofNullable(applicationProperty).map(ZoneId::of);
        } catch (DateTimeException e) {
            log.debug("Unable to find timezone {}. Assuming no server timezone.", applicationProperty);
            return Optional.empty();
        }
    }

    @Nonnull
    @Override
    public File getRepositoriesDir() {
        return settings.getRepositoriesDir();
    }

    @Nonnull
    @Override
    public File getRepositoryDir(@Nonnull Repository repository) {
        checkNotNull(repository, "repository");
        File root = getRepositoriesDir();
        String name = String.valueOf(repository.getId());
        return new File(root, name);
    }

    @Override
    public String getServerEmailAddress() {
        return getApplicationProperty(EMAIL_ADDRESS);
    }

    @Override
    public String getServerId() {
        return getApplicationProperty(SERVER_ID);
    }

    @Nonnull
    @Override
    public File getTempDir() {
        return settings.getTempDir();
    }

    @Override
    public boolean isAllowPublicSignUp() {
        return Boolean.valueOf(getApplicationProperty(ALLOW_PUBLIC_SIGNUP));
    }

    @Override
    public boolean isDebugLoggingEnabled() {
        String property = getApplicationPropertyOrFallback(DEBUG_LOGGING_ENABLED, "logging.debug.enabled");
        // default to FALSE if the setting is not defined
        return property != null && Boolean.valueOf(property);
    }

    @Override
    public boolean isDisasterRecovery() {
        return getProperty(DISASTER_RECOVERY, false);
    }

    @Override
    public boolean isProfilingEnabled() {
        String property = getApplicationPropertyOrFallback(PROFILING_ENABLED, "logging.profiling.enabled");
        // default to FALSE if the setting is not defined
        return property != null && Boolean.valueOf(property);
    }

    @Override
    public boolean isHttpScmHostingEnabled() {
        String property = getApplicationProperty(HTTP_SCM_HOSTING_ENABLED);
        // default to TRUE if the setting is not defined
        return property == null || Boolean.valueOf(property);
    }

    @Override
    public boolean isJmxEnabled() {
        return getProperty("jmx.enabled", false);
    }

    /**
     * Unlike {@link #isBootstrapped()} this value is cached.
     * This value does not change at runtime once it has been set to 'true'.
     * Caching it avoids unnecessary Hazelcast queries.
     */
    @Override
    public boolean isSetup() {
        if (!setup) {
            setup = Boolean.valueOf(getApplicationProperty(SETUP_COMPLETED));
        }
        return setup;
    }

    /**
     * Unlike {@link #isSetup()} this value is never cached
     */
    @Override
    public boolean isBootstrapped() {
        return propertyDao.isSetup();
    }

    @Override
    public boolean isShowCaptchaOnSignUp() {
        return Boolean.valueOf(getApplicationProperty(REQUIRE_CAPTCHA_ON_SIGNUP));
    }

    @Override
    @PreAuthorize("hasGlobalPermission('ADMIN')")
    @Transactional
    public void setAllowPublicSignUp(boolean publicSignUp) {
        updateApplicationProperty(ALLOW_PUBLIC_SIGNUP, Boolean.toString(publicSignUp));
    }

    @Override
    @Transactional
    public void setMode(@Nonnull ApplicationMode mode) {
        updateApplicationProperty(MODE, checkNotNull(mode, "mode").getId());
    }

    @Override
    @PreAuthorize("hasGlobalPermission('ADMIN')")
    @Transactional
    public void setAvatarSource(String avatarSource) {
        updateApplicationProperty(AVATAR_SOURCE, avatarSource);
    }

    @Override
    @PreAuthorize("hasGlobalPermission('SYS_ADMIN')")
    @Transactional
    public void setBaseURL(@Nonnull URI baseUri) {
        checkNotNull(baseUri, "baseUri");
        String baseUrl = StringUtils.removeEnd(baseUri.toString(), "/"); // ensure the URL is consistently stored without the '/' directory suffix
        String oldValue = updateApplicationProperty(BASE_URL, baseUrl);
        if (ObjectUtils.notEqual(baseUrl, oldValue)) {
            eventPublisher.publish(new BaseUrlChangedEvent(this, oldValue, baseUrl));
        }
    }

    @Override
    @PreAuthorize("hasGlobalPermission('SYS_ADMIN')")
    @Transactional
    public void setDebugLoggingEnabled(boolean debugLoggingEnabled) {
        updateApplicationProperty(DEBUG_LOGGING_ENABLED, Boolean.toString(debugLoggingEnabled));
    }

    @Override
    @Transactional
    public void setGracePeriodStartTime(@Nullable Long time) {
        deleteOrUpdate(LICENSE_EXCEEDED_GRACE_PERIOD_START, time);
    }

    @Override
    @PreAuthorize("hasGlobalPermission('ADMIN')")
    @Transactional
    public void setDisplayName(@Nonnull String displayName) {
        CustomPreconditions.checkRequiredString(displayName, 255);
        String oldValue = updateApplicationProperty(NAME, displayName);
        if (ObjectUtils.notEqual(displayName, oldValue)) {
            eventPublisher.publish(new DisplayNameChangedEvent(this, oldValue, displayName));
        }
    }

    @Override
    @Transactional
    public void setLastSharedHomeDir(@Nonnull String lastSharedHomeDir) {
        checkArgument(!checkNotNull(lastSharedHomeDir, "lastSharedHomeDir").trim().isEmpty(), "A non-blank path is required");

        updateApplicationProperty(LAST_HOME, lastSharedHomeDir);
    }

    @Override
    @PreAuthorize("hasGlobalPermission('SYS_ADMIN')")
    @Transactional
    public void setLocale(Locale locale) {
        deleteOrUpdate(LOCALE, locale != null ? locale.toString() : null);
    }

    @Override
    @PreAuthorize("hasGlobalPermission('SYS_ADMIN')")
    @Transactional
    public void setMailHostConfiguration(@Nonnull MailHostConfiguration mailHostConfiguration) {
        deleteOrUpdateMailHostConfiguration(checkNotNull(mailHostConfiguration, "mailHostConfiguration"));
    }

    @Override
    @PreAuthorize("hasGlobalPermission('ADMIN')")
    @Transactional
    public void setMaxCaptchaAttempts(int maxCaptchaAttempts) {
        updateApplicationProperty(MAX_CAPTCHA_ATTEMPTS, Integer.toString(maxCaptchaAttempts));
    }

    @Override
    @PreAuthorize("hasGlobalPermission('SYS_ADMIN')")
    @Transactional
    public void setProfilingEnabled(boolean profilingEnabled) {
        updateApplicationProperty(PROFILING_ENABLED, Boolean.toString(profilingEnabled));
    }

    @Override
    @PreAuthorize("hasGlobalPermission('SYS_ADMIN')") // should be consistent with the permission level to enable SSH
    @Transactional
    public void setHttpScmHostingEnabled(boolean enabled) {
        // SCM hosting over HTTP(S) is enabled by default when the property is not set;
        // hence remove it instead of setting it 'true' as its absence is equivalent
        String oldValue = deleteOrUpdate(HTTP_SCM_HOSTING_ENABLED, enabled ? null : "false");

        boolean wasEnabled = oldValue == null || Boolean.valueOf(oldValue); // must be consistent with isHttpScmHostingEnabled()
        if (wasEnabled != enabled) {
            eventPublisher.publish(new HttpScmHostingChangedEvent(this, wasEnabled, enabled));
        }
    }

    @Override
    @PreAuthorize("hasGlobalPermission('ADMIN')")
    @Transactional
    public void setServerEmailAddress(String emailAddress) {
        checkArgument(new EmailValidator().isValid(emailAddress, null), "email address must be valid");
        String oldValue = deleteOrUpdate(EMAIL_ADDRESS, emailAddress);
        if (ObjectUtils.notEqual(emailAddress, oldValue)) {
            eventPublisher.publish(new ServerEmailAddressChangedEvent(this, oldValue, emailAddress));
        }
    }

    @Override
    @PreAuthorize("hasGlobalPermission('SYS_ADMIN')")
    @Transactional
    public void setServerId(String serverId) {
        updateApplicationProperty(SERVER_ID, serverId);
    }

    @Override
    @PreAuthorize("hasGlobalPermission('SYS_ADMIN')")
    @Transactional
    public void setServerTimeZone(@Nullable ZoneId zone) {
        String oldValue = deleteOrUpdate(SERVER_TIMEZONE, zone);

        if (ObjectUtils.notEqual(zone, oldValue)) {
            eventPublisher.publish(new ServerTimeZoneChangedEvent(this, oldValue, zone == null ? null : zone.getId()));
        }
    }

    @Override
    @PreAuthorize("hasGlobalPermission('SYS_ADMIN')")
    @Transactional
    public void setSetup(boolean setup) {
        boolean oldValue = isSetup();
        if (oldValue != setup) {
            updateApplicationProperty(SETUP_COMPLETED, Boolean.toString(setup));

            eventPublisher.publish(new ApplicationSetupEvent(this, oldValue, setup));
        }
    }

    @Override
    @PreAuthorize("hasGlobalPermission('ADMIN')")
    @Transactional
    public void setShowCaptchaOnSignUp(boolean captchaOnSignUp) {
        updateApplicationProperty(REQUIRE_CAPTCHA_ON_SIGNUP, Boolean.toString(captchaOnSignUp));
    }

    /**
     * Retrieves or creates the cache using the atlassian-cache API
     * @param cacheManager the cache manager
     * @return the cache
     */
    private Cache<ApplicationProperty.Key, ValueWrapper<String>> createCache(CacheManager cacheManager) {
        CacheLoader<ApplicationProperty.Key, ValueWrapper<String>> cacheLoader =
                new CacheLoader<ApplicationProperty.Key, ValueWrapper<String>>() {

                    @Nonnull
                    @Override
                    public ValueWrapper<String> load(@Nonnull ApplicationProperty.Key key) {
                        return wrap(rawGetApplicationProperty(key));
                    }
                };
        // the number of entries is bounded by design. No need to limit it. The cache max size must be greater than 271
        // to ensure that it doesn't get constantly invalidated.
        // (Hazelcast quirk - see https://github.com/hazelcast/hazelcast/issues/2515)
        CacheSettings cacheSettings = new CacheSettingsBuilder()
                .maxEntries(Integer.MAX_VALUE)
                .build();

        // Note: The cache name is not prefixed because Atlassian Cache already prefixes the value (a lot)
        return cacheManager.getCache(ApplicationPropertiesService.class.getName(), cacheLoader, cacheSettings);
    }

    /**
     * Delete the properties indicated by the supplied keys
     * @param keys the keys to delete
     */
    private void delete(ApplicationProperty.Key... keys) {
        for (ApplicationProperty.Key key : keys) {
            deleteOrUpdate(key, null);
        }
    }

    /**
     * Update or delete a property (if the {@code newValue} is null)
     * @param key key key to update or delete
     * @param newValue newValue (if null, the property will be deleted)
     * @return the old value before the update
     **/
    private String deleteOrUpdate(ApplicationProperty.Key key, Object newValue) {
        String oldValue = unwrap(cache.get(key));
        String value = Objects.toString(newValue, null);
        if (newValue == null) {
            //ApplicationProperty.value is NONNULL, so the only way oldValue could be null is if the property
            //does not exist. If it doesn't exist, skip the deleteById call
            if (oldValue != null) {
                propertyDao.deleteById(key);
            }
        } else {
            ApplicationProperty newProperty = new ApplicationProperty(key, value);
            if (oldValue == null) {
                propertyDao.create(newProperty);
            } else {
                propertyDao.update(newProperty);
            }
        }

        // write the item to the cache when and if the transaction commits
        transactionSynchronizer.register(new TransactionSynchronizationAdapter() {
            @Override
            public void afterCommit() {
                if (!cache.replace(key, wrap(oldValue), wrap(value))) {
                    // cache has been updated since the database was updated. Invalidate the cache entry to force
                    // a reload
                    cache.remove(key);
                }
            }
        });

        return oldValue;
    }

    /**
     * In order to prevent programming errors, the update and delete logic is separated into
     * two methods. Internally we use the one to consolidate the code in one place.
     * @param mailHostConfiguration is {@code  null} delete the configuration. Update them otherwise
     */
    private void deleteOrUpdateMailHostConfiguration(MailHostConfiguration mailHostConfiguration) {
        MailHostConfiguration oldValue = getMailHostConfiguration();

        if (mailHostConfiguration == null) {
            delete(MAIL_HOST, MAIL_HOST_PORT, MAIL_HOST_USERNAME, MAIL_HOST_PASSWORD, MAIL_PROTOCOL, MAIL_USE_TLS, MAIL_REQUIRE_TLS);
        } else {
            ValidationUtils.validate(validator, mailHostConfiguration);

            deleteOrUpdate(MAIL_HOST, mailHostConfiguration.getHostname());
            deleteOrUpdate(MAIL_HOST_PORT, mailHostConfiguration.getPort());
            deleteOrUpdate(MAIL_HOST_USERNAME, mailHostConfiguration.getUsername());
            deleteOrUpdate(MAIL_HOST_PASSWORD, mailHostConfiguration.getPassword());
            deleteOrUpdate(MAIL_PROTOCOL, mailHostConfiguration.getProtocol().name());
            deleteOrUpdate(MAIL_USE_TLS, mailHostConfiguration.isUseStartTls());
            deleteOrUpdate(MAIL_REQUIRE_TLS, mailHostConfiguration.isRequireStartTls());
        }

        if (ObjectUtils.notEqual(mailHostConfiguration, oldValue)) {
            eventPublisher.publish(new MailHostConfigurationChangedEvent(this, oldValue, mailHostConfiguration));
        }
    }

    private String getApplicationProperty(ApplicationProperty.Key key) {
        try {
            return unwrap(cache.get(key));
        } catch (CacheException e) {
            throw Throwables.propagate(e.getCause());
        }
    }

    private String getApplicationPropertyOrFallback(ApplicationProperty.Key key, String propertyName) {
        String property = getApplicationProperty(key);
        return property == null ? getProperty(propertyName) : property;
    }

    private String rawGetApplicationProperty(ApplicationProperty.Key key) {
        // The transaction is only created on cache miss. Changing this behavior could cause the application to hung
        // on backups when the Database is latched
        ApplicationProperty property = readTx.execute(status -> propertyDao.getById(key));

        return property == null ? null : property.getValue();
    }

    private String unwrap(ValueWrapper<String> wrapper) {
        return wrapper == null ? null : wrapper.get();
    }

    /**
     * Update an application property.
     *
     * @param key the ApplicationProperty key to update
     * @param newValue the new value
     * @return the old value, if any
     */
    private String updateApplicationProperty(ApplicationProperty.Key key, @Nonnull String newValue) {
        checkNotNull(newValue, "newValue");
        return deleteOrUpdate(key, newValue);
    }

    /**
     * Checks that the given property name complies with the constraints as described in the JavaDoc for the {@link
     * ApplicationPropertiesServiceImpl#getPluginProperty(String)} method.
     * <p>
     * If this method completes without throwing an exception, then the property name is valid.
     *
     * @param propertyName the property name to be validated
     * @throws IllegalArgumentException if the property name is invalid
     * @throws NullPointerException if the property name is null
     */
    private void validatePluginPropertyName(String propertyName) {
        checkNotNull(propertyName, "Property name must not be null");
        checkArgument(propertyName.startsWith("plugin."),
                "Property name \"" + propertyName + "\" does not start with \"plugin.\"");
    }

    private ValueWrapper<String> wrap(String value) {
        return new ValueWrapper<>(value);
    }
}
