package com.atlassian.stash.internal.scm;

import com.atlassian.bitbucket.scm.*;
import com.atlassian.event.api.EventPublisher;
import com.atlassian.plugin.PluginAccessor;
import com.atlassian.bitbucket.i18n.I18nService;
import com.atlassian.bitbucket.i18n.KeyedMessage;
import com.atlassian.bitbucket.i18n.SimpleI18nService;
import com.atlassian.stash.internal.db.DatabaseManager;
import com.atlassian.stash.internal.maintenance.latch.LatchMode;
import com.atlassian.stash.internal.maintenance.latch.LatchState;
import com.atlassian.bitbucket.repository.Repository;
import com.atlassian.bitbucket.server.ApplicationPropertiesService;
import com.atlassian.bitbucket.util.Drainable;
import com.atlassian.bitbucket.util.ForcedDrainable;
import com.google.common.collect.Iterables;
import com.hazelcast.core.Cluster;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Spy;
import org.mockito.runners.MockitoJUnitRunner;

import java.util.Arrays;
import java.util.Collections;
import java.util.EnumSet;
import java.util.Set;
import java.util.concurrent.TimeUnit;

import static org.hamcrest.Matchers.contains;
import static org.junit.Assert.*;
import static org.mockito.Matchers.same;
import static org.mockito.Mockito.*;

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

    @Mock
    private ApplicationPropertiesService propertiesService;
    @Mock
    private Cluster cluster;
    @Mock
    private DatabaseManager databaseManager;
    @Mock
    private DrainableScm drainableScm;
    @Mock
    private ScmModuleDescriptor drainableScmDescriptor;
    @Mock
    private DrainableScm drainableScm2;
    @Mock
    private ScmModuleDescriptor drainableScmDescriptor2;
    @Mock
    private DrainableScm drainableScm3;
    @Mock
    private ScmModuleDescriptor drainableScmDescriptor3;
    @Mock
    private EventPublisher eventPublisher;
    @Mock
    private ScmModuleDescriptor forcedDrainableScmDescriptor;
    @Mock
    private ForcedDrainableScm forcedDrainableScm;
    @Spy
    @SuppressWarnings("unused")
    private I18nService i18nService = new SimpleI18nService();
    @Mock
    private PluginAccessor pluginAccessor;
    @Mock
    private Scm scm;
    @Mock
    private ScmModuleDescriptor scmDescriptor;
    @InjectMocks
    private PluginScmService service;
    @Mock
    private ScmStatus status;
    @Mock
    private Repository repository;
    @Mock
    private ScmProtocol ssh;
    @Mock
    private ScmProtocolModuleDescriptor sshDescriptor;
    @Mock
    private ScmProtocol http;
    @Mock
    private ScmProtocolModuleDescriptor httpDescriptor;

    @Before
    public void setup() {
        when(scmDescriptor.getModule()).thenReturn(scm);
        when(drainableScmDescriptor.getModule()).thenReturn(drainableScm);
        when(drainableScmDescriptor2.getModule()).thenReturn(drainableScm2);
        when(drainableScmDescriptor3.getModule()).thenReturn(drainableScm3);
        when(forcedDrainableScmDescriptor.getModule()).thenReturn(forcedDrainableScm);

        when(pluginAccessor.getEnabledModuleDescriptorsByClass(same(ScmModuleDescriptor.class))).thenReturn(Collections.singletonList(scmDescriptor));
    }

    @Test
    public void testAcquireLatch() {
        when(databaseManager.isLatched()).thenReturn(true);

        ScmLatch latch = service.acquireLatch(LatchMode.LOCAL);
        assertNotNull(latch);
        assertSame(latch, service.getCurrentLatch());
        assertTrue(service.isLatched());

        latch.unlatch();
        assertFalse(service.isLatched());
        assertNull(service.getCurrentLatch());
    }

    @Test(expected = IllegalStateException.class)
    public void testAcquireLatchThrowsWhenAlreadyLatched() {
        when(databaseManager.isLatched()).thenReturn(true);

        try {
            service.acquireLatch(LatchMode.LOCAL);
        } catch (Throwable t) {
            fail("Acquiring the latch should have succeeded");
        }

        service.acquireLatch(LatchMode.LOCAL);
    }

    @Test(expected = IllegalStateException.class)
    public void testAcquireLatchThrowsWhenDatabaseIsNotLatched() {
        service.acquireLatch(LatchMode.LOCAL);
    }

    @SuppressWarnings("unchecked")
    @Test
    public void testCreateBuilder() {
        ScmCommandBuilder builder = mock(ScmCommandBuilder.class);

        PluginCommandBuilderFactory factory = mock(PluginCommandBuilderFactory.class);
        when(factory.builder(same(repository))).thenReturn(builder);

        when(repository.getScmId()).thenReturn("humhum");

        when(scm.getCommandBuilderFactory()).thenReturn(factory);
        when(scm.getId()).thenReturn("humhum");
        when(scm.getStatus()).thenReturn(status);

        when(status.isAvailable()).thenReturn(true);

        ScmCommandBuilder commandBuilder = service.createBuilder(repository);
        assertSame(builder, commandBuilder);
    }

    @Test(expected = FeatureUnsupportedScmException.class)
    public void testCreateBuilderNotSupported() {
        when(scm.getId()).thenReturn("humhum");
        when(scm.getStatus()).thenReturn(status);
        when(status.isAvailable()).thenReturn(true);

        Repository repository = mock(Repository.class);
        when(repository.getScmId()).thenReturn("humhum");

        // scm.getCommandBuilderFactory() returns null

        service.createBuilder(repository);
    }

    @SuppressWarnings("unchecked")
    @Test
    public void testDelete() {
        Command<Void> command = mock(Command.class);
        DeleteCommandParameters parameters = mock(DeleteCommandParameters.class);

        AsyncCommand<Void> async = mock(AsyncCommand.class);
        when(async.synchronous()).thenReturn(command);

        Repository repository = mock(Repository.class);
        when(repository.getScmId()).thenReturn("git");

        PluginCommandFactory commandFactory = mock(PluginCommandFactory.class);
        when(commandFactory.delete(same(repository), same(parameters))).thenReturn(async);

        when(scm.getCommandFactory()).thenReturn(commandFactory);
        when(scm.getId()).thenReturn("git");
        when(scm.getStatus()).thenReturn(status);
        when(status.isAvailable()).thenReturn(true);

        service.delete(repository, parameters);

        verify(command).call();
    }

    @Test(expected = NullPointerException.class)
    public void testDeleteWithNullParameters() {
        service.delete(mock(Repository.class), null);
    }

    @Test(expected = NullPointerException.class)
    public void testDeleteWithNullRepository() {
        service.delete(null, mock(DeleteCommandParameters.class));
    }

    @Test
    public void testDrain() {
        when(databaseManager.isLatched()).thenReturn(true);

        when(drainableScm.getStatus()).thenReturn(status);
        when(drainableScm.drain(eq(5L), same(TimeUnit.NANOSECONDS))).thenReturn(true);

        when(drainableScm2.getStatus()).thenReturn(status);
        when(drainableScm2.drain(eq(0L), same(TimeUnit.NANOSECONDS))).thenReturn(true);

        when(drainableScm3.getStatus()).thenReturn(status);
        when(drainableScm3.drain(anyLong(), same(TimeUnit.NANOSECONDS))).thenReturn(true);

        when(status.isAvailable()).thenReturn(true);

        when(pluginAccessor.getEnabledModuleDescriptorsByClass(ScmModuleDescriptor.class))
                .thenReturn(Arrays.asList(drainableScmDescriptor, drainableScmDescriptor2));

        ScmLatch latch = service.acquireLatch(LatchMode.LOCAL);
        assertNotNull(latch);

        assertTrue(latch.drain(5L, TimeUnit.NANOSECONDS));

        //drainableScm should be called with the full timeout
        verify(drainableScm).drain(eq(5L), same(TimeUnit.NANOSECONDS));
        //All 5ns (and more) will be used by drainableScm, but drainableScm2 should still be called with a 0L timeout
        verify(drainableScm2).drain(eq(0L), same(TimeUnit.NANOSECONDS));
        //drainableScm3 should not be called after this
        verifyNoMoreInteractions(drainableScm3);
    }

    @Test
    public void testDrainIgnoresScmsWhichAreNotDrainable() {
        when(databaseManager.isLatched()).thenReturn(true);

        ScmLatch latch = service.acquireLatch(LatchMode.LOCAL);
        assertNotNull(latch);

        //No SCMs can actually be drained, and that should be considered successful
        assertTrue(latch.drain(123L, TimeUnit.NANOSECONDS));

        verify(pluginAccessor, only()).getEnabledModuleDescriptorsByClass(same(ScmModuleDescriptor.class)); //Ensure it tried
        verifyZeroInteractions(scm); //Ensure it did not interact with the non-Drainable Scm
    }

    @Test
    public void testDrainIgnoresUnavailableDrainableScms() {
        when(databaseManager.isLatched()).thenReturn(true);
        when(drainableScm.getStatus()).thenReturn(status);
        when(pluginAccessor.getEnabledModuleDescriptorsByClass(ScmModuleDescriptor.class)).thenReturn(Collections.singletonList(drainableScmDescriptor));

        ScmLatch latch = service.acquireLatch(LatchMode.LOCAL);
        assertNotNull(latch);

        //No SCMs can actually be drained, and that should be considered successful
        assertTrue(latch.drain(123L, TimeUnit.NANOSECONDS));

        verify(drainableScm, only()).getStatus();
    }

    @Test
    public void testDrainWhenNotLatched() {
        when(databaseManager.isLatched()).thenReturn(true);

        ScmLatch latch = service.acquireLatch(LatchMode.LOCAL);
        assertNotNull(latch);

        latch.unlatch(); //This is the simplest way to clear PluginScmService.latch

        // this shouldn't throw an exception. Because of clustering it's entirely possible that one node is draining
        // while another node initiates the unlatch operation.
        latch.drain(0L, TimeUnit.NANOSECONDS);
    }

    @Test
    public void testDrainWhenScmFailsToDrain() {
        when(databaseManager.isLatched()).thenReturn(true);
        when(drainableScm.getStatus()).thenReturn(status);
        when(pluginAccessor.getEnabledModuleDescriptorsByClass(ScmModuleDescriptor.class)).thenReturn(Collections.singletonList(drainableScmDescriptor));
        when(status.isAvailable()).thenReturn(true);

        ScmLatch latch = service.acquireLatch(LatchMode.LOCAL);
        assertNotNull(latch);

        assertFalse(latch.drain(1L, TimeUnit.SECONDS));

        verify(drainableScm).drain(eq(TimeUnit.SECONDS.toNanos(1)), same(TimeUnit.NANOSECONDS));
    }

    @Test(expected = IllegalArgumentException.class)
    public void testDrainWithNegativeTimeout() {
        when(databaseManager.isLatched()).thenReturn(true);

        ScmLatch latch = service.acquireLatch(LatchMode.LOCAL);
        assertNotNull(latch);

        latch.drain(-1L, TimeUnit.DAYS);
    }

    @Test(expected = NullPointerException.class)
    public void testDrainWithNullTimeUnit() {
        when(databaseManager.isLatched()).thenReturn(true);

        ScmLatch latch = service.acquireLatch(LatchMode.LOCAL);
        assertNotNull(latch);

        latch.drain(0L, null);
    }

    @Test
    public void testDrainWithZeroTimeout() {
        when(databaseManager.isLatched()).thenReturn(true);

        when(drainableScm.getStatus()).thenReturn(status);
        when(drainableScm.drain(eq(0L), same(TimeUnit.NANOSECONDS))).thenReturn(true);

        when(pluginAccessor.getEnabledModuleDescriptorsByClass(ScmModuleDescriptor.class)).thenReturn(Collections.singletonList(drainableScmDescriptor));

        when(status.isAvailable()).thenReturn(true);

        ScmLatch latch = service.acquireLatch(LatchMode.LOCAL);
        assertNotNull(latch);

        assertTrue(latch.drain(0L, TimeUnit.NANOSECONDS));

        verify(drainableScm).drain(eq(0L), same(TimeUnit.NANOSECONDS));
    }

    @Test(expected = UnsupportedScmException.class)
    public void testFindByIdUnsupported() {
        when(scm.getId()).thenReturn("nomatch");

        service.findById("git");
    }

    @Test(expected = UnavailableScmException.class)
    public void testFindByIdUnavailable() {
        when(scm.getId()).thenReturn("git");
        when(scm.getStatus()).thenReturn(status);
        when(status.isAvailable()).thenReturn(false);
        when(status.getMessage()).thenReturn(new KeyedMessage("scm.unavailable", "Unavailable", "Unavailable"));

        service.findById("git");
    }

    @Test
    public void testForceDrain() {
        when(databaseManager.isLatched()).thenReturn(true);

        when(pluginAccessor.getEnabledModuleDescriptorsByClass(ScmModuleDescriptor.class))
                .thenReturn(Collections.singletonList(forcedDrainableScmDescriptor));

        when(status.isAvailable()).thenReturn(true);

        when(forcedDrainableScm.getStatus()).thenReturn(status);
        when(forcedDrainableScm.forceDrain(eq(5L), same(TimeUnit.NANOSECONDS))).thenReturn(true);

        ScmLatch latch = service.acquireLatch(LatchMode.LOCAL);
        assertNotNull(latch);

        assertTrue(latch.forceDrain(5L, TimeUnit.NANOSECONDS));

        //drainableScm should be called with the full timeout
        verify(forcedDrainableScm).forceDrain(eq(5L), same(TimeUnit.NANOSECONDS));
    }

    @Test
    public void testForceDrainIgnoresScmsWhichAreNotForcedDrainable() {
        when(databaseManager.isLatched()).thenReturn(true);

        when(status.isAvailable()).thenReturn(true);

        when(drainableScm.getStatus()).thenReturn(status);
        when(forcedDrainableScm.getStatus()).thenReturn(status);
        when(forcedDrainableScm.forceDrain(eq(5L), same(TimeUnit.NANOSECONDS))).thenReturn(true);

        when(drainableScm.drain(eq(0L), same(TimeUnit.NANOSECONDS))).thenReturn(true);

        when(pluginAccessor.getEnabledModuleDescriptorsByClass(ScmModuleDescriptor.class))
                .thenReturn(Arrays.asList(forcedDrainableScmDescriptor, drainableScmDescriptor));

        ScmLatch latch = service.acquireLatch(LatchMode.LOCAL);
        assertNotNull(latch);

        assertTrue(latch.forceDrain(5L, TimeUnit.NANOSECONDS));

        //forcedDrainableScm should be called with the full timeout
        verify(forcedDrainableScm).forceDrain(eq(5L), same(TimeUnit.NANOSECONDS));
        //All 5ns (and more) will be used by drainableScm, but drainableScm should still be called with a 0L timeout
        verify(drainableScm).drain(eq(0L), same(TimeUnit.NANOSECONDS));
    }

    @Test
    public void testGetAvailable() {
        ScmModuleDescriptor git = mock(ScmModuleDescriptor.class);
        when(git.getModule()).thenReturn(scm);
        when(git.getWeight()).thenReturn(3);

        ScmModuleDescriptor hg = mock(ScmModuleDescriptor.class);
        when(hg.getModule()).thenReturn(scm);
        when(hg.getWeight()).thenReturn(2);

        ScmModuleDescriptor subversion = mock(ScmModuleDescriptor.class);
        when(subversion.getModule()).thenReturn(scm);
        when(subversion.getWeight()).thenReturn(1);

        reset(pluginAccessor);

        when(pluginAccessor.getEnabledModuleDescriptorsByClass(same(ScmModuleDescriptor.class)))
                .thenReturn(Arrays.asList(subversion, hg, git, git)); //Test handling for duplicate IDs
        when(scm.getId()).thenReturn("hg", "git");          //svn is not available, its ID and name won't
        when(scm.getName()).thenReturn("Mercurial", "Git"); //be retrieved, so they're not trained
        when(scm.getStatus()).thenReturn(status);
        when(status.isAvailable()).thenReturn(false, true); //First status will be svn

        Set<AvailableScm> scms = service.getAvailable();
        assertEquals(2, scms.size());

        AvailableScm first = Iterables.get(scms, 0);
        assertEquals("hg", first.getId());
        assertEquals("Mercurial", first.getName());

        AvailableScm second = Iterables.get(scms, 1);
        assertEquals("git", second.getId());
        assertEquals("Git", second.getName());
    }

    @Test
    public void testGetScmName() {
        when(repository.getScmId()).thenReturn("hg");
        when(scm.getId()).thenReturn("hg");
        when(scm.getName()).thenReturn("Mercurial");
        when(scm.getStatus()).thenReturn(status);
        when(status.isAvailable()).thenReturn(true);

        assertEquals("Mercurial", service.getScmName(repository));
    }

    @Test
    public void testGetState() {
        assertSame(LatchState.AVAILABLE, service.getState());
    }

    @Test
    public void testGetStateWhenDrained() {
        when(databaseManager.isLatched()).thenReturn(true);

        when(drainableScm.drain(eq(0L), same(TimeUnit.NANOSECONDS))).thenReturn(true);
        when(drainableScm.getStatus()).thenReturn(status);

        when(pluginAccessor.getEnabledModuleDescriptorsByClass(ScmModuleDescriptor.class)).thenReturn(Collections.singletonList(drainableScmDescriptor));

        when(status.isAvailable()).thenReturn(true);

        ScmLatch latch = service.acquireLatch(LatchMode.LOCAL);
        assertNotNull(latch);
        assertSame(LatchState.DRAINED, service.getState());
        assertSame(LatchState.DRAINED, service.getState());
        assertSame(LatchState.DRAINED, service.getState());

        //The drain state of the latch should be cached so that SCMs are only drained once
        verify(drainableScm).drain(eq(0L), same(TimeUnit.NANOSECONDS));
    }

    @Test
    public void testGetStateWhenLatched() {
        when(databaseManager.isLatched()).thenReturn(true);
        when(drainableScm.getStatus()).thenReturn(status);
        when(pluginAccessor.getEnabledModuleDescriptorsByClass(ScmModuleDescriptor.class)).thenReturn(Collections.singletonList(drainableScmDescriptor));
        when(status.isAvailable()).thenReturn(true);

        ScmLatch latch = service.acquireLatch(LatchMode.LOCAL);
        assertNotNull(latch);
        assertSame(LatchState.LATCHED, service.getState());
    }

    @Test(expected = UnsupportedScmException.class)
    public void testQueryScmProtocolsForUnsupportedScm() {
        when(repository.getScmId()).thenReturn("hg");
        service.getProtocols(repository);
    }

    @Test
    public void testQueryScmProtocolsWhenNoneRegistered() {
        when(scm.getId()).thenReturn("git");
        when(scm.getStatus()).thenReturn(status);
        when(status.isAvailable()).thenReturn(true);
        when(repository.getScmId()).thenReturn("git");

        when(pluginAccessor.getEnabledModulesByClass(ScmProtocol.class)).thenReturn(Collections.<ScmProtocol>emptyList());

        assertEquals(0, service.getProtocols(repository).size());
    }

    @Test
    public void testQueryScmProtocolsWhenRegistered() {
        when(scm.getId()).thenReturn("git");
        when(scm.getStatus()).thenReturn(status);
        when(status.isAvailable()).thenReturn(true);
        when(repository.getScmId()).thenReturn("git");

        when(sshDescriptor.getModule()).thenReturn(ssh);
        when(ssh.getName()).thenReturn("ssh");
        when(ssh.supports(scm)).thenReturn(true);

        when(httpDescriptor.getModule()).thenReturn(http);
        when(http.getName()).thenReturn("http");
        when(http.supports(scm)).thenReturn(true);

        when(pluginAccessor.getEnabledModuleDescriptorsByClass(ScmProtocolModuleDescriptor.class)).thenReturn(Arrays.asList(sshDescriptor, httpDescriptor));

        Set<ScmProtocol> protocols = service.getProtocols(repository);
        assertEquals(2, protocols.size());
        assertThat(protocols, contains(ssh, http));
    }

    @Test
    public void testIsSupported() {
        when(repository.getScmId()).thenReturn("git");
        when(scm.getFeatures()).thenReturn(EnumSet.of(ScmFeature.COMMAND_BUILDERS, ScmFeature.PULL_REQUESTS));
        when(scm.getId()).thenReturn("git");
        when(scm.getStatus()).thenReturn(status);
        when(status.isAvailable()).thenReturn(true);

        assertTrue(service.isSupported(repository, ScmFeature.COMMAND_BUILDERS));
        assertTrue(service.isSupported(repository, ScmFeature.PULL_REQUESTS));
        assertFalse(service.isSupported(repository, ScmFeature.COMPARE));
        assertFalse(service.isSupported(repository, ScmFeature.CROSS_REPOSITORY));
        assertFalse(service.isSupported(repository, ScmFeature.FORK));
        assertFalse(service.isSupported(repository, ScmFeature.HOOKS));
        assertFalse(service.isSupported(repository, ScmFeature.MERGE));
        assertFalse(service.isSupported(repository, ScmFeature.UPDATE_DEFAULT_BRANCH));
    }

    @Test(expected = IllegalStateException.class)
    public void testUnlatchingTwiceThrows() {
        when(databaseManager.isLatched()).thenReturn(true);

        try {
            ScmLatch latch = service.acquireLatch(LatchMode.LOCAL);
            assertNotNull(latch);

            latch.unlatch();
            latch.unlatch();
        } catch (IllegalStateException e) {
            //Other code paths also throw IllegalStateExceptions, so verify we got the one we wanted
            assertEquals("This latch is no longer active", e.getMessage());

            throw e;
        }
    }

    private interface DrainableScm extends Drainable, Scm {
        //Simplifies working with both the Scm and Drainable interfaces at once. Implementing both
        //interfaces can be accomplished without this, but the tests are harder to write
    }

    private interface ForcedDrainableScm extends ForcedDrainable, Scm {
        //Simplifies working with both the Scm and ForcedDrainable interfaces at once. Implementing both
        //interfaces can be accomplished without this, but the tests are harder to write
    }
}
