package com.atlassian.bitbucket.mesh.git.pull;

import com.atlassian.bitbucket.mesh.AbstractWiredExecutionContextTest;
import com.atlassian.bitbucket.mesh.config.ApplicationConfig;
import com.atlassian.bitbucket.mesh.config.GcConfig;
import com.atlassian.bitbucket.mesh.git.*;
import com.atlassian.bitbucket.mesh.git.builder.GitProcessBuilderFactory;
import com.atlassian.bitbucket.mesh.git.content.Change;
import com.atlassian.bitbucket.mesh.git.content.ChangeCallback;
import com.atlassian.bitbucket.mesh.git.exception.NoMergeBaseException;
import com.atlassian.bitbucket.mesh.git.gc.DefaultGarbageCollectionManager;
import com.atlassian.bitbucket.mesh.git.gc.GarbageCollectionParameters;
import com.atlassian.bitbucket.mesh.git.ref.RefUpdate;
import com.atlassian.bitbucket.mesh.io.MoreFiles;
import com.atlassian.bitbucket.mesh.repository.BitbucketServerRepository;
import com.atlassian.bitbucket.mesh.repository.DefaultRepositoryHashService;
import com.atlassian.bitbucket.mesh.repository.ManagedRepository;
import com.atlassian.bitbucket.mesh.repository.Repository;
import com.atlassian.bitbucket.mesh.rpc.v1.RpcRepositoryIntegrityIssue;
import com.atlassian.bitbucket.mesh.test.annotation.DirtiesRepo;
import com.atlassian.bitbucket.mesh.test.annotation.Repo;
import org.junit.Before;
import org.junit.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InOrder;
import org.mockito.Mock;

import javax.annotation.Resource;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.atomic.AtomicLong;
import java.util.stream.Collectors;

import static com.atlassian.bitbucket.mesh.git.pull.PullRequestRefBuilder.*;
import static com.atlassian.bitbucket.mesh.git.pull.PullRequestRefType.FROM;
import static com.atlassian.bitbucket.mesh.rpc.v1.RepositoryIntegrityIssueType.INTEGRITY_ISSUE_EXTRA_PRS;
import static com.atlassian.bitbucket.mesh.rpc.v1.RepositoryIntegrityIssueType.INTEGRITY_ISSUE_MISSING_PRS;
import static java.nio.file.Files.createDirectories;
import static java.util.Collections.singleton;
import static java.util.stream.IntStream.rangeClosed;
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.CoreMatchers.notNullValue;
import static org.hamcrest.Matchers.contains;
import static org.hamcrest.Matchers.not;
import static org.mockito.Mockito.*;

public class DefaultPullRequestManagerTest extends AbstractWiredExecutionContextTest {

    private static final String MERGE_HASH = "017caf31eca7c46eb3d1800fcac431cfa7147a01";
    //To reduce the likelihood of individual tests impacting other tests, this static variable is used
    //to ensure each test uses a distinct pull request ID
    private static final AtomicLong nextPullRequestId = new AtomicLong(1L);
    private final List<Path> created = new ArrayList<>();
    private final long pullRequestId;
    @Resource
    private GitAgent agent;
    @Repo(GitRepositories.MERGE)
    private BitbucketServerRepository bitbucketServerRepository;
    @Resource
    private GitProcessBuilderFactory builderFactory;
    @Resource
    private ApplicationConfig config;
    private PullRequestRef fromRef;
    private GcConfig gcConfig;
    private DefaultGarbageCollectionManager gcManager;
    @Resource
    private DefaultRepositoryHashService hashService;
    @Resource
    private GitRepositoryLayout layout;
    @Resource
    private DefaultPullRequestManager manager;
    @Repo(GitRepositories.MERGE)
    private Repository merge;
    @Mock
    private PullRequest pullRequest;
    @Repo(GitRepositories.PULL_REQUESTS)
    private Repository pullRequests;
    @Repo
    private ManagedRepository repository;
    @Resource
    private GitRepositoryLayout repositoryLayout;
    private PullRequestRef toRef;

    public DefaultPullRequestManagerTest() {
        pullRequestId = nextPullRequestId.getAndIncrement();
    }

