package com.atlassian.stash.internal.merge.upgrade;

import com.atlassian.bitbucket.AuthorisationException;
import com.atlassian.bitbucket.concurrent.LockService;
import com.atlassian.bitbucket.permission.Permission;
import com.atlassian.bitbucket.project.ProjectService;
import com.atlassian.bitbucket.repository.RepositoryService;
import com.atlassian.bitbucket.user.DummySecurityService;
import com.atlassian.bitbucket.user.SecurityService;
import com.atlassian.stash.internal.ApplicationSettings;
import com.atlassian.stash.internal.config.ConfigurationService;
import com.atlassian.stash.internal.config.DeprecateConfigurationRequest;
import com.atlassian.stash.internal.merge.*;
import com.atlassian.stash.internal.project.InternalProject;
import com.atlassian.stash.internal.repository.InternalRepository;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Iterables;
import org.hamcrest.Description;
import org.hamcrest.TypeSafeMatcher;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import org.junit.runner.RunWith;
import org.mockito.*;
import org.mockito.runners.MockitoJUnitRunner;
import org.springframework.jdbc.CannotGetJdbcConnectionException;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.TransactionStatus;

import java.io.File;
import java.io.IOException;
import java.util.Properties;
import java.util.Set;
import java.util.concurrent.locks.Lock;

import static com.atlassian.bitbucket.util.FileUtils.construct;
import static com.atlassian.bitbucket.util.FileUtils.mkdir;
import static com.atlassian.stash.internal.merge.upgrade.MigrateMergeConfigUpgradeTask.DEPRECATION_MESSAGE;
import static com.atlassian.stash.internal.merge.upgrade.MigrateMergeConfigUpgradeTask.SCM_ID;
import static com.google.common.io.Files.touch;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentCaptor.forClass;
import static org.mockito.Mockito.*;

@RunWith(MockitoJUnitRunner.class)
public class MigrateMergeConfigUpgradeTaskTest {

    private static final int PROJECT_ID = 10;
    private static final int REPOSITORY_ID = 100;

    @Rule
    public TemporaryFolder temporaryFolder = new TemporaryFolder();

    @Mock
    private ConfigurationService configurationService;
    @Mock
    private Lock lock;
    @Mock
    private LockService lockService;
    @Mock(name = "project")
    private InternalProject project;
    @Mock
    private ProjectMergeConfigDao projectMergeConfigDao;
    @Mock
    private ProjectService projectService;
    @Mock
    private Properties properties;
    @Mock
    private RepositoryMergeConfigDao repositoryMergeConfigDao;
    @Mock
    private RepositoryService repositoryService;
    @Mock(name = "repository")
    private InternalRepository repository;
    @Spy
    private SecurityService securityService = new DummySecurityService();
    @Mock
    private ScmMergeConfigDao scmMergeConfigDao;
    @Mock
    private ApplicationSettings settings;
    @Mock
    private TransactionStatus transaction;
    @Mock
    private PlatformTransactionManager transactionManager;
    @InjectMocks
    private MigrateMergeConfigUpgradeTask upgradeTask;

    @Before
    public void setup() throws IOException {
        when(lockService.getLock(MigrateMergeConfigUpgradeTask.KEY_TASK_LOCK)).thenReturn(lock);
        when(project.getId()).thenReturn(PROJECT_ID);
        when(projectService.getByKey("project")).thenReturn(project);
        when(properties.getProperty(anyString())).thenReturn("ff");
        when(repository.getId()).thenReturn(REPOSITORY_ID);
        when(repositoryService.getBySlug("project", "repo")).thenReturn(repository);
        when(transactionManager.getTransaction(notNull(TransactionDefinition.class))).thenReturn(transaction);
    }

