package com.atlassian.stash.internal.pull.rescope;

import com.atlassian.bitbucket.auth.AuthenticationContext;
import com.atlassian.bitbucket.commit.SimpleCommit;
import com.atlassian.bitbucket.pull.*;
import com.atlassian.bitbucket.repository.Ref;
import com.atlassian.bitbucket.repository.Repository;
import com.atlassian.bitbucket.repository.SimpleBranch;
import com.atlassian.bitbucket.scm.Command;
import com.atlassian.bitbucket.scm.ResolveRefsCommandParameters;
import com.atlassian.bitbucket.scm.ScmCommandFactory;
import com.atlassian.bitbucket.scm.pull.BulkRescopeCommandParameters;
import com.atlassian.bitbucket.scm.pull.BulkRescopeContext;
import com.atlassian.bitbucket.scm.pull.PullRequestRescope;
import com.atlassian.bitbucket.user.DummySecurityService;
import com.atlassian.bitbucket.user.SimplePerson;
import com.atlassian.bitbucket.util.MoreCollectors;
import com.atlassian.bitbucket.util.Page;
import com.atlassian.bitbucket.util.PageRequest;
import com.atlassian.bitbucket.util.PageUtils;
import com.atlassian.stash.internal.project.InternalProject;
import com.atlassian.stash.internal.pull.InternalPullRequest;
import com.atlassian.stash.internal.pull.InternalPullRequestService;
import com.atlassian.stash.internal.pull.InternalRescopeRequest;
import com.atlassian.stash.internal.pull.RescopeRequestDao;
import com.atlassian.stash.internal.repository.InternalRepository;
import com.atlassian.stash.internal.scm.InternalScmService;
import com.atlassian.stash.internal.user.InternalApplicationUser;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.google.common.collect.Sets;
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.time.ZonedDateTime;
import java.util.*;
import java.util.function.BiFunction;
import java.util.stream.Collectors;

import static com.atlassian.stash.internal.pull.rescope.RescopeTestUtils.*;
import static java.util.Arrays.asList;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.collection.IsIterableContainingInAnyOrder.containsInAnyOrder;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.mockito.Mockito.*;

@RunWith(MockitoJUnitRunner.class)
@SuppressWarnings("unchecked")
public class DefaultPullRequestRescopeServiceTest {

    private static final Date AUTHOR_TIMESTAMP = new Date();
    private static final Date DATE_YESTERDAY = Date.from(ZonedDateTime.now().minusDays(1).toInstant());
    // Marker value used to indicate that the value is interpolated. The date won't be verified in the tests
    // to avoid problems with the test running on days when daylight saving changes
    private static final ZonedDateTime DATE_INTERPOLATED = ZonedDateTime.now().minusMonths(1);

    private static final Integer ID_REPO = 19;
    private static final Integer ID_FORK = 31;
    private static final String REF_BRANCH = "refs/heads/branch";
    private static final String REF_MASTER = "refs/heads/master";

    @Mock
    private AuthenticationContext authenticationContext;
    @Mock
    private RescopeRequestDao dao;
    @Mock
    private InternalPullRequestService pullRequestService;
    @Mock
    private InternalScmService scmService;
    @Spy
    private DummySecurityService securityService;
    @InjectMocks
    private DefaultPullRequestRescopeService service;

    // mock data
    @Mock
    private InternalApplicationUser author;
    @Mock
    private InternalRepository repository;
    @Mock
    private InternalRepository fork;
    @Mock
    private InternalApplicationUser user1;
    @Mock
    private InternalApplicationUser user2;

    // mock responses for ScmCommandFactory etc
    private Map<Repository, ScmCommandFactory> commandFactories;
    private List<BiFunction<BulkRescopeContext, PullRequestRescope, Void>> scmResponses;
    private List<InternalPullRequest> openPullRequests;
    private InternalPullRequest pullRequest;
    private Map<Repository, Map<String, String>> refs;
    private List<InternalRescopeRequest> rescopeRequests;

    @Captor
    private ArgumentCaptor<List<SimplePullRequestRescope>> rescopeCaptor;

