package com.atlassian.stash.internal.server;

import com.atlassian.bitbucket.event.server.ApplicationConfigurationChangedEvent;
import com.atlassian.bitbucket.event.server.ApplicationConfigurationChangedEvent.Property;
import com.atlassian.bitbucket.hamcrest.OptionalMatcher;
import com.atlassian.bitbucket.mail.MailHostConfiguration;
import com.atlassian.bitbucket.server.ApplicationMode;
import com.atlassian.bitbucket.user.ApplicationUser;
import com.atlassian.cache.CacheManager;
import com.atlassian.cache.memory.MemoryCacheManager;
import com.atlassian.event.api.EventPublisher;
import com.atlassian.stash.internal.ApplicationConstants;
import com.atlassian.stash.internal.ApplicationSettings;
import com.atlassian.stash.internal.BuildInfo;
import com.atlassian.stash.internal.home.HomeDirectoryDetails;
import com.atlassian.stash.internal.jdbc.DataSourceConfiguration;
import com.atlassian.stash.internal.spring.TransactionSynchronizer;
import org.hamcrest.BaseMatcher;
import org.hamcrest.Description;
import org.junit.After;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.ExpectedException;
import org.junit.rules.TemporaryFolder;
import org.junit.runner.RunWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InOrder;
import org.mockito.Mock;
import org.mockito.internal.matchers.Equality;
import org.mockito.runners.MockitoJUnitRunner;
import org.springframework.orm.hibernate4.HibernateJdbcException;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.support.TransactionSynchronization;

import javax.validation.Validator;
import java.lang.reflect.Method;
import java.net.URI;
import java.sql.Driver;
import java.sql.DriverManager;
import java.time.ZoneId;
import java.util.Collections;
import java.util.Properties;
import java.util.TimeZone;

import static com.atlassian.bitbucket.mail.MailProtocol.SMTP;
import static com.atlassian.stash.internal.server.ApplicationProperty.Key.*;
import static java.util.Optional.empty;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.notNullValue;
import static org.junit.Assert.*;
import static org.mockito.Mockito.*;

@RunWith(MockitoJUnitRunner.class)
@SuppressWarnings("ConstantConditions")
public class ApplicationPropertiesServiceImplTest {

    private static final String DEFAULT_VALUE = "other";
    private static final int INT_VALUE = 999;
    private static final long LONG_VALUE = 999L;
    private static final String NON_EXISTING_PROPERTY = "plugin.non-existing-property";
    private static final String TEST_BAD_BOOL_PROPERTY1 = "plugin.testBadBoolProp1";
    private static final String TEST_BAD_BOOL_PROPERTY2 = "plugin.testBadBoolProp2";
    private static final String TEST_BAD_BOOL_PROPERTY3 = "plugin.testBadBoolProp3";
    private static final String TEST_BAD_INT_PROPERTY = "plugin.testBadIntProp";
    private static final String TEST_BAD_LONG_PROPERTY = "plugin.testBadLongProp";
    private static final String TEST_BOOL_PROPERTY1 = "plugin.testBoolProp1";
    private static final String TEST_BOOL_PROPERTY2 = "plugin.testBoolProp2";
    private static final String TEST_INT_PROPERTY = "plugin.testIntProp";
    private static final String TEST_LONG_PROPERTY = "plugin.testLongProp";
    private static final String TEST_PROPERTY1 = "plugin.test.property";
    private static final String TEST_PROPERTY2 = "plugin.testProperty";
    private static final String VALUE1 = "foo1";
    private static final String VALUE2 = "foo2";

    private final CacheManager cacheManager = new MemoryCacheManager();

    @Rule
    public TemporaryFolder folder = new TemporaryFolder();
    @Rule
    public ExpectedException thrown = ExpectedException.none();

    private BuildInfo buildInfo;
    @Mock
    private ApplicationPropertyDao dao;
    @Mock
    private DataSourceConfiguration dataSourceConfiguration;
    @Mock
    private Driver driver;
    @Mock
    private EventPublisher eventPublisher;
    private ApplicationPropertiesServiceImpl service;
    private ApplicationSettings settings;
    @Mock
    private PlatformTransactionManager transactionManager;
    @Mock
    private TransactionStatus transactionStatus;
    @Mock
    private TransactionSynchronizer transactionSynchronizer;
    @Mock
    private ApplicationUser user;
    @Mock
    private Validator validator;