    @Test
    public void testApply() throws Exception {
        when(settings.getConfigDir()).thenReturn(temporaryFolder.getRoot());

        Set<String> configValues = ImmutableSet.of("plugin.bitbucket-git.pullrequest.merge.strategy",
                "plugin.bitbucket-git.pullrequest.merge.strategy.project",
                "plugin.bitbucket-git.pullrequest.merge.strategy.project.repo");
        when(properties.stringPropertyNames()).thenReturn(configValues);

        upgradeTask.apply();

        verify(properties).stringPropertyNames();

        InOrder ordered = inOrder(lockService, lock, securityService, transactionManager, transaction);
        ordered.verify(lockService).getLock(MigrateMergeConfigUpgradeTask.KEY_TASK_LOCK);
        ordered.verify(lock).lock();
        ordered.verify(securityService).withPermission(same(Permission.PROJECT_READ), anyString());
        ordered.verify(transactionManager).getTransaction(notNull(TransactionDefinition.class));
        ordered.verify(transactionManager).commit(same(transaction));
        ordered.verify(transactionManager, never()).rollback(any(TransactionStatus.class));
        ordered.verify(lock).unlock();

        verify(projectMergeConfigDao).create(projectMergeConfig("ff"));
        verify(repositoryMergeConfigDao).create(repositoryMergeConfig("ff"));
        verify(scmMergeConfigDao).create(scmMergeConfig("ff"));
        verifyNoMoreInteractions(projectMergeConfigDao, repositoryMergeConfigDao, scmMergeConfigDao);

        verifyConfigChanges(configValues);

        //Verify that the marker file is created when the upgrade completes
        assertTrue(construct(temporaryFolder.getRoot(), MigrateMergeConfigUpgradeTask.PATH_MARKER_FILE).isFile());
    }

    @Test(expected = CannotGetJdbcConnectionException.class)
    public void testApplyRollsBackOnFailure() throws Exception {
        //The specifics of how this happens aren't terribly likely, but the important thing here is to verify
        //that the TransactionBatcher is rolled back if the commit() isn't hit.
        doThrow(CannotGetJdbcConnectionException.class).when(properties).stringPropertyNames();
        when(settings.getConfigDir()).thenReturn(temporaryFolder.getRoot());

        try {
            upgradeTask.apply();
        } finally {
            verify(transactionManager).getTransaction(notNull(TransactionDefinition.class));
            verify(transactionManager).rollback(same(transaction));
            verify(transactionManager, never()).commit(any(TransactionStatus.class));
        }
    }

    @Test
    public void testApplySkipsUpgradeIfMarkerPresent() throws Exception {
        when(settings.getConfigDir()).thenReturn(temporaryFolder.getRoot());

        File marker = construct(temporaryFolder.getRoot(), MigrateMergeConfigUpgradeTask.PATH_MARKER_FILE);
        mkdir(marker.getParentFile());
        touch(marker);

        MigrateMergeConfigUpgradeTask spy = spy(upgradeTask);
        spy.apply();

        verify(spy, never()).upgrade();
        verifyZeroInteractions(lockService, lock, projectMergeConfigDao, projectService,
                repositoryMergeConfigDao, repositoryService, scmMergeConfigDao);
    }

    @Test
    public void testApplySkipsUpgradeIfMarkerPresentAfterLocking() throws Exception {
        doAnswer(invocation -> {
            //Create the marker when Lock.lock is called, to ensure it's picked up by the double-check
            //rather than by the initial check
            File marker = construct(temporaryFolder.getRoot(), MigrateMergeConfigUpgradeTask.PATH_MARKER_FILE);
            mkdir(marker.getParentFile());
            touch(marker);

            return null;
        }).when(lock).lock();

        when(settings.getConfigDir()).thenReturn(temporaryFolder.getRoot());

        MigrateMergeConfigUpgradeTask spy = spy(upgradeTask);
        spy.apply();

        verify(spy, never()).upgrade();
        verifyZeroInteractions(projectMergeConfigDao, projectService, repositoryMergeConfigDao,
                repositoryService, scmMergeConfigDao);

        InOrder ordered = inOrder(lockService, lock);
        ordered.verify(lockService).getLock(MigrateMergeConfigUpgradeTask.KEY_TASK_LOCK);
        ordered.verify(lock).lock();
        //Verify the lock is released after skipping the upgrade task
        ordered.verify(lock).unlock();
    }

    @Test
    public void testUpgradeDropsPropertiesWithBlankValues() throws Exception {
        ImmutableSet<String> configValues = ImmutableSet.of("plugin.bitbucket-git.pullrequest.merge.strategy.project.repo");
        when(properties.getProperty(Iterables.getOnlyElement(configValues))).thenReturn("    ");
        when(properties.stringPropertyNames()).thenReturn(configValues);

        int updated = upgradeTask.upgrade();
        assertEquals(0, updated);

        verify(properties).stringPropertyNames();
        //Verify the empty value was ignored prior to calling any of the DAOs or RepositoryService
        verifyZeroInteractions(projectMergeConfigDao, repositoryMergeConfigDao, repositoryService, scmMergeConfigDao);

        //Verify that the property is still removed
        verifyConfigChanges(configValues);
    }