    @Before
    public void setUp() {
        InternalProject project = mock(InternalProject.class);
        when(project.getKey()).thenReturn("proj");
        when(repository.getId()).thenReturn(ID_REPO);
        when(repository.getSlug()).thenReturn("repo");
        when(repository.getProject()).thenReturn(project);
        when(fork.getId()).thenReturn(ID_FORK);
        when(fork.getSlug()).thenReturn("fork");
        when(fork.getProject()).thenReturn(project);
        when(user1.getId()).thenReturn(101);
        when(user2.getId()).thenReturn(102);
        when(author.getId()).thenReturn(103);

        openPullRequests = Lists.newArrayList();
        when(dao.findByRepositories(anyList())).thenAnswer(new RescopeDaoSearchAnswer());
        when(pullRequestService.search(any(PullRequestSearchRequest.class), any(PageRequest.class)))
                .thenAnswer(new PullRequestSearchAnswer());
        when(scmService.getCommandFactory(any(Repository.class))).thenAnswer(new CommandFactoryAnswer());
        doAnswer(new BulkRescopeAnswer()).when(scmService).bulkRescope(any());

        commandFactories = new HashMap<>();
        refs = new HashMap<>();
        rescopeRequests = new ArrayList<>();
        scmResponses = new ArrayList<>();

        service.setBulkRescope(true);
        service.setMaxCommitIds(5);
    }

    @Test
    public void testBestUserGuessedFromCandidate() {
        setupSimpleUpdateScenario();

        ZonedDateTime date = ZonedDateTime.now().minusHours(1);

        // override the rescope request with a request without any refchanges
        mockRescopeRequests(request(repository, date, user1));

        service.rescope(repository);

        verify(pullRequestService).rescope(anyInt(), anyInt(), rescopeCaptor.capture());
        assertRescope("update", rescopeCaptor.getValue().get(0),
                update(rescope(pullRequest, date, user1, "c", "b", "d", "b"), list("d"), list()));
    }

    @Test
    public void testBestUserIsAuthorWhenNoCandidatesExist() {
        setupSimpleUpdateScenario();

        // date precedes the scopeDate on the pull request - the candidate should not apply to the rescope chain
        ZonedDateTime date = ZonedDateTime.now().minusDays(2);

        // override the rescope request with a request without any refchanges
        mockRescopeRequests(request(repository, date, user1));

        service.rescope(repository);

        verify(pullRequestService).rescope(anyInt(), anyInt(), rescopeCaptor.capture());
        assertRescope("update", rescopeCaptor.getValue().get(0), update(
                rescope(pullRequest,
                        DATE_INTERPOLATED, // we expect the date to be derived from the known dates
                        author, // author because no candidate requests are found to pick a user from
                        "c", "b", "d", "b"),
                list("d"), list()));
    }

    @Test
    public void testDeclinePullRequest() {
        //
        // a --- b  -- d         <- master [b -> d]
        //         \
        //          -- c         <- branch [c -> X]
        //
        // mock a single pull request with a single update
        pullRequest = pullRequest(author, repository, REF_BRANCH, "c", repository, REF_MASTER, "b",
                DATE_YESTERDAY, 3);
        openPullRequests = Lists.newArrayList(pullRequest);

        ZonedDateTime date1 = ZonedDateTime.now().minusHours(1);
        ZonedDateTime date2 = date1.plusMinutes(5);
        mockRescopeRequests(request(repository, date1, user1, refDelete(REF_BRANCH, "c")),
                request(repository, date2, user2, refUpdate(REF_MASTER, "b", "d")));

        mockRepositoryRefs(repository, REF_MASTER, "d");

        mockScmDecline(pullRequest, "c", "b", null, "b");

        service.rescope(repository);

        verify(pullRequestService).rescope(anyInt(), anyInt(), rescopeCaptor.capture());
        assertRescope("decline", rescopeCaptor.getValue().get(0),
                decline(rescope(pullRequest, date1, user1, "c", "b", null, "b")));
    }