    @Before
    public void setup() {
        fromRef = new PullRequestRef.Builder(merge, "refs/heads/fromRef")
                .latestCommit("0000000000000000000000000000000000000000")
                .build();
        toRef = new PullRequestRef.Builder(merge, "refs/heads/toRef")
                .latestCommit("0000000000000000000000000000000000000000")
                .build();
        updatePullRequest();
        gcConfig = spy(config.getGit().getGc());
        ScheduledExecutorService immediateScheduler = mock(ScheduledExecutorService.class);
        when(immediateScheduler.schedule(any(Runnable.class), anyLong(), any())).thenAnswer(invocation -> {
            Runnable run = invocation.getArgument(0);
            run.run();
            return mock(ScheduledFuture.class);
        });
        gcManager = new DefaultGarbageCollectionManager(gcConfig, builderFactory, immediateScheduler, repositoryLayout);
    }

    @Test
    @DirtiesRepo("bitbucketServerRepository")
    public void testCheckIntegrityWhenBitbucketServerPullRequestsAreConsistent() {
        Path repoDir = bitbucketServerRepository.getPath();
        //Create 3 directories to hold pull request refs from id 1 to 3
        rangeClosed(1, 3).forEach(i -> {
            try {
                createDirectories(PullRequestRefBuilder.bitbucketServerDarkRef().id(i).toPath(repoDir));
            } catch (IOException e) {
                fail("Failed to create pull request directories");
            }
        });

        RpcRepositoryIntegrityIssue issue = manager.checkIntegrity(bitbucketServerRepository, 3L).orElse(null);

        assertNull(issue);
    }

    @Test
    @DirtiesRepo("bitbucketServerRepository")
    public void testCheckIntegrityWhenBitbucketServerPullRequestsAreExtra() {
        Path repoDir = bitbucketServerRepository.getPath();
        //Create 3 directories to hold pull request refs from id 1 to 3
        rangeClosed(1, 3).forEach(i -> {
            try {
                createDirectories(PullRequestRefBuilder.bitbucketServerDarkRef().id(i).toPath(repoDir));
            } catch (IOException e) {
                fail("Failed to create pull request directories");
            }
        });

        RpcRepositoryIntegrityIssue issue = manager.checkIntegrity(bitbucketServerRepository, 1L).orElse(null);

        assertThat(issue, notNullValue());
        assertThat(issue.getRepository(), is(bitbucketServerRepository.getId()));
        assertThat(issue.getIssueType(), is(INTEGRITY_ISSUE_EXTRA_PRS));
        assertThat(issue.getPullRequestIdsList(), contains(2L, 3L));
    }

    @Test
    @DirtiesRepo("bitbucketServerRepository")
    public void testCheckIntegrityWhenBitbucketServerPullRequestsAreMissing() {
        Path repoDir = bitbucketServerRepository.getPath();
        //Create 3 directories to hold pull request refs from id 1 to 3
        rangeClosed(1, 3).forEach(i -> {
            try {
                createDirectories(PullRequestRefBuilder.bitbucketServerDarkRef().id(i).toPath(repoDir));
            } catch (IOException e) {
                fail("Failed to create pull request directories");
            }
        });

        RpcRepositoryIntegrityIssue issue = manager.checkIntegrity(bitbucketServerRepository, 6L).orElse(null);

        assertThat(issue, notNullValue());
        assertThat(issue.getRepository(), is(bitbucketServerRepository.getId()));
        assertThat(issue.getIssueType(), is(INTEGRITY_ISSUE_MISSING_PRS));
        assertThat(issue.getPullRequestIdsList(), contains(6L, 5L, 4L));
    }

    @Test
    @DirtiesRepo("repository")
    public void testCheckIntegrityWhenPullRequestsAreConsistent() {
        Path repoDir = repository.getPath();
        //Create 3 directories to hold pull request refs from id 1 to 3
        rangeClosed(1, 3).forEach(i -> {
            try {
                createDirectories(PullRequestRefBuilder.darkRef().id(i).toPath(repoDir));
            } catch (IOException e) {
                fail("Failed to create pull request directories");
            }
        });

        RpcRepositoryIntegrityIssue issue = manager.checkIntegrity(repository, 3L).orElse(null);

        assertNull(issue);
    }

