package com.atlassian.stash.internal.scm.git.upgrade.sal;

import com.atlassian.sal.api.pluginsettings.PluginSettings;
import com.atlassian.sal.api.pluginsettings.PluginSettingsFactory;
import com.atlassian.sal.api.transaction.TransactionCallback;
import com.atlassian.sal.api.transaction.TransactionTemplate;
import com.atlassian.sal.core.transaction.NoOpTransactionTemplate;
import com.atlassian.sal.core.upgrade.PluginUpgrader;
import com.atlassian.bitbucket.concurrent.LockService;
import com.atlassian.stash.internal.scm.git.upgrade.AsynchronousUpgradeTask;
import com.atlassian.stash.internal.scm.git.upgrade.SynchronousUpgradeTask;
import com.atlassian.bitbucket.mockito.PageAnswer;
import com.atlassian.bitbucket.repository.Repository;
import com.atlassian.bitbucket.repository.RepositoryService;
import com.atlassian.bitbucket.scm.git.GitScm;
import com.atlassian.bitbucket.server.ApplicationPropertiesService;
import com.atlassian.bitbucket.user.DummySecurityService;
import com.atlassian.bitbucket.user.SecurityService;
import com.atlassian.bitbucket.util.FileUtils;
import com.atlassian.bitbucket.util.PageRequest;
import com.google.common.io.Files;
import org.junit.Before;
import org.junit.BeforeClass;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import org.junit.runner.RunWith;
import org.mockito.InOrder;
import org.mockito.Mock;
import org.mockito.Spy;
import org.mockito.runners.MockitoJUnitRunner;
import org.springframework.util.ReflectionUtils;

import java.io.File;
import java.lang.reflect.Field;
import java.util.Arrays;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.locks.Lock;

import static org.junit.Assert.*;
import static org.mockito.Mockito.*;

@RunWith(MockitoJUnitRunner.class)
public class SalGitUpgradeManagerTest {

    private static final String TASK_KEY = "test";

    private static Field upgradeThreadField;

    @Rule
    public TemporaryFolder folder = new TemporaryFolder();

    @Mock
    private AsynchronousUpgradeTask asynchronousUpgradeTask;
    @Mock
    private Lock lock;
    @Mock
    private LockService lockService;
    @Mock
    private PluginSettings pluginSettings;
    @Mock
    private PluginSettingsFactory pluginSettingsFactory;
    @Mock
    private ApplicationPropertiesService propertiesService;
    @Mock
    private Repository repository;
    @Mock
    private RepositoryService repositoryService;
    @Spy
    private SecurityService securityService = new DummySecurityService();
    @Mock
    private SynchronousUpgradeTask synchronousUpgradeTask;
    @Spy
    private TransactionTemplate transactionTemplate = new NoOpTransactionTemplate();
    private SalGitUpgradeManager upgradeManager;

    @BeforeClass
    public static void findUpgradeThreadField() {
        upgradeThreadField = ReflectionUtils.findField(SalGitUpgradeManager.class, "upgradeThread");
        if (upgradeThreadField == null) {
            throw new IllegalStateException("SalGitUpgradeManager.upgradeThread was not found");
        }
        ReflectionUtils.makeAccessible(upgradeThreadField);
    }

    @Before
    public void setup() {
        when(asynchronousUpgradeTask.getKey()).thenReturn(TASK_KEY);
        when(asynchronousUpgradeTask.getOrder()).thenReturn(1);
        when(lockService.getLock(eq(SalGitUpgradeManager.LOCK_NAME))).thenReturn(lock);
        when(pluginSettingsFactory.createGlobalSettings()).thenReturn(pluginSettings);
        when(propertiesService.getConfigDir()).thenReturn(folder.getRoot());
        when(repository.getScmId()).thenReturn(GitScm.ID);
        when(repositoryService.findAll(any(PageRequest.class))).thenAnswer(PageAnswer.withLastPageOf(repository));
        when(synchronousUpgradeTask.getOrder()).thenReturn(1);

        upgradeManager = new SalGitUpgradeManager(lockService, pluginSettingsFactory,
                propertiesService, repositoryService, securityService, transactionTemplate,
                Arrays.asList(asynchronousUpgradeTask, asynchronousUpgradeTask),
                Arrays.asList(synchronousUpgradeTask, synchronousUpgradeTask));
    }