    @Test
    public void testFallbackWhenUnsupported() {
        setupSimpleUpdateScenario();

        doThrow(new UnsupportedOperationException()).when(scmService).bulkRescope(any());

        service.rescope(repository);

        // the legacy implementation calls deleteByIds, but the new version does not
        verify(dao).deleteByIds(any());
    }

    @Test
    public void testLockedPullRequestIsSkipped() {
        setupSimpleUpdateScenario();
        when(pullRequestService.rescope(anyLong(), anyInt(), any())).thenAnswer(new PullRequestRescopeAnswer(0));

        // mark the pull request as locked
        when(pullRequest.isLocked()).thenReturn(true);

        RepositoryRescopeResult result = service.rescope(repository);

        assertNotNull(result);
        assertEquals(0, result.getErrorCount());
        assertEquals(1, result.getSkippedCount());

        verify(scmService, never()).getCommandFactory(repository);
        verify(pullRequestService, never()).rescope(anyLong(), anyInt(), any());
        verify(dao, never()).deleteByIds(anyList());
    }

    @Test
    public void testMergedWhenAllScopeStrippedFromPullRequest() {
        //
        // a --- b  -- d         <- master [d -> d]
        //         \
        //          -- c         <- branch [c -> b]
        //
        // mock a single pull request with a single update
        pullRequest = pullRequest(author, repository, REF_BRANCH, "c", repository, REF_MASTER, "d",
                DATE_YESTERDAY, 3);
        openPullRequests = Lists.newArrayList(pullRequest);

        ZonedDateTime date = ZonedDateTime.now().minusHours(1);
        mockRescopeRequests(
                request(repository, date, user1, refUpdate(REF_BRANCH, "c", "b")));

        mockRepositoryRefs(repository, REF_MASTER, "d", REF_BRANCH, "b");

        mockScmMerge(pullRequest, "c", "d", "b", "d", null);

        service.rescope(repository);

        verify(pullRequestService).rescope(anyInt(), anyInt(), rescopeCaptor.capture());
        assertRescope("merge", rescopeCaptor.getValue().get(0),
                merge(rescope(pullRequest, date, user1, "c", "d", "b", "d"), null));
    }