    @Test
    @DirtiesRepo("repository")
    public void testCheckIntegrityWhenPullRequestsAreExtra() {
        Path repoDir = repository.getPath();
        //Create 3 directories to hold pull request refs from id 1 to 3
        rangeClosed(1, 3).forEach(i -> {
            try {
                createDirectories(PullRequestRefBuilder.darkRef().id(i).toPath(repoDir));
            } catch (IOException e) {
                fail("Failed to create pull request directories");
            }
        });

        RpcRepositoryIntegrityIssue issue = manager.checkIntegrity(repository, 1L).orElse(null);

        assertThat(issue, notNullValue());
        assertThat(issue.getRepository(), is(repository.getId()));
        assertThat(issue.getIssueType(), is(INTEGRITY_ISSUE_EXTRA_PRS));
        assertThat(issue.getPullRequestIdsList(), contains(2L, 3L));
    }

    @Test
    @DirtiesRepo("repository")
    public void testCheckIntegrityWhenPullRequestsAreMissing() {
        Path repoDir = repository.getPath();
        //Create 3 directories to hold pull request refs from id 1 to 3
        rangeClosed(1, 3).forEach(i -> {
            try {
                createDirectories(PullRequestRefBuilder.darkRef().id(i).toPath(repoDir));
            } catch (IOException e) {
                fail("Failed to create pull request directories");
            }
        });

        RpcRepositoryIntegrityIssue issue = manager.checkIntegrity(repository, 6L).orElse(null);

        assertThat(issue, notNullValue());
        assertThat(issue.getRepository(), is(repository.getId()));
        assertThat(issue.getIssueType(), is(INTEGRITY_ISSUE_MISSING_PRS));
        assertThat(issue.getPullRequestIdsList(), contains(6L, 5L, 4L));
    }

    /**
     * Creates all of the "normal" pull request ref files/directories <i>that have ever existed in any shipped
     * version of the system</i> and verifies that they're all deleted.
     */
    @DirtiesRepo("bitbucketServerRepository")
    @Test
    public void testDeleteBitbucketServerPullRequest() throws Exception {
        toRepository(bitbucketServerRepository);
        populateRefs(bitbucketServerRepository, publicRef(), "from", "merge");
        populateRefs(bitbucketServerRepository, publicReflogs(), "from", "merge", "to"); //These could be created prior to Stash 2.9
        populateRefs(bitbucketServerRepository, bitbucketServerDarkRef(), "from", "merge", "merge-clean", "notes", "to");
        populateRefs(bitbucketServerRepository, darkReflogs(pullRequest.getRepository()), "from", "merge", "to");   //These are created for Stash 2.9 and newer
        created.forEach(path -> assertTrue(Files.exists(path)));

        manager.delete(pullRequest);

        //Preparing the ref dirs may create parent levels that this is not verifying. That's intentional, because
        //DeletePullRequestCommand does not try to clean up "above" the ref directories. "Higher" directories, like
        //"refs/pull-requests" and "dark-refs/pull-requests" are unlikely to be empty, and the next pull request
        //to be created will recreate them anyway
        created.forEach(file -> assertFalse(file + " was not deleted", Files.exists(file)));
    }

    @DirtiesRepo("merge")
    @Test
    public void testDeleteRemovesAllRefsAndReflogs() throws Exception {
        populateRefs(merge, publicRef(), "from", "merge");
        populateRefs(merge, publicReflogs(), "from", "merge", "to"); //These could be created prior to Stash 2.9
        populateRefs(merge, darkRef(), "from", "merge", "merge-clean", "notes", "to");
        populateRefs(merge, darkReflogs(pullRequest.getRepository()), "from", "merge", "to");   //These are created for Stash 2.9 and newer
        created.forEach(path -> assertTrue(Files.exists(path)));

        manager.delete(pullRequest);

        //Preparing the ref dirs may create parent levels that this is not verifying. That's intentional, because
        //DeletePullRequestCommand does not try to clean up "above" the ref directories. "Higher" directories, like
        //"refs/pull-requests" and "dark-refs/pull-requests" are unlikely to be empty, and the next pull request
        //to be created will recreate them anyway
        created.forEach(file -> assertFalse(file + " was not deleted", Files.exists(file)));
    }

