package com.atlassian.stash.internal.repository.sync;

import com.atlassian.activeobjects.external.ActiveObjects;
import com.atlassian.bitbucket.AuthorisationException;
import com.atlassian.bitbucket.auth.AuthenticationContext;
import com.atlassian.bitbucket.event.repository.RepositoryDeletedEvent;
import com.atlassian.bitbucket.event.user.UserCleanupEvent;
import com.atlassian.bitbucket.i18n.I18nService;
import com.atlassian.bitbucket.i18n.SimpleI18nService;
import com.atlassian.bitbucket.permission.Permission;
import com.atlassian.bitbucket.permission.PermissionService;
import com.atlassian.bitbucket.project.PersonalProject;
import com.atlassian.bitbucket.project.Project;
import com.atlassian.bitbucket.project.ProjectType;
import com.atlassian.bitbucket.project.ProjectVisitor;
import com.atlassian.bitbucket.repository.*;
import com.atlassian.bitbucket.repository.ref.restriction.RefAccessRequest;
import com.atlassian.bitbucket.repository.ref.restriction.RefAccessType;
import com.atlassian.bitbucket.repository.ref.restriction.RefRestrictionService;
import com.atlassian.bitbucket.repository.sync.*;
import com.atlassian.bitbucket.scm.Command;
import com.atlassian.bitbucket.scm.git.GitScm;
import com.atlassian.bitbucket.user.ApplicationUser;
import com.atlassian.bitbucket.user.DummySecurityService;
import com.atlassian.bitbucket.user.SecurityService;
import com.atlassian.bitbucket.util.PageRequest;
import com.atlassian.bitbucket.util.PageUtils;
import com.atlassian.cache.CacheFactory;
import com.atlassian.cache.memory.MemoryCacheManager;
import com.atlassian.event.api.EventPublisher;
import com.atlassian.sal.api.transaction.TransactionCallback;
import com.atlassian.sal.api.transaction.TransactionTemplate;
import com.atlassian.stash.internal.repository.sync.command.*;
import com.atlassian.stash.internal.repository.sync.dao.RefSyncDao;
import com.atlassian.stash.internal.repository.sync.dao.RefSyncNotConfiguredException;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Iterables;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.*;
import org.mockito.invocation.InvocationOnMock;
import org.mockito.runners.MockitoJUnitRunner;
import org.mockito.stubbing.Answer;

import java.util.Arrays;
import java.util.Collections;
import java.util.Date;
import java.util.Map;

import static com.atlassian.bitbucket.hamcrest.RepositoryMatchers.resolveRefRequest;
import static org.junit.Assert.*;
import static org.mockito.Mockito.*;

@RunWith(MockitoJUnitRunner.class)
@SuppressWarnings({"ConstantConditions", "unchecked"})
public class DefaultRefSyncServiceTest {

    private static final String MASTER = "refs/heads/master";
    private static final String MASTER_DISPLAY = "master";
    private static final Ref MASTER_REF = mockRef(MASTER, MASTER_DISPLAY);

    @Mock
    private ActiveObjects ao;
    @Mock
    private AuthenticationContext authenticationContext;
    @Mock
    private RefSyncCommandFactory commandFactory;
    @Mock
    private RefSyncConfig config;
    @Mock
    private RefSyncDao dao;
    @Mock
    private EventPublisher eventPublisher;
    @Spy
    private I18nService i18nService = new SimpleI18nService();
    @Mock
    private PermissionService permissionService;
    @Mock
    private RefRestrictionService refRestrictionService;
    @Mock
    private RefService refService;
    @Mock(answer = Answers.RETURNS_MOCKS)
    private Repository repository;
    @Mock
    private RepositoryService repositoryService;
    @Spy
    private SecurityService securityService = new DummySecurityService();
    @InjectMocks
    private DefaultRefSyncService service;
    @Mock
    private TransactionTemplate transactionTemplate;
    @Spy
    private CacheFactory cacheFactory = new MemoryCacheManager();

    @Before
    public void setup() throws Exception {
        setupTransactionTemplate();

        // All refs are restricted...
        when(refRestrictionService.hasPermission(any(RefAccessRequest.class)))
                .thenReturn(false);

        // ... except MASTER
        when(refRestrictionService.hasPermission(argThat(masterRefMatcher())))
                .thenReturn(true);

        when(config.isPossible()).thenReturn(true);
        when(repository.getId()).thenReturn(42);
        when(refService.resolveRef(argThat(resolveRefRequest(repository, MASTER)))).thenReturn(MASTER_REF);
    }

    @Test
    public void testGetStatus() {
        RejectedRef ahead = rejectedRef(RejectedRefState.AHEAD);
        RejectedRef diverged = rejectedRef(RejectedRefState.DIVERGED);
        RejectedRef orphaned = rejectedRef(RejectedRefState.ORPHANED);

        Date lastSync = new Date();
        RefSyncStatus status = new SimpleRefSyncStatus.Builder()
                .enabled(true)
                .lastSync(lastSync)
                .rejectedRef(ahead)
                .rejectedRef(diverged)
                .rejectedRef(orphaned)
                .build();

        when(dao.getStatus(eq(repository.getId()))).thenReturn(status);

        DefaultRefSyncService spy = spy(service);
        doReturn(true).when(spy).isAvailable(same(repository));

        RefSyncStatus actual = spy.getStatus(repository);
        assertNotNull(actual);
        assertNotSame(status, actual); //Should have been passed through a second builder
        assertTrue(actual.isAvailable());
        assertTrue(actual.isEnabled());
        assertEquals(lastSync, actual.getLastSync());
        assertSame(ahead, Iterables.getOnlyElement(actual.getAheadRefs()));
        assertSame(diverged, Iterables.getOnlyElement(actual.getDivergedRefs()));
        assertSame(orphaned, Iterables.getOnlyElement(actual.getOrphanedRefs()));

        verify(dao).getStatus(eq(repository.getId()));
        verify(spy).isAvailable(same(repository));
        verify(transactionTemplate).execute(isNotNull(TransactionCallback.class));
    }