    @Test
    public void testMixedUpdateScenario() {
        //
        //           -- d --- g     -- i   <- branch@repo            Start:
        //         /           \  /                                  x master@repo = b
        //  a --- b --- f ------ h         <- master@repo            x branch@repo = d
        //    \                                                      x master@fork = c
        // ----\------------------------------------------------------------------------
        //      - c --- e                  <- master@fork
        //
        // Changes:                                                  Pull Requests:
        // 1. user1: master@fork [c -> e]                            x pullRequest1 [branch@repo -> master@repo]
        // 2. user1: master@repo [b -> f]                            x pullRequest2 [master@fork -> master@repo]
        // 3. user2: branch@repo [d -> g]
        // 4. user2: master@repo [f -> h], branch@repo deleted
        // 5. user2: branch@repo recreated at i
        InternalPullRequest pullRequest1 = pullRequest(author, fork, REF_MASTER, "c", repository, REF_MASTER, "b",
                DATE_YESTERDAY, 3);
        InternalPullRequest pullRequest2 = pullRequest(author, repository, REF_BRANCH, "d", repository, REF_MASTER, "b",
                DATE_YESTERDAY, 18);
        openPullRequests = Lists.newArrayList(pullRequest1, pullRequest2);
        long pr1Id = pullRequest1.getGlobalId();
        long pr2Id = pullRequest2.getGlobalId();

        ZonedDateTime date1 = ZonedDateTime.now().minusHours(1);
        ZonedDateTime date2 = date1.plusMinutes(6);
        ZonedDateTime date3 = date2.plusMinutes(11);
        ZonedDateTime date4 = date3.plusMinutes(27);
        ZonedDateTime date5 = date4.plusMinutes(5);

        InternalRescopeRequest request1 = request(fork, date1, user1, refUpdate(REF_MASTER, "c", "e"));
        InternalRescopeRequest request2 = request(repository, date2, user1, refUpdate(REF_MASTER, "b", "f"));
        InternalRescopeRequest request3 = request(repository, date3, user2, refUpdate(REF_BRANCH, "d", "g"));
        // user2 does a remote merge and pushes the merge & deletes the branch in a single push
        InternalRescopeRequest request4 = request(repository, date4, user2, refUpdate(REF_MASTER, "f", "h"),
                refDelete(REF_BRANCH, "g"));
        // user2 recreates the branch. Note that request4 should trigger a MERGE rescope and this request should be
        // disregarded
        InternalRescopeRequest request5 = request(repository, date5, user2, refAdd(REF_BRANCH, "i"));

        mockRescopeRequests(request1, request2, request3, request4, request5);
        when(pullRequestService.rescope(anyLong(), anyInt(), any())).thenAnswer(new PullRequestRescopeAnswer(0));
        mockRepositoryRefs(repository, REF_BRANCH, "i", REF_MASTER, "h");
        mockRepositoryRefs(fork, REF_MASTER, "e");

        // mock the SCM classifications for PR1
        mockScmUpdate(pullRequest1, "c", "b", "e", "b", list("e"), list());
        mockScmUpdate(pullRequest1, "e", "b", "e", "f", list(), list());
        mockScmUpdate(pullRequest1, "e", "f", "e", "h", list(), list());
        // mock the SCM classifications for PR2
        mockScmUpdate(pullRequest2, "d", "b", "d", "f", list(), list());
        mockScmUpdate(pullRequest2, "d", "f", "g", "f", list("g"), list());
        mockScmMerge(pullRequest2, "g", "f", null, "h", "h");

        RepositoryRescopeResult result = service.rescope(repository);

        assertNotNull(result);
        assertEquals(0, result.getErrorCount());
        assertEquals(0, result.getSkippedCount());

        verify(pullRequestService, times(2)).search(any(PullRequestSearchRequest.class), any());

        // verify that the correct rescopes are applied to pullRequest1
        verify(pullRequestService).rescope(eq(pr1Id), anyInt(), rescopeCaptor.capture());
        List<SimplePullRequestRescope> rescopes = rescopeCaptor.getValue();
        assertRescope("pr1 rescope(0)", rescopes.get(0), update(rescope(pullRequest1, date1, user1, "c", "b", "e", "b"),
                list("e"), list()));
        assertRescope("pr1 rescope(1)", rescopes.get(1), update(rescope(pullRequest1, date2, user1, "e", "b", "e", "f"),
                list(), list()));
        assertRescope("pr1 rescope(2)", rescopes.get(2), update(rescope(pullRequest1, date4, user2, "e", "f", "e", "h"),
                list(), list()));

        // verify the correct rescopes are applied to pullRequest2
        verify(pullRequestService).rescope(eq(pr2Id), anyInt(), rescopeCaptor.capture());
        rescopes = rescopeCaptor.getValue();
        assertRescope("pr2 rescope(0)", rescopes.get(0), update(rescope(pullRequest2, date2, user1, "d", "b", "d", "f"),
                list(), list()));
        assertRescope("pr2 rescope(1)", rescopes.get(1), update(rescope(pullRequest2, date3, user2, "d", "f", "g", "f"),
                list("g"), list()));
        assertRescope("pr3 rescope(2)", rescopes.get(2), merge(rescope(pullRequest2, date4, user2, "g", "f", null, "h"),
                "h"));

        // verify that the rescope requests are deleted
        ArgumentCaptor<InternalRescopeRequest> reqCaptor = ArgumentCaptor.forClass(InternalRescopeRequest.class);
        verify(dao, times(4)).delete(reqCaptor.capture());
        List<InternalRescopeRequest> deletedRequests = reqCaptor.getAllValues();

        // request1 should not be deleted because it's for a different repository. It might affect other pull requests
        assertThat(deletedRequests, containsInAnyOrder(request2, request3, request4, request5));

        // finally verify that the comments are drifted only once per pull request
        verifyNoMoreInteractions(pullRequestService);
    }

    @Test
    public void testNoOpenPullRequests() {
        openPullRequests.clear();
        mockRescopeRequests(request(repository, ZonedDateTime.now().minusMinutes(2), user1,
                refUpdate(REF_BRANCH, "c", "d")));

        RepositoryRescopeResult result = service.rescope(repository);

        assertNotNull(result);
        assertEquals(0, result.getErrorCount());
        assertEquals(0, result.getSkippedCount());

        verify(scmService, never()).getCommandFactory(repository);
        verify(dao).delete(any());
    }