    @DirtiesRepo("merge")
    @Test
    public void testDeleteRemovesLooseRefs() {
        prepareRefs("branch_that_has_folder_moved_to_different_path",
                "508e5cd08c1264d5d12e5199d39dfe149e6598a4", "169666cc17ad1e9e6ae60945ce25ff46ca4e18b1");
        manager.updateRefs(singleton(pullRequest));

        List<RefUpdate> updates = manager.delete(pullRequest);

        assertThat(updates, contains(RefUpdate.ofDelete(
                "refs/pull-requests/" + pullRequestId + "/from", "508e5cd08c1264d5d12e5199d39dfe149e6598a4")));
    }

    @DirtiesRepo("merge")
    @Test
    public void testDeleteRemovesPackedRefs() throws IOException {
        prepareRefs("branch_that_has_folder_moved_to_different_path",
                "508e5cd08c1264d5d12e5199d39dfe149e6598a4", "169666cc17ad1e9e6ae60945ce25ff46ca4e18b1");
        manager.updateRefs(singleton(pullRequest));
        runPackRefs();

        List<RefUpdate> updates = manager.delete(pullRequest);

        assertThat(updates, contains(RefUpdate.ofDelete(
                "refs/pull-requests/" + pullRequestId + "/from", "508e5cd08c1264d5d12e5199d39dfe149e6598a4")));
    }

    @DirtiesRepo("merge")
    @Test
    public void testDeleteUpdatesContentHash() {
        prepareRefs("branch_that_has_folder_moved_to_different_path",
                "508e5cd08c1264d5d12e5199d39dfe149e6598a4", "169666cc17ad1e9e6ae60945ce25ff46ca4e18b1");
        String contentHashBeforePrCreated = hashService.getContentHash(merge);
        manager.updateRefs(singleton(pullRequest));
        String contentHashBeforePrDeleted = hashService.getContentHash(merge);

        manager.delete(pullRequest);

        String contentHashAfterPrDeleted = hashService.getContentHash(merge);
        assertThat(contentHashBeforePrCreated, is(not(contentHashBeforePrDeleted)));
        assertThat(contentHashBeforePrDeleted, is(not(contentHashAfterPrDeleted)));
        assertThat(contentHashAfterPrDeleted, is(contentHashBeforePrCreated));
    }

    @Test
    public void testGetEffectiveDiff() {
        verifyEffectiveDiff("branch_that_has_same_text_file_modified_on_both_clean_merge",
                "306d584aa574fa74901d3e288b2931b2c4f0e927", "7fa77c6e34c6c53de0bb880fc2b0b7924a601cd2",
                "e527f8731b48bca46d7e99e270d05a9a3fa91107");
    }

    @Test
    public void testGetEffectiveDiffBetweenRepositories() {
        fromRepository(pullRequests);

        verifyEffectiveDiff("branch_that_has_different_binary_files_with_same_name_added",
                "ff94c003ea50c05518316a5d75468f1c8d4eda2b", "1335a5b58bcdc27804915f352c883c5fa9024e5e",
                "017caf31eca7c46eb3d1800fcac431cfa7147a01");
    }

    @Test
    public void testGetEffectiveDiffBetweenUnrelatedRepositories() {
        fromRepository(repository);

        assertThrows(NoMergeBaseException.class, () ->
                verifyEffectiveDiff("master", "0a943a29376f2336b78312d99e65da17048951db",
                        "master", "017caf31eca7c46eb3d1800fcac431cfa7147a01", null));
    }

    @Test
    public void testFindMergeBaseForUnconnectedHistoriesReturnsNullTreeIfAllowed() {
        fromRepository(repository);
        prepareRefs("master", "0a943a29376f2336b78312d99e65da17048951db",
                "master", "017caf31eca7c46eb3d1800fcac431cfa7147a01");

        String mergeBase = manager.findMergeBase(pullRequest, true);
        assertEquals(GitUtils.NULL_TREE, mergeBase);
    }