    @Test
    public void testUpgradeDropsPropertiesWithEmptyValues() throws Exception {
        ImmutableSet<String> configValues = ImmutableSet.of("plugin.bitbucket-git.pullrequest.merge.strategy");
        when(properties.getProperty(Iterables.getOnlyElement(configValues))).thenReturn("");
        when(properties.stringPropertyNames()).thenReturn(configValues);

        int updated = upgradeTask.upgrade();
        assertEquals(0, updated);

        verify(properties).stringPropertyNames();
        //Verify the empty value was ignored prior to calling any DAOs
        verifyZeroInteractions(projectMergeConfigDao, repositoryMergeConfigDao, scmMergeConfigDao);

        //Verify that the property is still removed
        verifyConfigChanges(configValues);
    }

    @Test
    public void testUpgradeDropsPropertiesWithNullValues() throws Exception {
        ImmutableSet<String> configValues = ImmutableSet.of("plugin.bitbucket-git.pullrequest.merge.strategy.project");
        reset(properties); //Reset getProperty(String) training so it returns null instead
        when(properties.stringPropertyNames()).thenReturn(configValues);

        int updated = upgradeTask.upgrade();
        assertEquals(0, updated);

        verify(properties).stringPropertyNames();
        //Verify the null value was ignored prior to calling any DAOs or ProjectService
        verifyZeroInteractions(projectMergeConfigDao, projectService, repositoryMergeConfigDao, scmMergeConfigDao);

        //Verify that the property is still removed
        verifyConfigChanges(configValues);
    }

    @Test
    public void testUpgradeDropsPropertiesWithUnsupportedStrategies() throws Exception {
        when(properties.getProperty(eq("plugin.bitbucket-git.pullrequest.merge.strategy.project.repo")))
                .thenReturn("ff-bad");

        Set<String> configValues = ImmutableSet.of("plugin.bitbucket-git.pullrequest.merge.strategy.project.repo",
                "plugin.bitbucket-git.pullrequest.merge.strategy");
        when(properties.stringPropertyNames()).thenReturn(configValues);

        int updated = upgradeTask.upgrade();
        assertEquals(1, updated);

        verify(properties).stringPropertyNames();
        verify(scmMergeConfigDao).create(scmMergeConfig("ff"));
        verifyNoMoreInteractions(scmMergeConfigDao);
        verifyZeroInteractions(projectMergeConfigDao, repositoryMergeConfigDao);

        verifyConfigChanges(configValues);
    }

    @Test
    public void testUpgradeForNonexistentProject() throws Exception {
        Set<String> configValues = ImmutableSet.of("plugin.bitbucket-git.pullrequest.merge.strategy.badProject",
                "plugin.bitbucket-git.pullrequest.merge.strategy");
        when(properties.stringPropertyNames()).thenReturn(configValues);

        int updated = upgradeTask.upgrade();
        assertEquals(1, updated);

        verify(properties).stringPropertyNames();
        verify(scmMergeConfigDao).create(scmMergeConfig("ff"));
        verifyNoMoreInteractions(scmMergeConfigDao);
        verifyZeroInteractions(projectMergeConfigDao, repositoryMergeConfigDao);

        verifyConfigChanges(configValues);
    }

    @Test
    public void testUpgradeForNonexistentRepository() throws Exception {
        Set<String> configValues = ImmutableSet.of("plugin.bitbucket-git.pullrequest.merge.strategy.project.badRepo",
                "plugin.bitbucket-git.pullrequest.merge.strategy");
        when(properties.stringPropertyNames()).thenReturn(configValues);

        int updated = upgradeTask.upgrade();
        assertEquals(1, updated);

        verify(properties).stringPropertyNames();
        verify(scmMergeConfigDao).create(scmMergeConfig("ff"));
        verifyNoMoreInteractions(scmMergeConfigDao);
        verifyZeroInteractions(projectMergeConfigDao, repositoryMergeConfigDao);

        verifyConfigChanges(configValues);
    }

    @Test
    public void testUpgradeHandlesRuntimeExceptions() throws Exception {
        ImmutableSet<String> configValues = ImmutableSet.of("plugin.bitbucket-git.pullrequest.merge.strategy.project");

        doThrow(AuthorisationException.class).when(projectService).getByKey(eq("project"));
        when(properties.stringPropertyNames()).thenReturn(configValues);

        int updated = upgradeTask.upgrade();
        assertEquals(0, updated);

        verify(projectService).getByKey(eq("project"));
        verifyZeroInteractions(projectMergeConfigDao, repositoryMergeConfigDao, scmMergeConfigDao);

        //Even though looking up the project throws, the property should still be removed
        verifyConfigChanges(configValues);
    }