    @Test
    public void testGetStatusHandlesNullReturnFromDao() {
        assertNull(service.getStatus(repository));

        verify(dao).getStatus(eq(repository.getId()));
        verify(transactionTemplate).execute(isNotNull(TransactionCallback.class));
    }

    @Test
    public void testGetStatusForRef() {
        RejectedRef ref = mock(RejectedRef.class);

        when(dao.getRef(eq(repository.getId()), eq(MASTER))).thenReturn(ref);

        assertSame(ref, service.getStatus(repository, MASTER));

        verify(transactionTemplate).execute(isA(TransactionCallback.class));
    }

    @Test(expected = IllegalArgumentException.class)
    public void testGetStatusForRefWithBlankRefId() {
        service.getStatus(mock(Repository.class), "\n\t    ");
    }

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

    @Test(expected = NullPointerException.class)
    public void testGetStatusForRefWithNullRepository() {
        service.getStatus(null, MASTER);
    }

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

    @Test
    public void testIsAvailableForMercurialRepository() {
        when(repository.getScmId()).thenReturn("hg");

        assertFalse(service.isAvailable(repository));

        verify(config).isPossible();
        verify(repository).getScmId();
        verifyNoMoreInteractions(repository);
        verifyZeroInteractions(permissionService); //Should not check permissions for non-git repositories
    }

    @Test
    public void testIsAvailableForNonFork() {
        when(repository.getOrigin()).thenReturn(null); //Override default answer to return a mock and return null
        when(repository.getScmId()).thenReturn(GitScm.ID);

        assertFalse(service.isAvailable(repository));

        verify(config).isPossible();
        verify(repository).getOrigin();
        verify(repository).getScmId();
        verifyNoMoreInteractions(repository);
        verifyZeroInteractions(permissionService); //Should not check permissions for non-forks
    }

    @Test
    public void testIsAvailableForNormalFork() {
        Project project = mock(Project.class);
        when(project.getType()).thenReturn(ProjectType.NORMAL);

        when(repository.getOrigin()).thenReturn(repository); //Because we can
        when(repository.getProject()).thenReturn(project);
        when(repository.getScmId()).thenReturn(GitScm.ID);

        assertTrue(service.isAvailable(repository));

        verify(config).isPossible();
        verifyZeroInteractions(permissionService); //Should not check permissions for non-personal-forks
    }

    @Test
    public void testIsAvailableForPersonalFork() {
        ApplicationUser owner = mock(ApplicationUser.class);

        PersonalProject project = mock(PersonalProject.class);
        when(project.accept(isNotNull(ProjectVisitor.class))).thenAnswer(callVisitor());
        when(project.getOwner()).thenReturn(owner);
        when(project.getType()).thenReturn(ProjectType.PERSONAL);

        Repository origin = mock(Repository.class);

        when(permissionService.hasRepositoryPermission(same(owner), same(origin), same(Permission.REPO_READ)))
                .thenReturn(true, false);
        when(repository.getOrigin()).thenReturn(origin);
        when(repository.getProject()).thenReturn(project);
        when(repository.getScmId()).thenReturn(GitScm.ID);

        assertTrue(service.isAvailable(repository));  //Permission granted on the first call
        assertFalse(service.isAvailable(repository)); //No permission on the second call

        verify(config, times(2)).isPossible();
        verify(permissionService, times(2)).hasRepositoryPermission(same(owner), same(origin), same(Permission.REPO_READ));
    }

    @Test
    public void testIsAvailableWhenNotPossible() {
        reset(config);
        when(config.isPossible()).thenReturn(false);

        assertFalse(service.isAvailable(mock(Repository.class)));

        verify(config).isPossible();
        verifyZeroInteractions(permissionService);
    }

    @Test(expected = NullPointerException.class)
    public void testIsAvailableWithNullRepository() {
        try {
            service.isAvailable(null);
        } finally {
            verifyZeroInteractions(config);
            verifyZeroInteractions(permissionService);
        }
    }

    @Test
    public void testIsEnabled() {
        Repository enabled = mock(Repository.class);
        when(enabled.getId()).thenReturn(1);
        Repository disabled = mock(Repository.class);
        when(disabled.getId()).thenReturn(2);

        when(dao.isEnabled(eq(enabled.getId()))).thenReturn(true);
        when(dao.isEnabled(eq(disabled.getId()))).thenReturn(false);

        assertTrue(service.isEnabled(enabled));
        assertTrue(service.isEnabled(enabled));
        assertFalse(service.isEnabled(disabled));
        assertFalse(service.isEnabled(disabled));

        //With caching, isEnabled should only have been checked once per repository
        verify(dao).isEnabled(eq(disabled.getId()));
        verify(dao).isEnabled(eq(enabled.getId()));
        //Each call to the DAO should have been in a transaction
        verify(transactionTemplate, times(2)).execute(isA(TransactionCallback.class));
    }

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

    @Test
    public void testOnRepositoryDeleted() {
        service.onRepositoryDeleted(new RepositoryDeletedEvent(this, repository));

        verify(transactionTemplate).execute(isNotNull(TransactionCallback.class));
        verify(dao).cleanup(eq(repository.getId()));
    }