    @Test
    public void testGetOrder() {
        assertEquals(1, upgradeManager.getOrder());
    }

    @Test
    public void testOnStart() throws Exception {
        upgradeManager.onStart();

        Thread upgradeThread = (Thread) ReflectionUtils.getField(upgradeThreadField, upgradeManager);
        assertNotNull("Asynchronous upgrades should have been kicked off", upgradeThread);
        upgradeThread.join();

        //This verifies that the synchronous task and asynchronous task are executed while holding the lock, and
        //that the lock is acquired and released for each type.
        //
        //Note that, for synchronous tasks, the transaction is started before acquiring the lock. It has to be this
        //way, because testing whether synchronous upgrades need to run requires accessing PluginSettings, which
        //requires a transaction to be open.
        InOrder ordered = inOrder(asynchronousUpgradeTask, lock, lockService, synchronousUpgradeTask, transactionTemplate);
        ordered.verify(transactionTemplate).execute(isA(TransactionCallback.class));
        ordered.verify(lockService).getLock(eq(SalGitUpgradeManager.LOCK_NAME));
        ordered.verify(lock).lock();
        ordered.verify(synchronousUpgradeTask, times(2)).upgrade(same(repository)); //Registered twice, like two tasks
        ordered.verify(lock).unlock();
        ordered.verify(lockService).getLock(eq(SalGitUpgradeManager.LOCK_NAME));
        ordered.verify(lock).lock();
        ordered.verify(transactionTemplate).execute(isA(TransactionCallback.class));
        ordered.verify(asynchronousUpgradeTask, times(2)).upgrade(same(repository)); //As above, registered twice
        ordered.verify(lock).unlock();
    }

    @Test
    public void testOnStartAvoidsLockingWhenAllTasksHaveAlreadyBeenCompleted() throws Exception {
        //This will make the synchronous task appear completed
        when(pluginSettings.get(eq(SalPlugin.PLUGIN_KEY + PluginUpgrader.BUILD))).thenReturn("1");
        //This will make the asynchronous task appear completed
        Files.touch(new File(FileUtils.mkdir(folder.getRoot(), SalGitUpgradeManager.UPGRADES_DIR),
                SalGitUpgradeManager.UPGRADE_PREFIX + TASK_KEY));

        upgradeManager.onStart();

        Thread upgradeThread = (Thread) ReflectionUtils.getField(upgradeThreadField, upgradeManager);
        assertNotNull("Asynchronous upgrades should have been kicked off", upgradeThread);
        upgradeThread.join();

        //Verify both upgrade tasks weren't applied, to be sure our setup caused them to be bypassed
        verify(asynchronousUpgradeTask, never()).upgrade(any(Repository.class));
        verify(synchronousUpgradeTask, never()).upgrade(any(Repository.class));
        verifyZeroInteractions(lock, lockService);
    }

    @Test
    public void testOnStartBypassesAsynchronousTasksIfSynchronousTasksFail() throws Exception {
        doThrow(RuntimeException.class).when(synchronousUpgradeTask).upgrade(any(Repository.class));

        upgradeManager.onStart();

        assertNull("Asynchronous upgrades should not be started when synchronous upgrades fail",
                ReflectionUtils.getField(upgradeThreadField, upgradeManager));

        InOrder ordered = inOrder(lock, lockService, synchronousUpgradeTask, transactionTemplate);
        ordered.verify(transactionTemplate).execute(isA(TransactionCallback.class));
        ordered.verify(lockService).getLock(eq(SalGitUpgradeManager.LOCK_NAME));
        ordered.verify(lock).lock();
        ordered.verify(synchronousUpgradeTask).upgrade(same(repository)); //First "task" fails, second "task" not run
        ordered.verify(lock).unlock();
        ordered.verifyNoMoreInteractions();
        verify(asynchronousUpgradeTask, atLeastOnce()).getOrder(); //Sorted during construction
        verifyNoMoreInteractions(asynchronousUpgradeTask);         //But never applied
    }