    @Test
    public void testUpgradeNormalizesStrategies() throws Exception {
        Set<String> configValues = ImmutableSet.of("plugin.bitbucket-git.pullrequest.merge.strategy");
        when(properties.getProperty(eq("plugin.bitbucket-git.pullrequest.merge.strategy"))).thenReturn("FF_ONLY");
        when(properties.stringPropertyNames()).thenReturn(configValues);

        int updated = upgradeTask.upgrade();
        assertEquals(1, updated);

        verify(properties).stringPropertyNames();
        verify(scmMergeConfigDao).create(scmMergeConfig("ff-only"));
        verifyNoMoreInteractions(scmMergeConfigDao);
        verifyZeroInteractions(projectMergeConfigDao, repositoryMergeConfigDao);

        verifyConfigChanges(configValues);
    }

    @Test
    public void testUpgradeForMixedProperties() throws Exception {
        ImmutableSet<String> configValues = ImmutableSet.of("plugin.bitbucket-git.pullrequest.merge.strategy",
                "plugin.bitbucket-git.pullrequest.merge.strategy.project",
                "plugin.bitbucket-git.pullrequest.merge.strategy.project.repo");
        when(properties.stringPropertyNames()).thenReturn(configValues);

        int updated = upgradeTask.upgrade();
        assertEquals(3, updated);

        verify(projectMergeConfigDao).create(projectMergeConfig("ff"));
        verify(properties).stringPropertyNames();
        verify(repositoryMergeConfigDao).create(repositoryMergeConfig("ff"));
        verify(scmMergeConfigDao).create(scmMergeConfig("ff"));
        verifyNoMoreInteractions(projectMergeConfigDao, repositoryMergeConfigDao, scmMergeConfigDao);

        verifyConfigChanges(configValues);
    }

    @Test
    public void testUpgradeForProject() throws Exception {
        Set<String> configValues = ImmutableSet.of("plugin.bitbucket-git.pullrequest.merge.strategy.project");
        when(properties.stringPropertyNames()).thenReturn(configValues);

        int updated = upgradeTask.upgrade();

        verify(properties).stringPropertyNames();
        assertEquals(1, updated);

        verify(projectMergeConfigDao).create(projectMergeConfig("ff"));
        verifyNoMoreInteractions(projectMergeConfigDao);
        verifyZeroInteractions(repositoryMergeConfigDao, scmMergeConfigDao);

        verifyConfigChanges(configValues);
    }

    @Test
    public void testUpgradeForRepository() throws Exception {
        Set<String> configValues = ImmutableSet.of("plugin.bitbucket-git.pullrequest.merge.strategy.project.repo");

        when(properties.stringPropertyNames()).thenReturn(configValues);

        int updated = upgradeTask.upgrade();

        verify(properties).stringPropertyNames();
        assertEquals(1, updated);

        verify(repositoryMergeConfigDao).create(repositoryMergeConfig("ff"));
        verifyNoMoreInteractions(repositoryMergeConfigDao);
        verifyZeroInteractions(projectMergeConfigDao, scmMergeConfigDao);

        verifyConfigChanges(configValues);
    }

    @Test
    public void testUpgradeForScm() throws Exception {
        Set<String> configValues = ImmutableSet.of("plugin.bitbucket-git.pullrequest.merge.strategy");
        when(properties.stringPropertyNames()).thenReturn(configValues);

        int updated = upgradeTask.upgrade();
        assertEquals(1, updated);

        verify(properties).stringPropertyNames();
        verify(scmMergeConfigDao).create(scmMergeConfig("ff"));
        verifyNoMoreInteractions(scmMergeConfigDao);
        verifyZeroInteractions(projectMergeConfigDao, repositoryMergeConfigDao);

        verifyConfigChanges(configValues);
    }