    @Test
    public void testOnUserCleanup() {
        ApplicationUser user = mock(ApplicationUser.class);

        Repository forkA = mock(Repository.class);
        when(forkA.getId()).thenReturn(1);
        when(forkA.isFork()).thenReturn(true);
        Repository forkB = mock(Repository.class);
        when(forkB.getId()).thenReturn(2);
        when(forkB.isFork()).thenReturn(true);
        Repository nonFork = mock(Repository.class);

        when(repositoryService.findByOwner(same(user), isA(PageRequest.class)))
                .thenReturn(PageUtils.createPage(Arrays.asList(forkA, nonFork, forkB), PageUtils.newRequest(0, 25)));

        service.onUserCleanup(new UserCleanupEvent(this, user));

        //Ordered verification ensures that:
        //1. Synchronization is disabled in a transaction
        //2. SecurityService is used to ensure all repositories are retrieved
        //3. Repositories are retrieved by owner
        //4. Synchronization is only disabled for forks
        InOrder ordered = inOrder(dao, repositoryService, securityService, transactionTemplate);
        ordered.verify(transactionTemplate).execute(isA(TransactionCallback.class));
        ordered.verify(securityService).withPermission(same(Permission.REPO_READ), isA(String.class));
        ordered.verify(repositoryService).findByOwner(same(user), isA(PageRequest.class));
        ordered.verify(dao).setEnabled(eq(1), eq(false));
        ordered.verify(dao).setEnabled(eq(2), eq(false));

        verify(forkA).isFork();
        verify(forkB).isFork();
        verify(nonFork).isFork();
    }

    @Test
    public void testSetDisabled() throws Exception {
        when(permissionService.hasRepositoryPermission(same(repository), same(Permission.REPO_ADMIN))).thenReturn(true);

        DefaultRefSyncService spy = spy(service);

        spy.setEnabled(repository, false);
        verify(spy, never()).isAvailable(any(Repository.class)); //Disabling should not check availability

        InOrder ordered = inOrder(dao, transactionTemplate);
        ordered.verify(transactionTemplate).execute(isNotNull(TransactionCallback.class));
        ordered.verify(dao).setEnabled(eq(repository.getId()), eq(false));
        verifyZeroInteractions(commandFactory); //Should not trigger synchronization

        reset(dao);
        reset(transactionTemplate);

        when(dao.isEnabled(eq(repository.getId()))).thenReturn(false);
        setupTransactionTemplate();

        //When setting the flag, the existing cache entry should be removed and the next isEnabled
        //check should load it from the database
        assertFalse(spy.isEnabled(repository));
        assertFalse(spy.isEnabled(repository)); //Called multiple times to verify caching after first call
        assertFalse(spy.isEnabled(repository));
        verify(dao).isEnabled(eq(repository.getId()));
        verify(transactionTemplate).execute(isA(TransactionCallback.class));
    }

    @Test(expected = AuthorisationException.class)
    public void testSetDisabledByNonAdmin() {
        runSetEnabledPermissionTest(false);
    }

    @Test
    public void testSetEnabled() throws Exception {
        Repository origin = mock(Repository.class);
        ApplicationUser user = mock(ApplicationUser.class);

        //Short-circuit all the updates associated with the full synchronization. It will be tested by a different
        //method to try and keep this one from getting any more complicated
        RefSyncResult result = mock(RefSyncResult.class);
        when(result.isEmpty()).thenReturn(true);

        Command<RefSyncResult> command = mock(Command.class);
        when(command.call()).thenReturn(result);

        when(authenticationContext.getCurrentUser()).thenReturn(user);
        when(commandFactory.synchronizeRefs(isA(SynchronizeRefsCommandParameters.class))).thenReturn(command);
        when(permissionService.hasRepositoryPermission(same(repository), same(Permission.REPO_ADMIN))).thenReturn(true);
        when(repository.getOrigin()).thenReturn(origin);

        DefaultRefSyncService spy = spy(service);
        doReturn(true).when(spy).isAvailable(repository); //Stub out the availability check, rather than mocking for it

        spy.setEnabled(repository, true);

        verify(spy).isAvailable(same(repository)); //Ensure it did check availability

        ArgumentCaptor<SynchronizeRefsCommandParameters> parametersCaptor = ArgumentCaptor.forClass(SynchronizeRefsCommandParameters.class);

        //Ordered verification ensures that:
        //1. Synchronization is enabled in a transaction
        //2. An initial full synchronization is performed after acquiring the lock
        InOrder ordered = inOrder(dao, commandFactory, transactionTemplate);
        ordered.verify(transactionTemplate).execute(isNotNull(TransactionCallback.class));
        ordered.verify(dao).setEnabled(eq(repository.getId()), eq(true));
        ordered.verify(commandFactory).synchronizeRefs(parametersCaptor.capture());
        verify(authenticationContext).getCurrentUser();
        verify(permissionService).hasRepositoryPermission(same(repository), same(Permission.REPO_ADMIN));

        SynchronizeRefsCommandParameters parameters = parametersCaptor.getValue();
        assertNotNull(parameters);
        assertSame(repository, parameters.getRepository());
        assertSame(origin, parameters.getUpstream());
        assertSame(user, parameters.getUser());
        assertTrue(parameters.isFull());
        assertTrue(Iterables.isEmpty(parameters));

        reset(dao);
        reset(transactionTemplate);

        when(dao.isEnabled(eq(repository.getId()))).thenReturn(true);
        setupTransactionTemplate();

        //When setting the flag, the existing cache entry should be removed and the next isEnabled
        //check should load it from the database
        assertTrue(spy.isEnabled(repository));
        assertTrue(spy.isEnabled(repository)); //Called multiple times to verify caching after first call
        assertTrue(spy.isEnabled(repository));
        verify(dao).isEnabled(eq(repository.getId()));
        verify(transactionTemplate).execute(isA(TransactionCallback.class));
    }

    @Test(expected = AuthorisationException.class)
    public void testSetEnabledByNonAdmin() {
        runSetEnabledPermissionTest(true);
    }