    @Test
    public void testFindMergeBaseForUnconnectedHistoriesThrowsIfNotAllowed() {
        fromRepository(repository);
        prepareRefs("master", "0a943a29376f2336b78312d99e65da17048951db",
                "master", "017caf31eca7c46eb3d1800fcac431cfa7147a01");

        assertThrows(NoMergeBaseException.class, () -> manager.findMergeBase(pullRequest, false));
    }

    @Test
    public void testGetEffectiveDiffForDeclinedPullRequest() {
        pullRequest = new PullRequest.Builder(pullRequest)
                .state(PullRequestState.DECLINED)
                .build();

        verifyEffectiveDiff("branch_that_has_same_text_file_modified_on_both_clean_merge",
                "306d584aa574fa74901d3e288b2931b2c4f0e927", "7fa77c6e34c6c53de0bb880fc2b0b7924a601cd2",
                "e527f8731b48bca46d7e99e270d05a9a3fa91107");
    }

    @Test
    public void testGetEffectiveDiffForMergedPullRequest() {
        pullRequest = new PullRequest.Builder(pullRequest)
                .state(PullRequestState.MERGED)
                .build();

        verifyEffectiveDiff("branch_that_has_same_text_file_modified_on_both_clean_merge",
                "306d584aa574fa74901d3e288b2931b2c4f0e927", "7fa77c6e34c6c53de0bb880fc2b0b7924a601cd2",
                "e527f8731b48bca46d7e99e270d05a9a3fa91107");
    }

    @Test
    public void testGetEffectiveDiffWithSameCommits() {
        verifyEffectiveDiff("branch_that_differ_by_empty_commit_src", "017caf31eca7c46eb3d1800fcac431cfa7147a01",
                "branch_that_differ_by_negating_commits_src", "017caf31eca7c46eb3d1800fcac431cfa7147a01",
                "017caf31eca7c46eb3d1800fcac431cfa7147a01");
    }

    @Test
    public void testFindMergeBase() {
        prepareRefs("branch_that_has_same_text_file_modified_on_both_clean_merge",
                "306d584aa574fa74901d3e288b2931b2c4f0e927", "7fa77c6e34c6c53de0bb880fc2b0b7924a601cd2");

        assertEquals("e527f8731b48bca46d7e99e270d05a9a3fa91107", manager.findMergeBase(pullRequest, false));
    }

    @Test
    public void testFindMergeBaseForSameCommit() {
        prepareRefs("branch_that_differ_by_empty_commit_src", "017caf31eca7c46eb3d1800fcac431cfa7147a01",
                "branch_that_differ_by_negating_commits_src", "017caf31eca7c46eb3d1800fcac431cfa7147a01");

        assertEquals("017caf31eca7c46eb3d1800fcac431cfa7147a01", manager.findMergeBase(pullRequest, false));
    }

    @Test
    public void testStreamChanges() {
        prepareRefs("branch_that_has_same_text_file_modified_on_both_clean_merge_src",
                "306d584aa574fa74901d3e288b2931b2c4f0e927",
                "branch_that_has_same_text_file_modified_on_both_clean_merge_trgt",
                "7fa77c6e34c6c53de0bb880fc2b0b7924a601cd2");

        ChangeCallback callback = mock(ChangeCallback.class);
        StreamPullRequestChangesParameters parameters = new StreamPullRequestChangesParameters.Builder(pullRequest)
                .limit(100)
                .build();
        manager.streamChanges(parameters, callback);

        ArgumentCaptor<Change> changeCaptor = ArgumentCaptor.forClass(Change.class);

        InOrder ordered = inOrder(callback);
        ordered.verify(callback).onChange(changeCaptor.capture());
        ordered.verifyNoMoreInteractions();

        Change change = changeCaptor.getValue();
        assertNotNull(change);
        assertEquals("file.txt", change.getPath());
        assertNull(change.getSrcPath());
        assertSame(GitChangeType.MODIFY, change.getType());
    }