    @Before
    public void setUp() throws Exception {
        HomeDirectoryDetails details = new HomeDirectoryDetails(folder.getRoot(), folder.getRoot());

        settings = new ApplicationSettings(details, null, null, "1");
        buildInfo = new BuildInfo(new Properties() { {
            put(BuildInfo.BUILD_TIMESTAMP, "Mon Jan 9");
            put(BuildInfo.BUILD_TIMESTAMP_FORMAT, "EEE MMM d");
            put(BuildInfo.BUILD_VERSION, "2");
            put(BuildInfo.COMMIT_HASH, "0123456789abcdef0123456789abcdef01234567");
        } });

        when(driver.getMajorVersion()).thenReturn(10);
        when(driver.getMinorVersion()).thenReturn(2);

        when(dataSourceConfiguration.getDriverClassName()).thenReturn(driver.getClass().getName());
        DriverManager.registerDriver(driver);

        when(transactionManager.getTransaction(any(TransactionDefinition.class))).thenReturn(transactionStatus);
        when(user.getName()).thenReturn("cav-user");
        when(validator.validate(any(Object.class))).thenReturn(Collections.emptySet());

        Properties properties = new Properties() {
            {
                setProperty(TEST_PROPERTY1, VALUE1);
                setProperty(TEST_PROPERTY2, VALUE2);
                setProperty(TEST_LONG_PROPERTY, Long.toString(LONG_VALUE));
                setProperty(TEST_BAD_LONG_PROPERTY, "notalong");
                setProperty(TEST_INT_PROPERTY, Integer.toString(INT_VALUE));
                setProperty(TEST_BAD_INT_PROPERTY, "notanint");
                setProperty(TEST_BOOL_PROPERTY1, Boolean.toString(true));
                setProperty(TEST_BOOL_PROPERTY2, "TRUE");
                setProperty(TEST_BAD_BOOL_PROPERTY1, "notabool");
                setProperty(TEST_BAD_BOOL_PROPERTY2, "yes");
                setProperty(TEST_BAD_BOOL_PROPERTY3, "TRU");
            }
        };

        service = newService(properties);
    }

    @After
    public void tearDown() throws Exception {
        DriverManager.deregisterDriver(driver);
    }

    @Test
    public void testBuildProperties() {
        assertEquals(buildInfo.getBuildNumber(), service.getBuildNumber());
        assertEquals(buildInfo.getBuildTimestamp(), service.getBuildTimestamp());
        assertEquals(buildInfo.getBuildVersion(), service.getBuildVersion());
        assertEquals(buildInfo.getCommitHash(), service.getCommitHash());
    }

    //This test ensures exceptions that happen while trying to load cache entries are propagated correctly,
    //rather than being masked in some wrapper
    @Test(expected = HibernateJdbcException.class)
    public void testCacheExceptionHandling() {
        doThrow(HibernateJdbcException.class).when(dao).getById(same(AVATAR_SOURCE));

        service.getAvatarSource();
    }

    @Test
    public void testCaching() {
        expectGetProperty(NAME, "displayName");

        assertEquals("displayName", service.getDisplayName());
        assertEquals("displayName", service.getDisplayName());
        assertEquals("displayName", service.getDisplayName());
        assertEquals("displayName", service.getDisplayName());
        assertEquals("displayName", service.getDisplayName());
        service.setDisplayName("newDisplayName");
        assertEquals("displayName", service.getDisplayName());
        assertEquals("displayName", service.getDisplayName());
        assertEquals("displayName", service.getDisplayName());
        assertEquals("displayName", service.getDisplayName());
        assertEquals("displayName", service.getDisplayName());

        InOrder ordered = inOrder(dao, transactionManager, transactionSynchronizer);
        ordered.verify(transactionManager).getTransaction(isNotNull(TransactionDefinition.class)); //Should use a tx
        ordered.verify(dao).getById(same(NAME)); //Should only hit the DAO once for the first 5 gets and loading old
        ordered.verify(transactionManager).commit(same(transactionStatus)); //Even read-only; commit calls rollback
        ordered.verify(dao).update(eq(new ApplicationProperty(NAME, "newDisplayName"))); //Transaction by annotation
        ArgumentCaptor<TransactionSynchronization> synchronizationCaptor =
                ArgumentCaptor.forClass(TransactionSynchronization.class);
        ordered.verify(transactionSynchronizer).register(synchronizationCaptor.capture());

        // simulate the tx commit. at this point, the cache should be updated
        synchronizationCaptor.getValue().afterCommit();
        assertEquals("newDisplayName", service.getDisplayName());
        assertEquals("newDisplayName", service.getDisplayName());
        assertEquals("newDisplayName", service.getDisplayName());

        ordered.verifyNoMoreInteractions();
    }