    @Test(expected = RefSyncNotAvailableException.class)
    public void testSetEnabledForForkWhenNotAvailable() {
        ApplicationUser owner = mock(ApplicationUser.class);

        PersonalProject project = mock(PersonalProject.class);
        when(project.accept(isA(ProjectVisitor.class))).thenAnswer(callVisitor());
        when(project.getKey()).thenReturn("KEY");
        when(project.getOwner()).thenReturn(owner);

        when(permissionService.hasRepositoryPermission(same(repository), same(Permission.REPO_ADMIN))).thenReturn(true);
        when(repository.getOrigin()).thenReturn(repository); //Because we can
        when(repository.getProject()).thenReturn(project);
        when(repository.getSlug()).thenReturn("slug");
        when(repository.isFork()).thenReturn(true);

        DefaultRefSyncService spy = spy(service);
        doReturn(false).when(spy).isAvailable(same(repository));
        try {
            spy.setEnabled(repository, true);
        } finally {
            verify(i18nService).createKeyedMessage(eq("bitbucket.service.repository.refsync.notavailable.detail"), anyVararg());
            verify(permissionService).hasRepositoryPermission(same(repository), same(Permission.REPO_ADMIN));
            verify(repository).isFork(); //Verify that it did check whether the repository was a fork
            verify(spy).isAvailable(same(repository));
            verifyZeroInteractions(dao);
            verifyZeroInteractions(commandFactory);
            verifyZeroInteractions(transactionTemplate);
        }
    }

    @Test(expected = RefSyncNotAvailableException.class)
    public void testSetEnabledForNonForkWhenNotAvailable() {
        Project project = mock(Project.class);
        when(project.getKey()).thenReturn("KEY");

        when(permissionService.hasRepositoryPermission(same(repository), same(Permission.REPO_ADMIN))).thenReturn(true);
        when(repository.getProject()).thenReturn(project);

        DefaultRefSyncService spy = spy(service);
        doReturn(false).when(spy).isAvailable(same(repository));
        try {
            spy.setEnabled(repository, true);
        } finally {
            verify(i18nService).createKeyedMessage(eq("bitbucket.service.repository.refsync.notafork"), anyVararg());
            verify(permissionService).hasRepositoryPermission(same(repository), same(Permission.REPO_ADMIN));
            verify(repository).isFork(); //Verify that it did check whether the repository was a fork
            verify(spy).isAvailable(same(repository));
            verifyZeroInteractions(dao);
            verifyZeroInteractions(commandFactory);
            verifyZeroInteractions(transactionTemplate);
        }
    }

    @Test(expected = NullPointerException.class)
    public void testSetEnabledWithNullRepository() {
        service.setEnabled(null, true);
    }

    @Test
    public void testSynchronize() throws Throwable {
        RefChange changedRef = mock(RefChange.class);
        RejectedRef rejectedRef = mock(RejectedRef.class);
        RefChange upstreamRef = mock(RefChange.class);
        Repository upstream = mock(Repository.class);

        ApplicationUser user = mock(ApplicationUser.class);
        when(user.getName()).thenReturn("jdoe");

        RefSyncResult result = mock(RefSyncResult.class);
        when(result.getChangedRefs()).thenReturn(Collections.singleton(changedRef));
        when(result.getRejectedRefs()).thenReturn(Collections.singleton(rejectedRef));

        Command<RefSyncResult> command = mock(Command.class);
        when(command.call()).thenReturn(result);

        when(commandFactory.synchronizeRefs(isA(SynchronizeRefsCommandParameters.class))).thenReturn(command);

        service.synchronize(bulkRequestFor(repository, upstream, user, upstreamRef));

        ArgumentCaptor<RefsSynchronizedEvent> eventCaptor = ArgumentCaptor.forClass(RefsSynchronizedEvent.class);
        ArgumentCaptor<SynchronizeRefsCommandParameters> parametersCaptor = ArgumentCaptor.forClass(SynchronizeRefsCommandParameters.class);

        //Ordered verification ensures that:
        //1. Event publishing happens before updating the database
        //2. Updating the database happens in a transaction
        InOrder ordered = inOrder(eventPublisher, dao, commandFactory, transactionTemplate);
        ordered.verify(commandFactory).synchronizeRefs(parametersCaptor.capture());
        ordered.verify(eventPublisher).publish(eventCaptor.capture());
        ordered.verify(transactionTemplate).execute(isNotNull(TransactionCallback.class));
        ordered.verify(dao).setStatus(eq(repository.getId()), same(result));

        SynchronizeRefsCommandParameters parameters = parametersCaptor.getValue();
        assertNotNull(parameters);
        assertSame(repository, parameters.getRepository());
        assertSame(upstream, parameters.getUpstream());
        assertSame(upstreamRef, Iterables.getOnlyElement(parameters));
        assertSame(user, parameters.getUser());
        assertFalse(parameters.isFull());

        RefsSynchronizedEvent event = eventCaptor.getValue();
        assertNotNull(event);
        assertSame(repository, event.getRepository());
        assertSame(service, event.getSource());
        assertSame(upstream, event.getUpstream());
        assertSame(changedRef, Iterables.getOnlyElement(event.getRefChanges()));
        assertSame(rejectedRef, Iterables.getOnlyElement(event.getRejectedRefs()));
        assertSame(upstreamRef, Iterables.getOnlyElement(event.getUpstreamChanges()));
    }

    @Test(expected = AuthorisationException.class)
    public void testSynchronizeForRefForAnonymous() {
        try {
            service.synchronize(mock(RefSyncRequest.class));
        } finally {
            verify(authenticationContext).getCurrentUser();
            verifyZeroInteractions(permissionService);
            verifyZeroInteractions(dao);
            verifyZeroInteractions(commandFactory);
            verifyZeroInteractions(transactionTemplate);
        }
    }