    @Test
    public void testUpgradeIgnoresInvalidProperties() throws Exception {
        //"here.be.dragons" has 3 parts. It couldn't possibly be targeting the Git SCM, and it
        //would have been ignored. Since it's not "active" configuration, the migration should
        //likewise ignore it
        when(properties.getProperty("plugin.bitbucket-git.pullrequest.merge.strategy.here.be.dragons")).thenReturn(null);

        when(properties.stringPropertyNames()).thenReturn(ImmutableSet.of(
                "plugin.bitbucket-git.pullrequest.merge.strategy.here.be.dragons",
                "plugin.bitbucket-git.pullrequest.merge.strategy"));

        int updated = upgradeTask.upgrade();
        assertEquals(1, updated);

        verify(properties).stringPropertyNames();
        verify(scmMergeConfigDao).create(scmMergeConfig("ff"));
        verifyNoMoreInteractions(scmMergeConfigDao);
        verifyZeroInteractions(projectMergeConfigDao, repositoryMergeConfigDao);

        //Verify that the "here.be.dragons" property is not removed
        verifyConfigChanges(ImmutableSet.of("plugin.bitbucket-git.pullrequest.merge.strategy"));
    }

    private static InternalProjectMergeConfig projectMergeConfig(String strategyId) {
        return argThat(new ProjectMergeConfigMatcher(strategyId));
    }

    private static InternalRepositoryMergeConfig repositoryMergeConfig(String strategyId) {
        return argThat(new RepositoryMergeConfigMatcher(strategyId));
    }

    private static InternalScmMergeConfig scmMergeConfig(String strategyId) {
        return argThat(new ScmMergeConfigMatcher(strategyId));
    }

    private void verifyConfigChanges(Set<String> properties) {
        ArgumentCaptor<DeprecateConfigurationRequest> captor = forClass(DeprecateConfigurationRequest.class);

        verify(configurationService).removeMergeStrategyProperties(captor.capture());

        assertEquals("# " + DEPRECATION_MESSAGE, captor.getValue().getDeprecationMessage());
        assertEquals(properties, captor.getValue().getProperties());
    }

    private static class ProjectMergeConfigMatcher extends TypeSafeMatcher<InternalProjectMergeConfig> {

        private final String strategyId;

        public ProjectMergeConfigMatcher(String strategyId) {
            super(InternalProjectMergeConfig.class);

            this.strategyId = strategyId;
        }

        @Override
        public void describeTo(Description description) {
            description.appendText("InternalProjectMergeConfig(")
                    .appendValue(PROJECT_ID)
                    .appendText(", ")
                    .appendText(SCM_ID)
                    .appendText(", ")
                    .appendText(strategyId)
                    .appendText(")");
        }

        @Override
        protected boolean matchesSafely(InternalProjectMergeConfig config) {
            return PROJECT_ID == config.getProjectId() &&
                    SCM_ID.equals(config.getScmId()) &&
                    strategyId.equals(config.getDefaultStrategyId()) &&
                    ImmutableSet.of(strategyId).equals(config.getStrategyIds());
        }
    }

    private static class RepositoryMergeConfigMatcher extends TypeSafeMatcher<InternalRepositoryMergeConfig> {

        private final String strategyId;

        public RepositoryMergeConfigMatcher(String strategyId) {
            super(InternalRepositoryMergeConfig.class);

            this.strategyId = strategyId;
        }

        @Override
        public void describeTo(Description description) {
            description.appendText("InternalRepositoryMergeConfig(")
                    .appendValue(REPOSITORY_ID)
                    .appendText(", ")
                    .appendText(strategyId)
                    .appendText(")");
        }

        @Override
        protected boolean matchesSafely(InternalRepositoryMergeConfig config) {
            return REPOSITORY_ID == config.getRepositoryId() &&
                    strategyId.equals(config.getDefaultStrategyId()) &&
                    ImmutableSet.of(strategyId).equals(config.getStrategyIds());
        }
    }

    private static class ScmMergeConfigMatcher extends TypeSafeMatcher<InternalScmMergeConfig> {

        private final String strategyId;

        public ScmMergeConfigMatcher(String strategyId) {
            super(InternalScmMergeConfig.class);

            this.strategyId = strategyId;
        }

        @Override
        public void describeTo(Description description) {
            description.appendText("InternalScmMergeConfig(")
                    .appendText(SCM_ID)
                    .appendText(", ")
                    .appendText(strategyId)
                    .appendText(")");
        }

        @Override
        protected boolean matchesSafely(InternalScmMergeConfig config) {
            return SCM_ID.equals(config.getScmId()) &&
                    strategyId.equals(config.getDefaultStrategyId()) &&
                    ImmutableSet.of(strategyId).equals(config.getStrategyIds());
        }
    }
}