    @Test
    public void testDeleteMailHostConfigurationWhenNotConfiguredDoesNotRaiseEvent() {
        service.deleteMailHostConfiguration();

        verify(eventPublisher, never()).publish(anyObject());
    }

    @Test
    public void testDirectories() {
        assertEquals(settings.getConfigDir(), service.getConfigDir());
        assertEquals(settings.getHomeDir(), service.getHomeDir());
        assertEquals(settings.getRepositoriesDir(), service.getRepositoriesDir());
    }

    @Test
    public void testJdbcProperties() {
        when(dataSourceConfiguration.getDriverClassName()).thenReturn("driver");
        when(dataSourceConfiguration.getUrl()).thenReturn("url");

        assertEquals("driver", service.getJdbcDriver());
        assertEquals("url", service.getJdbcUrl());
    }

    @Test
    public void testServerProperties() {
        expectGetProperty(NAME, "test");
        assertEquals("test", service.getDisplayName());

        expectGetProperty(BASE_URL, "http://test/base/url");
        assertEquals("http://test/base/url", service.getBaseUrl().toString());

        expectGetProperty(EMAIL_ADDRESS, "test@stash.email");
        assertEquals("test@stash.email", service.getServerEmailAddress());
    }

    @Test
    public void testIsAllowPublicSignup() {
        expectGetProperty(ALLOW_PUBLIC_SIGNUP, "true");
        assertTrue(service.isAllowPublicSignUp());
    }

    @Test
    public void testIsAllowSignupWithNonBooleanValue() {
        expectGetProperty(ALLOW_PUBLIC_SIGNUP, "invalid");
        assertFalse(service.isAllowPublicSignUp());
    }

    @Test
    public void testIsHttpScmHostingEnabledByDefault() {
        assertTrue("SCM access over HTTP(s) should be enabled by default", service.isHttpScmHostingEnabled());
    }

    @Test
    public void testIsHttpScmHostingDisabledWhenMarkedAsDisabled() {
        expectGetProperty(HTTP_SCM_HOSTING_ENABLED, "false");
        assertFalse("SCM access over HTTP(s) should be disabled", service.isHttpScmHostingEnabled());
    }

    @Test
    public void testIsHttpScmHostingEnabledWhenMarkedAsEnabled() {
        expectGetProperty(HTTP_SCM_HOSTING_ENABLED, "true");
        assertTrue("SCM access over HTTP(s) should be enabled", service.isHttpScmHostingEnabled());
    }

    @Test
    public void testIsShowCaptchaOnSignup() {
        expectGetProperty(REQUIRE_CAPTCHA_ON_SIGNUP, "true");
        assertTrue(service.isShowCaptchaOnSignUp());
    }

    @Test
    public void testIsShowCaptchaOnSignupWithNonBooleanValue() {
        expectGetProperty(REQUIRE_CAPTCHA_ON_SIGNUP, "invalid");
        assertFalse(service.isShowCaptchaOnSignUp());
    }

    @Test
    public void testGetMaxCaptchaAttempts() {
        expectGetProperty(MAX_CAPTCHA_ATTEMPTS, "42");
        assertEquals(42, service.getMaxCaptchaAttempts());
    }

    @Test
    public void testGetMaxCaptchaAttemptsWithLong() {
        expectGetProperty(MAX_CAPTCHA_ATTEMPTS, String.valueOf(Long.MAX_VALUE));
        assertEquals(ApplicationPropertiesDefaults.DEFAULT_MAX_CAPTCHA_ATTEMPTS, service.getMaxCaptchaAttempts());
    }

    @Test
    public void testSetGracePeriodStart() {
        service.setGracePeriodStartTime(123L);
        verifyCreated(LICENSE_EXCEEDED_GRACE_PERIOD_START, "123");
        verifyZeroInteractions(eventPublisher);
    }

    @Test
    public void testSetGracePeriodStartDelete() {
        expectGetProperty(LICENSE_EXCEEDED_GRACE_PERIOD_START, "123");
        service.setGracePeriodStartTime(null);
        verifyDeleted(LICENSE_EXCEEDED_GRACE_PERIOD_START);
        verifyZeroInteractions(eventPublisher);
    }