    @Test
    public void testStreamChangesWithSameCommit() {
        prepareRefs("branch_that_has_same_text_file_modified_src", "5bb214e6dd15643dabfe4b6b0739becfb1e59d9f",
                "branch_that_has_same_text_file_modified_trgt", "5bb214e6dd15643dabfe4b6b0739becfb1e59d9f");

        ChangeCallback callback = mock(ChangeCallback.class);
        StreamPullRequestChangesParameters parameters = new StreamPullRequestChangesParameters.Builder(pullRequest)
                .limit(100)
                .build();
        manager.streamChanges(parameters, callback);

        InOrder ordered = inOrder(callback);
        ordered.verifyNoMoreInteractions();
    }

    @DirtiesRepo("merge")
    @Test
    public void testUpdateRefsBatchForCrossRepositoryPullRequest() {
        fromRepository(pullRequests);
        prepareRefs("branch_that_has_different_binary_files_with_same_name_added",
                "ff94c003ea50c05518316a5d75468f1c8d4eda2b", "1335a5b58bcdc27804915f352c883c5fa9024e5e");

        manager.updateRefs(singleton(pullRequest));

        verifyPullRequestRefsInSync(pullRequest);
    }

    @DirtiesRepo("merge")
    @Test
    public void testUpdateRefsBatchForDeclinedPullRequest() {
        prepareRefs("branch_that_has_same_text_file_modified_on_both_clean_merge",
                "306d584aa574fa74901d3e288b2931b2c4f0e927", "7fa77c6e34c6c53de0bb880fc2b0b7924a601cd2");

        //Create the pull request refs as they should be for an open pull request
        manager.updateRefs(singleton(pullRequest));
        verifyPullRequestRefsInSync(pullRequest);

        //Now simulate the pull request being declines
        pullRequest = new PullRequest.Builder(pullRequest)
                .state(PullRequestState.DECLINED)
                .build();

        manager.updateRefs(singleton(pullRequest));
        verifyPullRequestRefsInSync(pullRequest);
    }

    @DirtiesRepo("merge")
    @Test
    public void testUpdateRefsBatchForMergedPullRequest() {
        prepareRefs("branch_that_has_same_text_file_modified_on_both_clean_merge",
                "306d584aa574fa74901d3e288b2931b2c4f0e927", "7fa77c6e34c6c53de0bb880fc2b0b7924a601cd2");

        //Create the pull request refs as they should be for an open pull request
        manager.updateRefs(singleton(pullRequest));
        verifyPullRequestRefsInSync(pullRequest);

        //Now simulate the pull request being merged
        pullRequest = new PullRequest.Builder(pullRequest)
                .state(PullRequestState.MERGED)
                .build();

        manager.updateRefs(singleton(pullRequest));
        verifyPullRequestRefsInSync(pullRequest);
    }

    @DirtiesRepo("merge")
    @Test
    public void testUpdateRefsBatchForOpenPullRequest() {
        prepareRefs("branch_that_has_same_text_file_modified_on_both_clean_merge",
                "306d584aa574fa74901d3e288b2931b2c4f0e927", "7fa77c6e34c6c53de0bb880fc2b0b7924a601cd2");

        manager.updateRefs(singleton(pullRequest));

        verifyPullRequestRefsInSync(pullRequest);
    }

    @DirtiesRepo("bitbucketServerRepository")
    @Test
    public void testUpdateRefsBatchForOpenPullRequestOnBitbucketServerRepository() {
        toRepository(bitbucketServerRepository);
        prepareRefs("branch_that_has_same_text_file_modified_on_both_clean_merge",
                "306d584aa574fa74901d3e288b2931b2c4f0e927", "7fa77c6e34c6c53de0bb880fc2b0b7924a601cd2");

        manager.updateRefs(singleton(pullRequest));

        verifyPullRequestRefsInSync(pullRequest);
    }

    @DirtiesRepo("merge")
    @Test
    public void testUpdateRefsForCrossRepositoryPullRequest() {
        fromRepository(pullRequests);
        prepareRefs("branch_that_has_different_binary_files_with_same_name_added",
                "ff94c003ea50c05518316a5d75468f1c8d4eda2b", "1335a5b58bcdc27804915f352c883c5fa9024e5e");

        manager.updateRefs(pullRequest);

        verifyPullRequestRefsInSync(pullRequest);
    }