    @Test(expected = AuthorisationException.class)
    public void testSynchronizeForRefForNonWriter() {
        ApplicationUser user = mock(ApplicationUser.class);

        when(authenticationContext.getCurrentUser()).thenReturn(user);

        try {
            service.synchronize(requestFor(repository, RefSyncAction.DISCARD));
        } finally {
            verify(authenticationContext).getCurrentUser();
            verify(i18nService).getText(eq("bitbucket.service.repository.refsync.readonly"), anyString(), anyVararg());
            verify(permissionService).hasRepositoryPermission(same(user), same(repository), same(Permission.REPO_WRITE));
            verifyZeroInteractions(dao);
            verifyZeroInteractions(commandFactory);
            verifyZeroInteractions(transactionTemplate);
        }
    }

    @Test(expected = RefAlreadySynchronizedException.class)
    public void testSynchronizeForRefWhenAlreadySynchronized() {
        ApplicationUser user = mock(ApplicationUser.class);

        when(authenticationContext.getCurrentUser()).thenReturn(user);
        when(permissionService.hasRepositoryPermission(same(user), same(repository), same(Permission.REPO_WRITE)))
                .thenReturn(true);

        DefaultRefSyncService spy = spy(service);
        doReturn(null).when(spy).getStatus(same(repository), eq(MASTER));
        doReturn(true).when(spy).isAvailable(same(repository));
        doReturn(true).when(spy).isEnabled(same(repository));
        try {
            spy.synchronize(requestFor(repository, RefSyncAction.DISCARD));
        } finally {
            verify(authenticationContext).getCurrentUser();
            verify(i18nService).getText(eq("bitbucket.service.repository.refsync.alreadyinsync"), anyString(), anyVararg());
            verify(permissionService).hasRepositoryPermission(same(user), same(repository), same(Permission.REPO_WRITE));
            verify(spy).getStatus(same(repository), eq(MASTER));
            verify(spy).isAvailable(same(repository));
            verify(spy).isEnabled(same(repository));
            verifyZeroInteractions(dao);
            verifyZeroInteractions(commandFactory);
            verifyZeroInteractions(transactionTemplate);
        }
    }

    @Test(expected = AuthorisationException.class)
    public void testSynchronizeForRefWhenNotAuthorizedToUpdateRef() {
        ApplicationUser user = mock(ApplicationUser.class);

        when(authenticationContext.getCurrentUser()).thenReturn(user);
        when(refRestrictionService.hasPermission(argThat(masterRefMatcher()))).thenReturn(false);
        when(permissionService.hasRepositoryPermission(same(user), same(repository), same(Permission.REPO_WRITE)))
                .thenReturn(true);

        try {
            service.synchronize(requestFor(repository, RefSyncAction.DISCARD));
        } finally {
            verify(authenticationContext).getCurrentUser();
            verify(i18nService).getText(eq("bitbucket.service.repository.refsync.unauthorizedref"), anyString(), anyVararg());
            verify(permissionService).hasRepositoryPermission(same(user), same(repository), same(Permission.REPO_WRITE));
            verifyZeroInteractions(dao);
            verifyZeroInteractions(commandFactory);
            verifyZeroInteractions(transactionTemplate);
        }
    }

    @Test(expected = RefSyncNotAvailableException.class)
    public void testSynchronizeForRefWhenNotAvailable() {
        ApplicationUser user = mock(ApplicationUser.class);

        when(authenticationContext.getCurrentUser()).thenReturn(user);
        when(permissionService.hasRepositoryPermission(same(user), same(repository), same(Permission.REPO_WRITE)))
                .thenReturn(true);

        DefaultRefSyncService spy = spy(service);
        doReturn(false).when(spy).isAvailable(same(repository));
        doReturn(true).when(spy).isEnabled(same(repository));
        try {
            spy.synchronize(requestFor(repository, RefSyncAction.DISCARD));
        } finally {
            verify(authenticationContext).getCurrentUser();
            verify(i18nService).getText(eq("bitbucket.service.repository.refsync.notavailable"), anyString(), anyVararg());
            verify(permissionService).hasRepositoryPermission(same(user), same(repository), same(Permission.REPO_WRITE));
            verify(spy).isEnabled(same(repository));
            verify(spy).isAvailable(any(Repository.class));
            verifyZeroInteractions(dao);
            verifyZeroInteractions(commandFactory);
            verifyZeroInteractions(transactionTemplate);
        }
    }

    @Test(expected = RefSyncNotEnabledException.class)
    public void testSynchronizeForRefWhenNotEnabled() {
        ApplicationUser user = mock(ApplicationUser.class);

        when(authenticationContext.getCurrentUser()).thenReturn(user);
        when(permissionService.hasRepositoryPermission(same(user), same(repository), same(Permission.REPO_WRITE)))
                .thenReturn(true);

        DefaultRefSyncService spy = spy(service);
        doReturn(false).when(spy).isEnabled(same(repository));
        try {
            spy.synchronize(requestFor(repository, RefSyncAction.DISCARD));
        } finally {
            verify(authenticationContext).getCurrentUser();
            verify(i18nService).getText(eq("bitbucket.service.repository.refsync.notenabled"), anyString(), anyVararg());
            verify(permissionService).hasRepositoryPermission(same(user), same(repository), same(Permission.REPO_WRITE));
            verify(spy).isEnabled(same(repository));
            verify(spy, never()).isAvailable(any(Repository.class));
            verifyZeroInteractions(dao);
            verifyZeroInteractions(commandFactory);
            verifyZeroInteractions(transactionTemplate);
        }
    }