    @Test
    public void testNoRescopeRequests() {
        //noinspection unchecked
        when(dao.findByRepositories(anyList())).thenReturn(Collections.emptyList());

        RepositoryRescopeResult result = service.rescope(repository);

        assertNotNull(result);
        assertEquals(0, result.getErrorCount());
        assertEquals(0, result.getSkippedCount());

        verify(scmService, never()).getCommandFactory(repository);
    }

    @Test
    public void testRescopeFailureIsRetried() {
        setupSimpleUpdateScenario();

        // mock pullRequestService.rescope failing twice before succeeding
        when(pullRequestService.rescope(anyLong(), anyInt(), any())).thenAnswer(new PullRequestRescopeAnswer(2));
        RepositoryRescopeResult result = service.rescope(repository);

        assertNotNull(result);
        assertEquals(0, result.getErrorCount());
        assertEquals(0, result.getSkippedCount());

        verify(pullRequestService, times(3)).rescope(anyLong(), anyInt(), any());
        verify(dao).delete(any());
    }

    @Test
    public void testRescopePersistentFailureIsHandled() {
        setupSimpleUpdateScenario();

        // mock pullRequestService.rescope consistently failing
        when(pullRequestService.rescope(anyLong(), anyInt(), any())).thenAnswer(new PullRequestRescopeAnswer(100));
        RepositoryRescopeResult result = service.rescope(repository);

        assertNotNull(result);
        assertEquals(1, result.getErrorCount());
        assertEquals(0, result.getSkippedCount());

        verify(pullRequestService, times(3)).rescope(anyLong(), anyInt(), any());
        verify(dao, never()).delete(any());
    }

    private static void assertRescope(String message, SimplePullRequestRescope actual, SimplePullRequestRescope expected) {
        RescopeTestUtils.assertRescope(message, expected, actual, DATE_INTERPOLATED);
    }

    private static SimplePullRequestRescope decline(SimplePullRequestRescope rescope) {
        rescope.setOutcome(new DeclineOutcome());
        return rescope;
    }

    private static RescopeDetails rescopeDetails(List<String> commitIds) {
        return rescopeDetails(commitIds.subList(0, Math.min(commitIds.size(), 5)), commitIds.size());
    }

    private static RescopeDetails rescopeDetails(List<String> commitIds, int total) {
        return new SimpleRescopeDetails.Builder()
                .total(total)
                .commits(commitIds.stream().map(id -> new SimpleCommit.Builder(id)
                        .author(new SimplePerson("user", "user@domain"))
                        .authorTimestamp(AUTHOR_TIMESTAMP)
                        .build()).collect(Collectors.toList()))
                .build();
    }

    private static List<String> list(String...values) {
        return Arrays.asList(values);
    }

    private static boolean matches(PullRequestRescope rescope, Repository fromRepo, Repository toRepo,
                                   String oldFrom, String oldTo, String newFrom, String newTo) {
        return rescope.getFromRepository().equals(fromRepo) && rescope.getToRepository().equals(toRepo) &&
                Objects.equals(oldFrom, rescope.getOldFromHash()) && Objects.equals(oldTo, rescope.getOldToHash()) &&
                Objects.equals(newFrom, rescope.getNewFromHash()) && Objects.equals(newTo, rescope.getNewToHash());
    }

    private static SimplePullRequestRescope merge(SimplePullRequestRescope rescope, String mergeHash) {
        rescope.setOutcome(new MergeOutcome(mergeHash));
        return rescope;
    }

    private static SimplePullRequestRescope update(SimplePullRequestRescope rescope, List<String> added,
                                                   List<String> removed) {
        rescope.setOutcome(new UpdateOutcome(rescopeDetails(added), rescopeDetails(removed)));
        return rescope;
    }