    @DirtiesRepo("merge")
    @Test
    public void testUpdateRefsForDeclinedPullRequest() {
        prepareRefs("branch_that_has_same_text_file_modified_on_both_clean_merge",
                "306d584aa574fa74901d3e288b2931b2c4f0e927", "7fa77c6e34c6c53de0bb880fc2b0b7924a601cd2");

        //Create the pull request refs as they should be for an open pull request
        manager.updateRefs(pullRequest);
        verifyPullRequestRefsInSync(pullRequest);

        //Now simulate the pull request being declines
        pullRequest = new PullRequest.Builder(pullRequest)
                .state(PullRequestState.DECLINED)
                .build();

        manager.updateRefs(singleton(pullRequest));
        verifyPullRequestRefsInSync(pullRequest);
    }

    @DirtiesRepo("merge")
    @Test
    public void testUpdateRefsForMergedPullRequest() {
        prepareRefs("branch_that_has_same_text_file_modified_on_both_clean_merge",
                "306d584aa574fa74901d3e288b2931b2c4f0e927", "7fa77c6e34c6c53de0bb880fc2b0b7924a601cd2");

        //Create the pull request refs as they should be for an open pull request
        manager.updateRefs(pullRequest);
        verifyPullRequestRefsInSync(pullRequest);

        //Now simulate the pull request being merged
        pullRequest = new PullRequest.Builder(pullRequest)
                .state(PullRequestState.MERGED)
                .build();

        manager.updateRefs(singleton(pullRequest));
        verifyPullRequestRefsInSync(pullRequest);
    }

    @DirtiesRepo("merge")
    @Test
    public void testUpdateRefsForOpenPullRequest() {
        prepareRefs("branch_that_has_same_text_file_modified_on_both_clean_merge",
                "306d584aa574fa74901d3e288b2931b2c4f0e927", "7fa77c6e34c6c53de0bb880fc2b0b7924a601cd2");

        manager.updateRefs(pullRequest);

        verifyPullRequestRefsInSync(pullRequest);
    }

    @DirtiesRepo("merge")
    @Test
    public void testUpdateRefsForPullRequestWithExistingSymbolicRef() {
        prepareRefs("branch_that_has_same_text_file_modified_on_both_clean_merge",
                "306d584aa574fa74901d3e288b2931b2c4f0e927", "7fa77c6e34c6c53de0bb880fc2b0b7924a601cd2");

        //Create existing dark and public refs in the repository for the pull request (but at an older hash)
        //to verify that existing pull requests can be upgraded from using a symbolic ref for the public from ref
        //to using a standard, non-symbolic ref.
        String darkFromRef = PullRequestRefBuilder.darkRef().id(pullRequest).type(FROM).toRef();
        String publicFromRef = PullRequestRefBuilder.publicRef().id(pullRequest).type(FROM).toRef();
        Repository repository = pullRequest.getRepository();
        agent.createRef(repository, darkFromRef, "e527f8731b48bca46d7e99e270d05a9a3fa91107");
        agent.enableReflog(repository, darkFromRef);
        agent.createSymbolicRef(repository, publicFromRef, darkFromRef);

        manager.updateRefs(pullRequest);

        verifyPullRequestRefsInSync(pullRequest);
    }

    protected void prepareRefs(String base, String fromCommit, String toCommit) {
        prepareRefs(base + "_src", fromCommit, base + "_trgt", toCommit);
    }

    protected void prepareRefs(String fromRef, String fromCommit, String toRef, String toCommit) {
        this.fromRef = prepareRef(this.fromRef, fromRef, fromCommit);
        this.toRef = prepareRef(this.toRef, toRef, toCommit);
        updatePullRequest();
    }

    private void fromRepository(Repository repository) {
        fromRef =
                new PullRequestRef.Builder(repository, fromRef.getId()).latestCommit(fromRef.getLatestCommit()).build();
        updatePullRequest();
    }

    private void populateRefs(Repository repository, PullRequestRefBuilder builder, String... names) throws Exception {
        created.add(builder.id(pullRequest).toPath(repository.getPath()));

        for (String name : names) {
            Path ref = builder.name(name).toPath(repository.getPath());
            MoreFiles.mkdir(ref.getParent());
            MoreFiles.write(ref, MERGE_HASH);
            created.add(ref);
        }
    }