    @Test
    public void testOnStartReleasesLockIfAsynchronousTasksFail() throws Exception {
        doThrow(RuntimeException.class).when(asynchronousUpgradeTask).upgrade(any(Repository.class));

        upgradeManager.onStart();

        Thread upgradeThread = (Thread) ReflectionUtils.getField(upgradeThreadField, upgradeManager);
        assertNotNull("Asynchronous upgrades should have been kicked off", upgradeThread);
        upgradeThread.join();

        InOrder ordered = inOrder(asynchronousUpgradeTask, lock, lockService, synchronousUpgradeTask, transactionTemplate);
        ordered.verify(transactionTemplate).execute(isA(TransactionCallback.class));
        ordered.verify(lockService).getLock(eq(SalGitUpgradeManager.LOCK_NAME));
        ordered.verify(lock).lock();
        ordered.verify(synchronousUpgradeTask, times(2)).upgrade(same(repository));
        ordered.verify(lock).unlock();
        ordered.verify(lockService).getLock(eq(SalGitUpgradeManager.LOCK_NAME));
        ordered.verify(lock).lock();
        ordered.verify(transactionTemplate).execute(isA(TransactionCallback.class));
        ordered.verify(asynchronousUpgradeTask).upgrade(same(repository)); //First "task" fails, second "task" not run
        ordered.verify(lock).unlock();
        ordered.verifyNoMoreInteractions();
    }

    @Test
    public void testShutdown() {
        upgradeManager.onStop(); //Should be a no-op; upgradeThread is null
    }

    @Test
    public void testShutdownIgnoresUpgradeThreadIfNotAlive() throws Exception {
        //Simulate the asynchronous upgrade tasks running to completion, meaning the upgradeThread
        //will be set but no longer alive, before shutdown() is called
        Thread upgradeThread = sleeper();
        upgradeThread.interrupt(); //Wake the thread up and let it finish
        upgradeThread.join();

        ReflectionUtils.setField(upgradeThreadField, upgradeManager, upgradeThread);

        upgradeManager.onStop();
    }

    @Test
    public void testShutdownInterruptsUpgradeThreadAndHandlesInterruptedException() throws Exception {
        final CountDownLatch interruptLatch = new CountDownLatch(1);
        final CountDownLatch sleeperLatch = new CountDownLatch(1);

        Thread upgradeThread = new Thread("latched-sleeper") {

            @Override
            public void run() {
                while (true) {
                    try {
                        sleeperLatch.await();

                        break;
                    } catch (InterruptedException ignored) {
                        interruptLatch.countDown(); //Second call will be ignored
                    }
                }
            }
        };
        upgradeThread.setDaemon(true);
        upgradeThread.start();

        try {
            ReflectionUtils.setField(upgradeThreadField, upgradeManager, upgradeThread);

            final AtomicBoolean wasInterrupted = new AtomicBoolean(false);
            Thread interrupter = new Thread("interrupter") {
                @Override
                public void run() {
                    upgradeManager.onStop();

                    //Capture this state before this thread stops, because once the thread
                    //is no longer alive this state is lost
                    wasInterrupted.set(isInterrupted());
                }
            };
            interrupter.setDaemon(true);
            interrupter.start();

            interruptLatch.await(); //Wait for shutdown() to interrupt the sleeper the first time
            Thread.sleep(TimeUnit.SECONDS.toMillis(2L)); //Wait a bit to try and get into join()
            interrupter.interrupt();
            interrupter.join();

            //Verify the interrupter thread was interrupted and reasserted its interrupted state
            assertTrue(wasInterrupted.get());
        } finally {
            sleeperLatch.countDown();
            upgradeThread.join();
        }
    }

    @Test
    public void testShutdownInterruptsUpgradeThreadIfAlive() throws Exception {
        Thread upgradeThread = sleeper();

        ReflectionUtils.setField(upgradeThreadField, upgradeManager, upgradeThread);

        assertTrue(upgradeThread.isAlive());
        upgradeManager.onStop();
        assertFalse(upgradeThread.isAlive());
    }

    private static Thread sleeper() {
        Thread thread = new Thread("sleeper") {

            @Override
            public void run() {
                try {
                    sleep(TimeUnit.DAYS.toMillis(1L));
                } catch (InterruptedException ignored) {
                    //Time to be done...
                }
            }
        };
        thread.setDaemon(true);
        thread.start();

        return thread;
    }
}