    private ScmCommandFactory mockCommandFactory(Repository repository) {
        ScmCommandFactory factory = mock(ScmCommandFactory.class);

        // mock the resolve refs command
        when(factory.resolveRefs(any())).thenAnswer(invocation -> {
            ResolveRefsCommandParameters parameters = (ResolveRefsCommandParameters) invocation.getArguments()[0];
            Command<Map<String, Ref>> result = mock(Command.class);
            when(result.call()).thenAnswer(inv -> {
                Map<String, String> repoRefs = refs.get(repository);
                Map<String, Ref> resolved = Maps.newHashMap();
                if (repoRefs != null) {
                    for (String branchId : parameters.getBranchIds()) {
                        String hash = repoRefs.get(branchId);
                        if (hash != null) {
                            resolved.put(branchId, new SimpleBranch.Builder()
                                    .id(branchId)
                                    .displayId(branchId)
                                    .latestCommit(hash)
                                    .build());
                        }
                    }
                }
                return resolved;
            });
            return result;
        });

        return factory;
    }

    // sets the refs as they will be resolved by ScmCommandFactory.resolveRefs / ScmCommandFactory.heads
    private void mockRepositoryRefs(Repository repo, String... refs) {
        Map<String, String> repoRefs = this.refs.computeIfAbsent(repo, r -> new HashMap<>());
        for (int i = 0; i < refs.length; i += 2) {
            repoRefs.put(refs[i], refs[i + 1]);
        }
    }

    private void mockRescopeRequests(InternalRescopeRequest...requests) {
        rescopeRequests = asList(requests);
    }

    private void mockScmDecline(PullRequest pullRequest, String oldFrom, String oldTo,
                                String newFrom, String newTo) {

        scmResponses.add((context, rescope) -> {
            if (matches(rescope, pullRequest.getFromRef().getRepository(), pullRequest.getToRef().getRepository(),
                    oldFrom, oldTo, newFrom, newTo)) {
                context.decline(rescope);
            }
            return null;
        });
    }

    private void mockScmMerge(PullRequest pullRequest, String oldFrom, String oldTo,
                                String newFrom, String newTo, String mergeHash) {

        scmResponses.add((context, rescope) -> {
            if (matches(rescope, pullRequest.getFromRef().getRepository(), pullRequest.getToRef().getRepository(),
                    oldFrom, oldTo, newFrom, newTo)) {
                context.merge(rescope, mergeHash);
            }
            return null;
        });
    }

    private void mockScmUpdate(PullRequest pullRequest, String oldFrom, String oldTo,
                               String newFrom, String newTo, List<String> commitsAdded, List<String> commitsRemoved) {

        scmResponses.add((context, rescope) -> {
            if (matches(rescope, pullRequest.getFromRef().getRepository(), pullRequest.getToRef().getRepository(),
                    oldFrom, oldTo, newFrom, newTo)) {
                context.update(rescope, rescopeDetails(commitsAdded), rescopeDetails(commitsRemoved));
            }
            return null;
        });
    }

    private void setupSimpleUpdateScenario() {
        //
        // a --- b               <- master
        //         \
        //          -- c --- d   <- branch [c -> d]
        //
        // mock a single pull request with a single update
        pullRequest = pullRequest(author, repository, REF_BRANCH, "c", repository, REF_MASTER, "b",
                DATE_YESTERDAY, 3);
        openPullRequests = Lists.newArrayList(pullRequest);

        ZonedDateTime date = ZonedDateTime.now().minusHours(1);
        mockRescopeRequests(request(repository, date, user1, refUpdate(REF_BRANCH, "c", "d")));

        mockRepositoryRefs(repository, REF_BRANCH, "d", REF_MASTER, "b");

        mockScmUpdate(pullRequest, "c", "b", "d", "b", list("d"), list());
    }

    /**
     * Answer that returns a mock ScmCommandFactory with mock implementations for ancestor, resolveRef and
     * commits
     */
    private class CommandFactoryAnswer implements Answer<ScmCommandFactory> {
        @Override
        public ScmCommandFactory answer(InvocationOnMock invocation) throws Throwable {
            Repository repository = (Repository) invocation.getArguments()[0];
            return commandFactories.computeIfAbsent(repository, DefaultPullRequestRescopeServiceTest.this::mockCommandFactory);
        }
    }