    private PullRequestRef prepareRef(PullRequestRef ref, String shortId, String latestCommit) {
        return new PullRequestRef.Builder(ref.getRepository(), GitRefPattern.HEADS.qualify(shortId))
                .latestCommit(latestCommit)
                .build();
    }

    private void runPackRefs() throws IOException {
        List<Path> paths = Arrays.asList("packrefs.ts", "vacuum.ts").stream()
                .map(layout.getApplicationInfoDir(merge)::resolve)
                .collect(Collectors.toList());
        for (Path path : paths) {
            Files.deleteIfExists(path);
        }
        when(gcConfig.getDelay()).thenReturn(Duration.ofSeconds(0L));
        try {
            gcManager.collectGarbage(new GarbageCollectionParameters.Builder(merge).build());
        } finally {
            when(gcConfig.getDelay()).thenCallRealMethod();
        }
    }

    private void toRepository(Repository repository) {
        toRef = new PullRequestRef.Builder(repository, toRef.getId()).latestCommit(toRef.getLatestCommit()).build();
        updatePullRequest();
    }

    private void updatePullRequest() {
        pullRequest = new PullRequest.Builder(pullRequestId, 0)
                .fromRef(fromRef)
                .state(PullRequestState.OPEN)
                .toRef(toRef)
                .build();
    }

    private void verifyEffectiveDiff(String base, String fromCommit, String toCommit, String mergeBase) {
        verifyEffectiveDiff(base + "_src", fromCommit, base + "_trgt", toCommit, mergeBase);
    }

    private void verifyEffectiveDiff(String fromRef, String fromCommit, String toRef,
                                     String toCommit, String mergeBase) {
        prepareRefs(fromRef, fromCommit, toRef, toCommit);

        PullRequestEffectiveDiff diff = manager.getEffectiveDiff(GetPullRequestEffectiveDiffParameters.of(pullRequest));
        assertNotNull(diff);
        assertEquals(mergeBase, diff.getFromCommit());
        assertEquals(fromCommit, diff.getToCommit());

        verifyPullRequestRefsInSync(pullRequest);
    }

    private void verifyPullRequestRefsInSync(PullRequest pullRequest) {
        String fromCommit = pullRequest.getFromRef().getLatestCommit();
        DefaultPullRequestRefs refs = new DefaultPullRequestRefs(agent, pullRequest);
        //A "dark" from ref with a reflog should be created to protect the fromRef's commits from pruning
        String resolvedFrom = refs.resolveDarkFrom();
        assertNotNull("a \"from\" ref should have been created", resolvedFrom);
        assertEquals(fromCommit, resolvedFrom);
        verifyReflog(pullRequest.getRepository(), refs.getDarkFrom());

        Path logsDir = pullRequest.getRepository().getPath().resolve(GitConstants.PATH_LOGS);
        Path reflog = logsDir.resolve(refs.getDarkFrom());
        assertTrue("A reflog should have been created for the \"from\" ref", Files.isRegularFile(reflog));

        if (pullRequest.isOpen()) {
            //A public from ref should also be created, since the pull request is open
            resolvedFrom = refs.resolvePublicFrom();
            assertNotNull("Open pull requests should have a public \"from\" ref", resolvedFrom);
            assertEquals(fromCommit, resolvedFrom);
        } else {
            assertNull(pullRequest.getState() + " pull requests should not have a public \"from\" ref",
                    agent.resolveBranch(pullRequest.getRepository(), refs.getPublicFrom(), true));
        }

        //"Dark" and public merge refs should not be created
        assertNull("a \"merge\" ref should not have been created", refs.resolveDarkMerge());
        assertNull(agent.resolveBranch(pullRequest.getRepository(), refs.getPublicMerge(), true));
    }

    private void verifyReflog(Repository repository, String refId) {
        Path logsDir = repository.getPath().resolve(GitConstants.PATH_LOGS);
        Path reflog = logsDir.resolve(refId);
        assertTrue("A reflog should have been created for the \"" + refId + "\" ref", Files.isRegularFile(reflog));
    }
}