    @Test
    public void testSetHttpScmHostingEnabledIsNoOpWhenNotAlreadySet() {
        // no value exists so it should be a no-op
        service.setHttpScmHostingEnabled(true);
        verify(dao, never()).create(any(ApplicationProperty.class));
        verify(dao, never()).update(any(ApplicationProperty.class));
        verifyNoEventPublished(Property.HTTP_SCM_HOSTING);
    }

    @Test
    public void testSetHttpScmHostingEnabledWhenDisabling() {
        service.setHttpScmHostingEnabled(false);
        verifyCreated(HTTP_SCM_HOSTING_ENABLED, "false");
        verifyEventPublished(Property.HTTP_SCM_HOSTING, true, false);
    }

    @Test
    public void testSetHttpScmHostingEnabledWhenRenabling() {
        expectGetProperty(HTTP_SCM_HOSTING_ENABLED, "false");
        service.setHttpScmHostingEnabled(true);
        verifyDeleted(HTTP_SCM_HOSTING_ENABLED);
        verifyEventPublished(Property.HTTP_SCM_HOSTING, false, true);
    }

    private ApplicationProperty expectGetProperty(ApplicationProperty.Key key, String value) {
        ApplicationProperty property = new ApplicationProperty(key, value);
        when(dao.getById(key)).thenReturn(property);

        return property;
    }

    private void verifyUpdatedAndEventPublished(ApplicationProperty.Key key, Property property, String oldValue, String newValue) {
        if (oldValue == null) {
            verifyCreated(key, newValue);
        } else {
            verifyUpdated(key, newValue);
        }

        verifyEventPublished(property, oldValue, newValue);
    }

    private void verifyEventPublished(Property property, Object oldValue, Object newValue) {
        verify(eventPublisher).publish(booleanThat(new BaseMatcher<Boolean>() {
            @Override
            public boolean matches(Object o) {
                if (o instanceof ApplicationConfigurationChangedEvent) {
                    ApplicationConfigurationChangedEvent evt = (ApplicationConfigurationChangedEvent) o;
                    return evt.getProperty().equals(property) &&
                            Equality.areEqual(evt.getOldValue(), oldValue) &&
                            Equality.areEqual(evt.getNewValue(), newValue);
                }
                return false;
            }

            @Override
            public void describeTo(Description description) {
                description.appendText("event does not match");
            }
        }));
    }

    private void verifyNoEventPublished(Property property) {
        verify(eventPublisher, never()).publish(booleanThat(new BaseMatcher<Boolean>() {
            @Override
            public boolean matches(Object o) {
                if (o instanceof ApplicationConfigurationChangedEvent) {
                    ApplicationConfigurationChangedEvent event = (ApplicationConfigurationChangedEvent) o;
                    return event.getProperty().equals(property);
                }
                return false;
            }

            @Override
            public void describeTo(Description description) {
                description.appendText("event does not match");
            }
        }));
    }

    private void verifyUpdated(ApplicationProperty.Key key, String newValue) {
        verify(dao).update(new ApplicationProperty(key, newValue));
    }

    private void verifyCreated(ApplicationProperty.Key key, String newValue) {
        verify(dao).create(new ApplicationProperty(key, newValue));
    }

    private void verifyDeleted(ApplicationProperty.Key key) {
        verify(dao).deleteById(same(key));
    }

    @Test
    public void testUpdateServerProperties() {
        service.setDisplayName("testname");
        verifyUpdatedAndEventPublished(NAME, Property.DISPLAY_NAME, null, "testname");

        service.setBaseURL(URI.create("http://test.example.com"));
        verifyUpdatedAndEventPublished(BASE_URL, Property.BASE_URL, null, "http://test.example.com");

        service.setServerEmailAddress("email@stash.test");
        verifyUpdatedAndEventPublished(EMAIL_ADDRESS, Property.SERVER_EMAIL_ADDRESS, null, "email@stash.test");
    }

    @Test
    public void testSetBaseUrlToIdenticalDoesNotRaiseEvent() {
        expectGetProperty(BASE_URL, "http://base/url");

        service.setBaseURL(URI.create("http://base/url"));
        verify(eventPublisher, never()).publish(anyObject());
    }

    @Test
    public void testSetDisplayNameToIdenticalDoesNotRaiseEvent() {
        expectGetProperty(NAME, "Awesome Bitbucket");

        service.setDisplayName("Awesome Bitbucket");
        verify(eventPublisher, never()).publish(anyObject());
    }