    @Test
    public void testSynchronizeForRefWithDiscard() throws Throwable {
        RefChange change = mock(RefChange.class);
        Repository origin = mock(Repository.class);

        Command<RefChange> command = mock(Command.class);
        when(command.call()).thenReturn(change);

        RejectedRef status = mock(RejectedRef.class);
        when(status.getState()).thenReturn(RejectedRefState.DIVERGED);

        ApplicationUser user = mock(ApplicationUser.class);
        when(user.getName()).thenReturn("jdoe");

        when(authenticationContext.getCurrentUser()).thenReturn(user);
        when(commandFactory.discardRef(isNotNull(DiscardRefCommandParameters.class))).thenReturn(command);
        when(permissionService.hasRepositoryPermission(same(user), same(repository), same(Permission.REPO_WRITE)))
                .thenReturn(true);
        when(repository.getOrigin()).thenReturn(origin);

        DefaultRefSyncService spy = spy(service);
        doReturn(status).when(spy).getStatus(same(repository), eq(MASTER));
        doReturn(true).when(spy).isAvailable(same(repository));
        doReturn(true).when(spy).isEnabled(same(repository));

        spy.synchronize(requestFor(repository, RefSyncAction.DISCARD));

        ArgumentCaptor<DiscardRefCommandParameters> parametersCaptor = ArgumentCaptor.forClass(DiscardRefCommandParameters.class);

        verify(authenticationContext).getCurrentUser();
        verify(eventPublisher).publish(isA(RefsSynchronizedEvent.class));
        verify(permissionService).hasRepositoryPermission(same(user), same(repository), same(Permission.REPO_WRITE));
        verify(dao).removeRef(eq(repository.getId()), eq(MASTER));
        verify(commandFactory).discardRef(parametersCaptor.capture());
        verify(spy).getStatus(same(repository), eq(MASTER));
        verify(spy).isAvailable(same(repository));
        verify(spy).isEnabled(same(repository));
        verify(transactionTemplate).execute(isNotNull(TransactionCallback.class));
        verifyZeroInteractions(i18nService);

        DiscardRefCommandParameters parameters = parametersCaptor.getValue();
        assertNotNull(parameters);
        assertEquals(MASTER, parameters.getRefId());
        assertSame(repository, parameters.getRepository());
        assertSame(origin, parameters.getUpstream());
        assertSame(user, parameters.getUser());
    }

    @Test
    public void testSynchronizeForRefWithMerge() throws Throwable {
        RefChange change = mock(RefChange.class);
        Repository origin = mock(Repository.class);

        Command<RefChange> command = mock(Command.class);
        when(command.call()).thenReturn(change);

        RejectedRef status = mock(RejectedRef.class);
        when(status.getState()).thenReturn(RejectedRefState.DIVERGED);

        ApplicationUser user = mock(ApplicationUser.class);
        when(user.getName()).thenReturn("jdoe");

        when(authenticationContext.getCurrentUser()).thenReturn(user);
        when(commandFactory.mergeRef(isNotNull(MergeRefCommandParameters.class))).thenReturn(command);
        when(permissionService.hasRepositoryPermission(same(user), same(repository), same(Permission.REPO_WRITE)))
                .thenReturn(true);
        when(repository.getOrigin()).thenReturn(origin);

        DefaultRefSyncService spy = spy(service);
        doReturn(status).when(spy).getStatus(same(repository), eq(MASTER));
        doReturn(true).when(spy).isAvailable(same(repository));
        doReturn(true).when(spy).isEnabled(same(repository));

        spy.synchronize(requestFor(repository, RefSyncAction.MERGE, ImmutableMap.of("commitMessage", "Test")));

        ArgumentCaptor<MergeRefCommandParameters> parametersCaptor = ArgumentCaptor.forClass(MergeRefCommandParameters.class);

        verify(authenticationContext).getCurrentUser();
        verify(eventPublisher).publish(isA(RefsSynchronizedEvent.class));
        verify(permissionService).hasRepositoryPermission(same(user), same(repository), same(Permission.REPO_WRITE));
        verify(dao).updateRef(eq(repository.getId()), eq(MASTER), same(RejectedRefState.AHEAD));
        verify(commandFactory).mergeRef(parametersCaptor.capture());
        verify(spy).getStatus(same(repository), eq(MASTER));
        verify(spy).isAvailable(same(repository));
        verify(spy).isEnabled(same(repository));
        verify(transactionTemplate).execute(isNotNull(TransactionCallback.class));
        verifyZeroInteractions(i18nService);

        MergeRefCommandParameters parameters = parametersCaptor.getValue();
        assertNotNull(parameters);
        assertEquals("Test", parameters.getMessage());
        assertEquals(MASTER, parameters.getRefId());
        assertSame(repository, parameters.getRepository());
        assertSame(origin, parameters.getUpstream());
        assertSame(user, parameters.getUser());
    }