    private class BulkRescopeAnswer implements Answer<Void> {
        @Override
        public Void answer(InvocationOnMock invocation) throws Throwable {
            BulkRescopeCommandParameters params = (BulkRescopeCommandParameters) invocation.getArguments()[0];
            BulkRescopeContext context = params.getRescopeContext();

            for (PullRequestRescope rescope : context) {
                for (BiFunction<BulkRescopeContext, PullRequestRescope, Void> mockedResponse : scmResponses) {
                    mockedResponse.apply(context, rescope);
                }
            }
            return null;
        }
    }

    /**
     * Answer that mocks out PullRequestService.rescope
     */
    private static class PullRequestRescopeAnswer implements Answer<PullRequest> {
        private int count;
        private int failures;

        PullRequestRescopeAnswer(int failures) {
            this.failures = failures;
        }

        @Override
        public PullRequest answer(InvocationOnMock invocation) throws Throwable {
            if (++count <= failures) {
                throw new IllegalStateException("simulating rescope failure");
            }
            int version = (int) invocation.getArguments()[1];
            List<SimplePullRequestRescope> rescopes = invocation.getArgumentAt(2, List.class);
            SimplePullRequestRescope rescope = rescopes.get(rescopes.size() - 1);
            SimpleMinimalPullRequest pr = rescope.getPullRequest();

            if (rescope.getOutcome() == null || rescope.getOutcome() instanceof UpdateOutcome) {
                return pullRequest(pr.getCreatedBy(), (InternalRepository) pr.getFromRef().getRepository(),
                        pr.getFromRef().getId(), rescope.getNewFromHash(),
                        (InternalRepository) pr.getToRef().getRepository(), pr.getToRef().getId(), rescope.getNewToHash(),
                        rescope.getDate(), version + 1);
            }
            // decline or merge: no update to the refs, just a version bump
            return pullRequest(pr.getCreatedBy(), (InternalRepository) pr.getFromRef().getRepository(),
                    pr.getFromRef().getId(), pr.getFromRef().getLatestCommit(),
                    (InternalRepository) pr.getToRef().getRepository(), pr.getToRef().getId(),
                    pr.getToRef().getLatestCommit(),
                    rescope.getDate(), version + 1);
        }
    }

    /**
     * Mock implementation of PullRequestService.search
     */
    private class PullRequestSearchAnswer implements Answer<Page<PullRequest>> {

        @Override
        public Page<PullRequest> answer(InvocationOnMock invocation) throws Throwable {
            PullRequestSearchRequest searchRequest = (PullRequestSearchRequest) invocation.getArguments()[0];
            PageRequest pageRequest = (PageRequest) invocation.getArguments()[1];
            List<PullRequest> data = openPullRequests.stream()
                    .filter(pr -> matchesSearchRequest(searchRequest, pr))
                    .collect(Collectors.toList());
            return PageUtils.createPage(data, pageRequest);
        }

        private boolean matchesSearchRequest(PullRequestSearchRequest request, InternalPullRequest pullRequest) {
            return matches(request.getFromRepositoryId(), request.getFromRefIds(), pullRequest.getFromRef()) &&
                    matches(request.getToRepositoryId(), request.getToRefIds(), pullRequest.getToRef());
        }

        private boolean matches(Integer repoId, Collection<String> refIds, PullRequestRef pullRequestRef) {
            return (repoId == null || pullRequestRef.getRepository().getId() == repoId) &&
                    (refIds.isEmpty() || refIds.contains(pullRequestRef.getId()));
        }
    }

    private class RescopeDaoSearchAnswer implements Answer<List<InternalRescopeRequest>> {

        @Override
        public List<InternalRescopeRequest> answer(InvocationOnMock invocation) throws Throwable {
            //noinspection unchecked
            Set<Integer> ids = Sets.newHashSet((Collection<Integer>) invocation.getArguments()[0]);
            return rescopeRequests.stream()
                    .filter(req -> ids.contains(req.getRepository().getId()))
                    .collect(MoreCollectors.toImmutableList());
        }
    }
}