    @Test
    public void testSetServerEmailToIdenticalDoesNotRaiseEvent() {
        expectGetProperty(EMAIL_ADDRESS, "server@awesome.com");

        service.setServerEmailAddress("server@awesome.com");
        verify(eventPublisher, never()).publish(anyObject());
    }

    @Test(expected = IllegalArgumentException.class)
    public void testClearDisplayName() {
        service.setDisplayName(null);
    }

    @Test(expected = IllegalArgumentException.class)
    public void setUpdateDisplayNameWithEmptyValue() {
        service.setDisplayName("");
    }

    @Test(expected = IllegalArgumentException.class)
    public void setUpdateServerEmailWithInvalidEmail() {
        service.setServerEmailAddress("#");
    }

    @Test(expected = NullPointerException.class)
    public void testClearBaseURL() {
        service.setBaseURL(null);
    }

    @Test
    public void testClearServerEmailAddress() {
        service.setServerEmailAddress(null);

        assertNull(service.getServerEmailAddress());
    }

    @Test
    public void testGetDefaultTimeZoneReturnsJvmTzWhenNoDefaultSet() {
        whenNoServerTzSet();

        TimeZone defaultTimeZone = service.getDefaultTimeZone();

        assertThat(defaultTimeZone, equalTo(TimeZone.getDefault()));
    }

    @Test
    public void testGetDefaultTimeZoneReturnsJvmTzWhenUnknownDefaultSet() {
        whenServerTzSetTo("anInvalidTz");

        TimeZone defaultTimeZone = service.getDefaultTimeZone();

        assertThat(defaultTimeZone, equalTo(TimeZone.getDefault()));
    }

    @Test
    public void testGetDefaultTimeZoneReturnsServerTzWhenSet() {
        ZoneId tz = cairoTz();

        whenServerTzSetTo(tz);

        TimeZone defaultTimeZone = service.getDefaultTimeZone();

        assertThat(defaultTimeZone, equalTo(TimeZone.getTimeZone(tz)));
    }

    @Test
    public void testGetGracePeriodStartNotSet() {
        service = newService(new Properties());
        assertEquals(empty(), service.getGracePeriodStartTime());
    }

    @Test
    public void testGetGracePeriodStart() {
        service = newService(new Properties());
        expectGetProperty(LICENSE_EXCEEDED_GRACE_PERIOD_START, "123");

        assertEquals((Long) 123L, service.getGracePeriodStartTime().get());
    }

    @Test
    public void testGetModeReadsThePropertyValue() {
        service = newService(new Properties() { { put(ApplicationConstants.PROP_APPLICATION_MODE, "mirror"); } });
        assertEquals(ApplicationMode.MIRROR, service.getMode());
    }

    @Test
    public void testGetModeDefaultsThenToTheDefaultValue() {
        service = newService(new Properties());
        assertEquals(ApplicationMode.DEFAULT, service.getMode());
    }

    @Test
    public void testGetServerTimeZoneReturnsEmptyWhenNotSet() {
        service = newService(new Properties());

        whenNoServerTzSet();

        assertThat(service.getServerTimeZone(), OptionalMatcher.empty());
    }

    @Test
    public void testGetServerTimeZoneReturnsEmptyWhenSetZoneIsInvalid() {
        service = newService(new Properties());

        whenServerTzSetTo("invalidTimeZoneId");

        assertThat(service.getServerTimeZone(), OptionalMatcher.empty());
    }

    @Test
    public void testGetServerTimeZoneReturnServerTimeZoneWhenSet() {
        service = newService(new Properties());
        ZoneId zoneId = cairoTz();

        whenServerTzSetTo(zoneId);

        assertThat(service.getServerTimeZone(), OptionalMatcher.some(zoneId));
    }

    @Test
    public void testMailSettings() {
        expectGetProperty(MAIL_HOST, "mail.host.name");
        expectGetProperty(MAIL_HOST_PORT, "1234");
        expectGetProperty(MAIL_HOST_USERNAME, "mailuser");
        expectGetProperty(MAIL_HOST_PASSWORD, "mailpwd");
        expectGetProperty(MAIL_PROTOCOL, SMTP.name());
        expectGetProperty(MAIL_USE_TLS, "true");
        expectGetProperty(MAIL_REQUIRE_TLS, "false");

        MailHostConfiguration config = service.getMailHostConfiguration();

        assertNotNull(config);
        assertEquals("mail.host.name", config.getHostname());
        assertEquals(Integer.valueOf(1234), config.getPort());
        assertEquals("mailuser", config.getUsername());
        assertEquals("mailpwd", config.getPassword());
        assertEquals(SMTP, config.getProtocol());
        assertTrue(config.isUseStartTls());
        assertFalse(config.isRequireStartTls());
    }