    /**
     * 2.6 was shipped with a bug caused by a last minute change from {@code (String) context.get("commitMessage")} to
     * {@code String.valueOf(context.get("commitMessage"))}, which results in "null" when no message is provided. This
     * test ensures that doesn't happen again.
     */
    @Test
    public void testSynchronizeForRefWithMergeDefaultsMessage() throws Throwable {
        RefChange change = mock(RefChange.class);

        Command<RefChange> command = mock(Command.class);
        when(command.call()).thenReturn(change);

        Project project = mock(Project.class);
        when(project.getKey()).thenReturn("ORIGIN");

        Repository origin = mock(Repository.class);
        when(origin.getProject()).thenReturn(project);
        when(origin.getSlug()).thenReturn("origin");

        RejectedRef status = mock(RejectedRef.class);
        when(status.getState()).thenReturn(RejectedRefState.DIVERGED);

        ApplicationUser user = mock(ApplicationUser.class);
        when(user.getName()).thenReturn("jdoe");

        when(authenticationContext.getCurrentUser()).thenReturn(user);
        when(commandFactory.mergeRef(isNotNull(MergeRefCommandParameters.class))).thenReturn(command);
        when(permissionService.hasRepositoryPermission(same(user), same(repository), same(Permission.REPO_WRITE)))
                .thenReturn(true);
        when(repository.getOrigin()).thenReturn(origin);

        DefaultRefSyncService spy = spy(service);
        doReturn(status).when(spy).getStatus(same(repository), eq(MASTER));
        doReturn(true).when(spy).isAvailable(same(repository));
        doReturn(true).when(spy).isEnabled(same(repository));

        spy.synchronize(requestFor(repository, RefSyncAction.MERGE, Collections.emptyMap()));

        ArgumentCaptor<MergeRefCommandParameters> parametersCaptor = ArgumentCaptor.forClass(MergeRefCommandParameters.class);

        verify(authenticationContext).getCurrentUser();
        verify(eventPublisher).publish(isA(RefsSynchronizedEvent.class));
        verify(permissionService).hasRepositoryPermission(same(user), same(repository), same(Permission.REPO_WRITE));
        verify(dao).updateRef(eq(repository.getId()), eq(MASTER), same(RejectedRefState.AHEAD));
        verify(commandFactory).mergeRef(parametersCaptor.capture());
        verify(spy).getStatus(same(repository), eq(MASTER));
        verify(spy).isAvailable(same(repository));
        verify(spy).isEnabled(same(repository));
        verify(transactionTemplate).execute(isNotNull(TransactionCallback.class));
        verifyZeroInteractions(i18nService);

        MergeRefCommandParameters parameters = parametersCaptor.getValue();
        assertNotNull(parameters);
        assertEquals("Merging in latest from upstream (ORIGIN/origin:refs/heads/master)", parameters.getMessage());
        assertEquals(MASTER, parameters.getRefId());
        assertSame(repository, parameters.getRepository());
        assertSame(origin, parameters.getUpstream());
        assertSame(user, parameters.getUser());
    }

    @Test(expected = RefAlreadySynchronizedException.class)
    public void testSynchronizeForRefWithMergeWhenNotDiverged() {
        ApplicationUser user = mock(ApplicationUser.class);

        RejectedRef status = mock(RejectedRef.class);
        when(status.getState()).thenReturn(RejectedRefState.AHEAD);

        when(authenticationContext.getCurrentUser()).thenReturn(user);
        when(permissionService.hasRepositoryPermission(same(user), same(repository), same(Permission.REPO_WRITE)))
                .thenReturn(true);

        DefaultRefSyncService spy = spy(service);
        doReturn(status).when(spy).getStatus(same(repository), eq(MASTER));
        doReturn(true).when(spy).isAvailable(same(repository));
        doReturn(true).when(spy).isEnabled(same(repository));
        try {
            spy.synchronize(requestFor(repository, RefSyncAction.MERGE));
        } finally {
            verify(authenticationContext).getCurrentUser();
            verify(i18nService).getText(eq("bitbucket.service.repository.refsync.nothingtomerge"), anyString(), anyVararg());
            verify(permissionService).hasRepositoryPermission(same(user), same(repository), same(Permission.REPO_WRITE));
            verify(spy).getStatus(same(repository), eq(MASTER));
            verify(spy).isAvailable(same(repository));
            verify(spy).isEnabled(same(repository));
            verifyZeroInteractions(dao);
            verifyZeroInteractions(commandFactory);
            verifyZeroInteractions(transactionTemplate);
        }
    }

    @Test(expected = NullPointerException.class)
    public void testSynchronizeForRefWithNullRequest() {
        service.synchronize((RefSyncRequest) null);
    }

    @Test(expected = NullPointerException.class)
    public void testSynchronizeWithNullRequest() {
        service.synchronize((BulkRefSyncRequest) null);
    }

    @Test
    public void testUpdateStatus() {
        RefSyncResult result = mock(RefSyncResult.class);
        Repository upstream = mock(Repository.class);
        ApplicationUser user = mock(ApplicationUser.class);

        Command<RefSyncResult> command = mock(Command.class);
        when(command.call()).thenReturn(result);

        when(commandFactory.reviewRefs(isNotNull(ReviewRefsCommandParameters.class))).thenReturn(command);

        service.updateStatus(bulkRequestFor(repository, upstream, user));

        ArgumentCaptor<ReviewRefsCommandParameters> parametersCaptor = ArgumentCaptor.forClass(ReviewRefsCommandParameters.class);

        verify(dao).setStatus(eq(repository.getId()), same(result));
        verify(commandFactory).reviewRefs(parametersCaptor.capture());
        verify(result).isEmpty(); //Ensure it did the empty check on the result
        verify(transactionTemplate).execute(isNotNull(TransactionCallback.class));

        ReviewRefsCommandParameters parameters = parametersCaptor.getValue();
        assertNotNull(parameters);
        assertSame(repository, parameters.getRepository());
        assertSame(upstream, parameters.getUpstream());
        assertSame(user, parameters.getUser());
        assertTrue(Iterables.isEmpty(parameters));
    }

    @Test(expected = IllegalStateException.class)
    public void testUpdateStatusHandlesRefSyncNotConfiguredException() {
        RefSyncResult result = mock(RefSyncResult.class);
        Repository upstream = mock(Repository.class);
        ApplicationUser user = mock(ApplicationUser.class);

        Command<RefSyncResult> command = mock(Command.class);
        when(command.call()).thenReturn(result);

        when(commandFactory.reviewRefs(isNotNull(ReviewRefsCommandParameters.class))).thenReturn(command);
        doThrow(RefSyncNotConfiguredException.class).when(dao).setStatus(eq(42), same(result));

        service.updateStatus(bulkRequestFor(repository, upstream, user));

        ArgumentCaptor<ReviewRefsCommandParameters> parametersCaptor = ArgumentCaptor.forClass(ReviewRefsCommandParameters.class);

        verify(dao).setStatus(eq(repository.getId()), same(result));
        verify(commandFactory).reviewRefs(parametersCaptor.capture());
        verify(result).isEmpty(); //Ensure it did the empty check on the result
        verify(transactionTemplate).execute(isNotNull(TransactionCallback.class));

        ReviewRefsCommandParameters parameters = parametersCaptor.getValue();
        assertNotNull(parameters);
        assertSame(repository, parameters.getRepository());
        assertSame(upstream, parameters.getUpstream());
        assertSame(user, parameters.getUser());
        assertTrue(Iterables.isEmpty(parameters));
    }