    @Test
    public void testMailHostConfigurationUndefined() {
        MailHostConfiguration config = service.getMailHostConfiguration();

        assertNull(config);
    }

    @Test
    public void testMinimalMailHostConfiguration() {
        expectGetProperty(MAIL_HOST, "mail.host.name");

        MailHostConfiguration config = service.getMailHostConfiguration();

        assertNotNull(config);
        assertEquals("mail.host.name", config.getHostname());
        assertNull(config.getPort());
        assertNull(config.getUsername());
        assertNull(config.getPassword());
    }

    /**
     * Tests that the service does not blow up when an illegal value is defined in the database, but instead disregards it
     */
    @Test
    public void testInvalidMailHostPort() {
        expectGetProperty(MAIL_HOST, "mail.host.name");
        expectGetProperty(MAIL_HOST_PORT, "Not a  number");

        MailHostConfiguration config = service.getMailHostConfiguration();

        assertNotNull(config);
        assertEquals("mail.host.name", config.getHostname());
        assertNull(config.getPort());
    }

    @Test
    public void testSetMailHostConfiguration() {
        MailHostConfiguration config = new MailHostConfiguration.Builder().hostname("mail.host.name").port(2234)
                .username("username").password("password").protocol(SMTP).useStartTls(true).build();
        service.setMailHostConfiguration(config);

        verifyCreated(MAIL_HOST, "mail.host.name");
        verifyCreated(MAIL_HOST_PORT, "2234");
        verifyCreated(MAIL_HOST_USERNAME, "username");
        verifyCreated(MAIL_HOST_PASSWORD, "password");
        verifyCreated(MAIL_PROTOCOL, SMTP.name());
        verifyCreated(MAIL_USE_TLS, "true");
        verifyCreated(MAIL_REQUIRE_TLS, "false");

        verifyEventPublished(Property.MAIL_HOST_CONFIGURATION, null, config);

        ArgumentCaptor<TransactionSynchronization> synchronizationCaptor =
                ArgumentCaptor.forClass(TransactionSynchronization.class);
        verify(transactionSynchronizer, times(7)).register(synchronizationCaptor.capture());

        // simulate the tx commit. at this point, the cache should be updated
        for (TransactionSynchronization tx : synchronizationCaptor.getAllValues()) {
            tx.afterCommit();
        }

        MailHostConfiguration retrieved = service.getMailHostConfiguration();
        assertEquals(config, retrieved);
    }

    @Test
    public void testSetMailHostConfigurationPartiallyEmpty() {
        // simulate pre-existing config
        MailHostConfiguration oldConfig = new MailHostConfiguration.Builder().hostname("mail.host.name").port(1234)
                .username("mailuser").password("mailpwd").protocol(SMTP).build();

        expectGetProperty(MAIL_HOST, oldConfig.getHostname());
        expectGetProperty(MAIL_HOST_PORT, oldConfig.getPort().toString());
        expectGetProperty(MAIL_HOST_USERNAME, oldConfig.getUsername());
        expectGetProperty(MAIL_HOST_PASSWORD, oldConfig.getPassword());
        expectGetProperty(MAIL_PROTOCOL, oldConfig.getProtocol().name());
        expectGetProperty(MAIL_USE_TLS, "false");

        MailHostConfiguration newConfig = new MailHostConfiguration.Builder().hostname("new.mail.host.name").protocol(SMTP).useStartTls(true).build();
        service.setMailHostConfiguration(newConfig);

        verifyUpdated(MAIL_HOST, "new.mail.host.name");
        verifyDeleted(MAIL_HOST_PORT);
        verifyDeleted(MAIL_HOST_USERNAME);
        verifyDeleted(MAIL_HOST_PASSWORD);
        verifyUpdated(MAIL_PROTOCOL, SMTP.name());
        verifyUpdated(MAIL_USE_TLS, "true");

        verifyEventPublished(Property.MAIL_HOST_CONFIGURATION, oldConfig, newConfig);
    }

    @Test
    public void testSetMailHostConfigurationIdenticalDoesNotRaiseEvent() {
        MailHostConfiguration oldConfig = new MailHostConfiguration.Builder().hostname("mail.host.name").port(1234)
                .username("mailuser").password("mailpwd").protocol(SMTP).build();

        expectGetProperty(MAIL_HOST, oldConfig.getHostname());
        expectGetProperty(MAIL_HOST_PORT, oldConfig.getPort().toString());
        expectGetProperty(MAIL_HOST_USERNAME, oldConfig.getUsername());
        expectGetProperty(MAIL_HOST_PASSWORD, oldConfig.getPassword());
        expectGetProperty(MAIL_PROTOCOL, SMTP.name());
        expectGetProperty(MAIL_USE_TLS, "false");
        expectGetProperty(MAIL_REQUIRE_TLS, "false");

        service.setMailHostConfiguration(oldConfig);

        verify(eventPublisher, never()).publish(anyObject());
    }

    @Test
    public void testSetServerId() {
        ArgumentCaptor<ApplicationProperty> captor = ArgumentCaptor.forClass(ApplicationProperty.class);

        when(dao.getById(same(SERVER_ID))).thenReturn(null);

        service.setServerId("ID");

        verify(dao).getById(same(SERVER_ID));
        verify(dao).create(captor.capture());

        ApplicationProperty actual = captor.getValue();
        assertSame(SERVER_ID, actual.getKey());
        assertEquals("ID", actual.getValue());
    }

    @Test
    public void testSetServerTimeRestrictedToSysAdmin() throws Exception {
        service = newService(new Properties());

        Method setServerTimeZone = service.getClass().getMethod("setServerTimeZone", ZoneId.class);
        PreAuthorize annotation = setServerTimeZone.getAnnotation(PreAuthorize.class);

        assertThat(annotation, notNullValue());
        assertThat(annotation.value(), equalTo("hasGlobalPermission('SYS_ADMIN')"));
    }

    @Test
    public void testSetServerTimeZoneToEmptyClearsProperty() {
        service = newService(new Properties());
        ZoneId oldTz = cairoTz();

        whenServerTzSetTo(oldTz);

        service.setServerTimeZone(null);

        verify(dao).deleteById(SERVER_TIMEZONE);
        verifyEventPublished(Property.TIME_ZONE, oldTz.getId(), null);
    }

    @Test
    public void testSetServerTimeZoneSetsProperty() {
        service = newService(new Properties());
        ZoneId tz = cairoTz();

        whenNoServerTzSet();

        service.setServerTimeZone(tz);

        verify(dao).create(new ApplicationProperty(SERVER_TIMEZONE, tz.getId()));
        verifyEventPublished(Property.TIME_ZONE, null, tz.getId());
    }

    @Test
    public void testSetServerTimeZoneUpdatesProperty() {
        service = newService(new Properties());
        ZoneId newTz = cairoTz();
        ZoneId oldTz = londonTz();

        whenServerTzSetTo(oldTz);

        service.setServerTimeZone(newTz);

        verify(dao).update(new ApplicationProperty(SERVER_TIMEZONE, newTz.getId()));
        verifyEventPublished(Property.TIME_ZONE, oldTz.getId(), newTz.getId());
    }

    @Test
    public void testNullPropertyName() {
        thrown.expect(NullPointerException.class);
        thrown.expectMessage("Property name must not be null");
        service.getPluginProperty(null);
    }

    @Test
    public void testEmptyPropertyName() {
        thrown.expect(IllegalArgumentException.class);
        thrown.expectMessage("Property name \"\" does not start with \"plugin.\"");
        service.getPluginProperty("");
    }

    @Test
    public void testBlankPropertyName() {
        thrown.expect(IllegalArgumentException.class);
        thrown.expectMessage("Property name \"    \" does not start with \"plugin.\"");
        service.getPluginProperty("    ");
    }

    /**
     * Tests that the {@link ApplicationPropertiesServiceImpl#getPluginProperty(String)} method
     * rejects property names that start with something other than "plugin."
     */
    @Test
    public void testNonPluginProperty() {
        thrown.expect(IllegalArgumentException.class);
        thrown.expectMessage("Property name \"notPlugin.whatever\" does not start with \"plugin.\"");
        service.getPluginProperty("notPlugin.whatever");
    }

    /**
     * Tests that the {@link ApplicationPropertiesServiceImpl#getPluginProperty(String, String)} method
     * rejects property names that start with something other than "plugin."
     */
    @Test
    public void testNonPluginPropertyWithDefaultString() {
        thrown.expect(IllegalArgumentException.class);
        thrown.expectMessage("Property name \"notPlugin.whatever\" does not start with \"plugin.\"");
        service.getPluginProperty("notPlugin.whatever", "someDefaultValue");
    }