    @Test
    public void testUpdateStatusShortCircuitsOnEmptyResult() {
        Repository upstream = mock(Repository.class);
        ApplicationUser user = mock(ApplicationUser.class);

        RefSyncResult result = mock(RefSyncResult.class);
        when(result.isEmpty()).thenReturn(true);

        Command<RefSyncResult> command = mock(Command.class);
        when(command.call()).thenReturn(result);

        when(commandFactory.reviewRefs(isA(ReviewRefsCommandParameters.class))).thenReturn(command);

        service.updateStatus(bulkRequestFor(repository, upstream, user));

        ArgumentCaptor<ReviewRefsCommandParameters> parametersCaptor = ArgumentCaptor.forClass(ReviewRefsCommandParameters.class);

        verify(commandFactory).reviewRefs(parametersCaptor.capture());
        verifyZeroInteractions(dao);
        verifyZeroInteractions(transactionTemplate);

        ReviewRefsCommandParameters parameters = parametersCaptor.getValue();
        assertNotNull(parameters);
        assertSame(repository, parameters.getRepository());
        assertSame(upstream, parameters.getUpstream());
        assertSame(user, parameters.getUser());
        assertTrue(Iterables.isEmpty(parameters));
    }

    @Test
    public void testUpdateStatusShortCircuitsOnNullResult() {
        Command<RefSyncResult> command = mock(Command.class);
        Repository upstream = mock(Repository.class);
        ApplicationUser user = mock(ApplicationUser.class);

        when(commandFactory.reviewRefs(isNotNull(ReviewRefsCommandParameters.class))).thenReturn(command);

        service.updateStatus(bulkRequestFor(repository, upstream, user));

        ArgumentCaptor<ReviewRefsCommandParameters> parametersCaptor = ArgumentCaptor.forClass(ReviewRefsCommandParameters.class);

        verify(commandFactory).reviewRefs(parametersCaptor.capture());
        verifyZeroInteractions(dao);
        verifyZeroInteractions(transactionTemplate);

        ReviewRefsCommandParameters parameters = parametersCaptor.getValue();
        assertNotNull(parameters);
        assertSame(repository, parameters.getRepository());
        assertSame(upstream, parameters.getUpstream());
        assertSame(user, parameters.getUser());
        assertTrue(Iterables.isEmpty(parameters));
    }

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

    private ArgumentMatcher<RefAccessRequest> masterRefMatcher() {
        return new ArgumentMatcher<RefAccessRequest>() {

            @Override
            public boolean matches(Object argument) {
                if (argument instanceof RefAccessRequest) {
                    RefAccessRequest request = ((RefAccessRequest) argument);
                    return request.getAccessType() == RefAccessType.UPDATE &&
                            request.getRefs().iterator().next().getId().equals(MASTER) &&
                            request.getRepository().getId() == repository.getId();
                }
                return false;
            }
        };
    }

    private void runSetEnabledPermissionTest(boolean enabled) {
        try {
            service.setEnabled(repository, enabled);
        } finally {
            verify(i18nService).createKeyedMessage(eq("bitbucket.service.repository.refsync.notanadmin"), anyVararg());
            verify(permissionService).hasRepositoryPermission(same(repository), same(Permission.REPO_ADMIN));
            verifyZeroInteractions(dao);
            verifyZeroInteractions(commandFactory);
            verifyZeroInteractions(transactionTemplate);
        }
    }

    private void setupTransactionTemplate() {
        doAnswer(new Answer<Object>() {

            private boolean transaction;

            @Override
            public Object answer(InvocationOnMock invocationOnMock) throws Throwable {
                TransactionCallback<?> callback = (TransactionCallback) invocationOnMock.getArguments()[0];
                assertNotNull(callback);
                assertFalse("Transactions should never be nested", transaction);

                transaction = true;
                try {
                    return callback.doInTransaction();
                } finally {
                    transaction = false;
                }
            }
        }).when(transactionTemplate).execute(isNotNull(TransactionCallback.class));
    }

    private static BulkRefSyncRequest bulkRequestFor(Repository repository, Repository upstream, ApplicationUser user,
                                                     RefChange... changes) {
        return new BulkRefSyncRequest(repository, upstream, user, Arrays.asList(changes));
    }

    private static Answer<Object> callVisitor() {
        return invocationOnMock -> {
            ProjectVisitor<?> visitor = (ProjectVisitor) invocationOnMock.getArguments()[0];
            assertNotNull(visitor);

            return visitor.visit((PersonalProject) invocationOnMock.getMock());
        };
    }

    private static Ref mockRef(String id, String displayId) {
        Branch mockBranch = mock(Branch.class);
        when(mockBranch.getId()).thenReturn(id);
        when(mockBranch.getDisplayId()).thenReturn(displayId);

        return mockBranch;
    }

    private static RejectedRef rejectedRef(RejectedRefState state) {
        RejectedRef ref = mock(RejectedRef.class);
        when(ref.getState()).thenReturn(state);

        return ref;
    }

    private static RefSyncRequest requestFor(Repository repository, RefSyncAction action) {
        return requestFor(repository, action, Collections.emptyMap());
    }

    private static RefSyncRequest requestFor(Repository repository, RefSyncAction action, Map<String, Object> context) {
        return new RefSyncRequest.Builder()
                .action(action)
                .context(context)
                .refId(MASTER)
                .repository(repository)
                .build();
    }
}