    @Test
    public void testPropertyValues() {
        assertEquals(VALUE1, service.getPluginProperty(TEST_PROPERTY1, DEFAULT_VALUE));
        assertEquals(VALUE2, service.getPluginProperty(TEST_PROPERTY2, DEFAULT_VALUE));
        assertEquals(LONG_VALUE, service.getPluginProperty(TEST_LONG_PROPERTY, 789L));
        assertEquals(LONG_VALUE, service.getPluginProperty(NON_EXISTING_PROPERTY, LONG_VALUE));
        assertEquals(INT_VALUE, service.getPluginProperty(TEST_INT_PROPERTY, 789));
        assertEquals(INT_VALUE, service.getPluginProperty(NON_EXISTING_PROPERTY, INT_VALUE));
        assertEquals(true, service.getPluginProperty(TEST_BOOL_PROPERTY1, false));
        assertEquals(true, service.getPluginProperty(TEST_BOOL_PROPERTY2, false));
        assertEquals(true, service.getPluginProperty(NON_EXISTING_PROPERTY, true));
    }

    @Test(expected = NumberFormatException.class)
    public void testPropertyBadLongValue() {
        service.getPluginProperty(TEST_BAD_LONG_PROPERTY, 789L);
    }

    @Test(expected = NumberFormatException.class)
    public void testPropertyBadIntValue() {
        service.getPluginProperty(TEST_BAD_INT_PROPERTY, 789);
    }

    @Test
    public void testPropertyBadBooleanValues() {
        assertFalse(service.getPluginProperty(TEST_BAD_BOOL_PROPERTY1, false));
        assertFalse(service.getPluginProperty(TEST_BAD_BOOL_PROPERTY2, false));
        assertFalse(service.getPluginProperty(TEST_BAD_BOOL_PROPERTY3, false));
    }

    @Test
    public void testPropertyNoValues() throws Exception {
        assertEquals(VALUE1, service.getPluginProperty(NON_EXISTING_PROPERTY, VALUE1));
        assertEquals(VALUE2, service.getPluginProperty(NON_EXISTING_PROPERTY, VALUE2));
        assertEquals(LONG_VALUE, service.getPluginProperty(NON_EXISTING_PROPERTY, LONG_VALUE));
        assertEquals(INT_VALUE, service.getPluginProperty(NON_EXISTING_PROPERTY, INT_VALUE));
        assertEquals(true, service.getPluginProperty(NON_EXISTING_PROPERTY, true));
        assertNull(service.getPluginProperty(NON_EXISTING_PROPERTY));
    }

    @Test
    public void testDriverClassVersion() throws Exception {
        assertEquals("10.2", service.getJdbcDriverVersion());
    }

    @Test
    public void testIsSetupCache() {
        when(dao.getById(SETUP_COMPLETED)).thenReturn(new ApplicationProperty(SETUP_COMPLETED, "false"));
        assertFalse(service.isSetup());
        verify(dao).getById(SETUP_COMPLETED);

        when(dao.getById(SETUP_COMPLETED)).thenReturn(new ApplicationProperty(SETUP_COMPLETED, "true"));
        cacheManager.flushCaches();

        assertTrue(service.isSetup());
        verify(dao, times(2)).getById(SETUP_COMPLETED);
        cacheManager.flushCaches();

        // The second check should not query the cache.
        assertTrue(service.isSetup());
        verify(dao, times(2)).getById(SETUP_COMPLETED);
    }

    private static ZoneId cairoTz() {
        return ZoneId.of("Africa/Cairo");
    }

    private static ZoneId londonTz() {
        return ZoneId.of("Europe/London");
    }

    private ApplicationPropertiesServiceImpl newService(Properties properties) {
        return new ApplicationPropertiesServiceImpl(settings, buildInfo, cacheManager, dataSourceConfiguration,
                eventPublisher, properties, dao, transactionManager, transactionSynchronizer, validator);
    }

    private ApplicationPropertiesServiceImplTest whenNoServerTzSet() {
        when(dao.getById(SERVER_TIMEZONE)).thenReturn(null);
        return this;
    }

    private ApplicationPropertiesServiceImplTest whenServerTzSetTo(String zoneId) {
        when(dao.getById(SERVER_TIMEZONE)).thenReturn(new ApplicationProperty(SERVER_TIMEZONE, zoneId));
        return this;
    }

    private ApplicationPropertiesServiceImplTest whenServerTzSetTo(ZoneId zoneId) {
        return whenServerTzSetTo(zoneId.getId());
    }
}
