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

import com.atlassian.bitbucket.mesh.AbstractGrpcTest;
import com.atlassian.bitbucket.mesh.git.GitProcessCompletionHandler;
import com.atlassian.bitbucket.mesh.git.GitRepositories;
import com.atlassian.bitbucket.mesh.git.builder.diff.DiffStdioHandler;
import com.atlassian.bitbucket.mesh.git.content.DiffCallback;
import com.atlassian.bitbucket.mesh.git.content.DiffSegmentType;
import com.atlassian.bitbucket.mesh.git.exception.CommitNotFoundException;
import com.atlassian.bitbucket.mesh.grpc.FragmentResponseObserver;
import com.atlassian.bitbucket.mesh.process.ProcessStdioHandler;
import com.atlassian.bitbucket.mesh.repository.Repository;
import com.atlassian.bitbucket.mesh.rpc.v1.*;
import com.atlassian.bitbucket.mesh.test.HasGitError;
import com.atlassian.bitbucket.mesh.test.annotation.Repo;
import com.google.common.collect.ImmutableSet;
import com.google.protobuf.ByteString;
import io.grpc.ManagedChannel;
import io.grpc.Status;
import io.grpc.StatusRuntimeException;
import org.junit.Before;
import org.junit.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.InOrder;

import javax.annotation.Resource;
import java.io.ByteArrayOutputStream;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import static com.atlassian.bitbucket.mesh.git.GitUtils.NULL_TREE;
import static org.hamcrest.Matchers.emptyString;
import static org.hamcrest.Matchers.equalTo;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;

//Parallels DiffCommandTest in the Git SCM, with some additional tests from DefaultGitCommandFactoryTest
public class DiffRequestTest extends AbstractGrpcTest {

    //protobuf doesn't have the concept of null/unset, so this provides some reasonable defaults that
    //can then be customized as necessary inside various tests, rather than repeating them everywhere
    private static final RpcDiffOptions DEFAULT_OPTIONS = RpcDiffOptions.newBuilder()
            .setColor(RpcDiffColor.COLOR_NEVER)
            .setDstPrefix(DiffStdioHandler.DST_PREFIX)
            .setRenames(RpcDiffRenames.RENAMES_OFF)
            .setSrcPrefix(DiffStdioHandler.SRC_PREFIX)
            .setWhitespace(RpcDiffWhitespace.WHITESPACE_SHOW)
            .build();

    @Repo(GitRepositories.BINARY_FILES)
    private Repository binaryFiles;
    @Captor
    private ArgumentCaptor<String> destinationCaptor;
    @Repo(GitRepositories.DIFF_TREE)
    private Repository diffTree;
    @Resource
    private ManagedChannel managedChannel;
    @Repo(GitRepositories.QA_RESOURCES)
    private Repository qaResources;
    @Repo
    private Repository repository;
    @Captor
    private ArgumentCaptor<String> sourceCaptor;
    private PorcelainServiceGrpc.PorcelainServiceStub stub;

    @Before
    public void setup() {
        stub = PorcelainServiceGrpc.newStub(managedChannel);
    }

    @Test
    public void testBinaryAdd() {
        RpcDiffRequest request = RpcDiffRequest.newBuilder()
                .addPaths(ByteString.copyFromUtf8("B and B.zip"))
                .setDiffOptions(DEFAULT_OPTIONS)
                .setRepository(binaryFiles.getId())
                .setRev(ByteString.copyFromUtf8("951e3a5da1585359746153a30ee83fd9218f6967"))
                .build();

        DiffCallback callback = mock(DiffCallback.class);
        diff(request, new DiffStdioHandler(callback, null, 25, 100));

        InOrder ordered = inOrder(callback);
        ordered.verify(callback).onBinary(isNull(), destinationCaptor.capture());
        ordered.verifyNoMoreInteractions();

        assertPath("B and B.zip", destinationCaptor.getValue());
    }

    @Test
    public void testBinaryModify() {
        RpcDiffRequest request = RpcDiffRequest.newBuilder()
                .addPaths(ByteString.copyFromUtf8("B and B.zip"))
                .setDiffOptions(DEFAULT_OPTIONS)
                .setRepository(binaryFiles.getId())
                .setRev(ByteString.copyFromUtf8("b3bdf3100d55f44cdc61dc7a2ecfa784d650770f"))
                .build();

        DiffCallback callback = mock(DiffCallback.class);
        diff(request, new DiffStdioHandler(callback, null, 25, 100));

        InOrder ordered = inOrder(callback);
        ordered.verify(callback).onBinary(sourceCaptor.capture(), destinationCaptor.capture());
        ordered.verifyNoMoreInteractions();

        assertPath("B and B.zip", sourceCaptor.getValue());
        assertPath("B and B.zip", destinationCaptor.getValue());
    }

    @Test
    public void testBinaryRemove() {
        RpcDiffRequest request = RpcDiffRequest.newBuilder()
                .addPaths(ByteString.copyFromUtf8("B and B.zip"))
                .setDiffOptions(DEFAULT_OPTIONS)
                .setRepository(binaryFiles.getId())
                .setRev(ByteString.copyFromUtf8("986094590bbbec6ae23a531667b77662c78c1625"))
                .build();

        DiffCallback callback = mock(DiffCallback.class);
        diff(request, new DiffStdioHandler(callback, null, 25, 100));

        InOrder ordered = inOrder(callback);
        ordered.verify(callback).onBinary(sourceCaptor.capture(), isNull());
        ordered.verifyNoMoreInteractions();

        assertPath("B and B.zip", sourceCaptor.getValue());
    }

    @Test
    public void testCommitDiff() {
        RpcDiffRequest request = RpcDiffRequest.newBuilder()
                .setDiffOptions(DEFAULT_OPTIONS)
                .setRepository(repository.getId())
                .setRev(ByteString.copyFromUtf8("6053a1eaa1c009dd11092d09a72f3c41af1b59ad"))
                .build();

        DiffCallback callback = mock(DiffCallback.class);
        diff(request, new DiffStdioHandler(callback, null, 25, 132));

        //Exact segment details are tested in testCommitDiff, and not repeated here for brevity
        InOrder ordered = inOrder(callback);
        verifyDiffStart(ordered.verify(callback), "branch_mod_merge/file.txt");
        ordered.verify(callback).onHunkStart(eq(1), eq(1), eq(1), eq(2), isNull());
        ordered.verify(callback).onSegmentStart(same(DiffSegmentType.CONTEXT));
        ordered.verify(callback).onSegmentLine(eq("branch, mod and merge scenario"), eq(false));
        ordered.verify(callback).onSegmentEnd(eq(false));
        ordered.verify(callback).onSegmentStart(same(DiffSegmentType.ADDED));
        ordered.verify(callback).onSegmentLine(eq("a modification"), eq(false));
        ordered.verify(callback).onSegmentEnd(eq(false));
        ordered.verify(callback).onHunkEnd(eq(false));
        ordered.verify(callback).onDiffEnd(eq(false));
        ordered.verifyNoMoreInteractions();
    }

    @Test
    public void testCommitDiffWithCR() {
        RpcDiffRequest request = RpcDiffRequest.newBuilder()
                .addPaths(ByteString.copyFromUtf8("mac.txt"))
                .setDiffOptions(DEFAULT_OPTIONS)
                .setRepository(diffTree.getId())
                .setRev(ByteString.copyFromUtf8("83f0ca32d75e3121c57a3cc2d549a8a01a1cfafc"))
                .build();

        DiffCallback callback = mock(DiffCallback.class);
        diff(request, new DiffStdioHandler(callback, null, 25, 132));

        InOrder ordered = inOrder(callback);
        verifyDiffStart(ordered.verify(callback), "mac.txt");
        ordered.verify(callback).onHunkStart(eq(1), eq(1), eq(1), eq(1), isNull());
        ordered.verify(callback).onSegmentStart(same(DiffSegmentType.REMOVED));
        ordered.verify(callback).onSegmentLine(eq("This is a\rmac file\rwith line endings\rin cr with edit"), eq(false));
        ordered.verify(callback).onSegmentEnd(eq(false));
        ordered.verify(callback).onSegmentStart(same(DiffSegmentType.ADDED));
        ordered.verify(callback).onSegmentLine(eq("This is a\rmac file\rwith line endings\rin cr "), eq(false));
        ordered.verify(callback).onSegmentEnd(eq(false));
        ordered.verify(callback).onHunkEnd(eq(false));
        ordered.verify(callback).onDiffEnd(eq(false));
        ordered.verifyNoMoreInteractions();
    }

    @Test
    public void testCommitDiffWithCRAndLF() {
        RpcDiffRequest request = RpcDiffRequest.newBuilder()
                .addPaths(ByteString.copyFromUtf8("mac_and_unix.txt"))
                .setDiffOptions(DEFAULT_OPTIONS)
                .setRepository(diffTree.getId())
                .setRev(ByteString.copyFromUtf8("83f0ca32d75e3121c57a3cc2d549a8a01a1cfafc"))
                .build();

        DiffCallback callback = mock(DiffCallback.class);
        diff(request, new DiffStdioHandler(callback, null, 25, 132));

        InOrder ordered = inOrder(callback);
        verifyDiffStart(ordered.verify(callback), "mac_and_unix.txt");
        ordered.verify(callback).onHunkStart(eq(9), eq(7), eq(9), eq(7), eq("CR^M\rfor\r ^Mline\r^M"));
        ordered.verify(callback).onSegmentStart(same(DiffSegmentType.REMOVED));
        ordered.verify(callback).onSegmentLine(eq("-edit\rand "), eq(false));
        ordered.verify(callback).onSegmentEnd(eq(false));
        ordered.verify(callback).onSegmentStart(same(DiffSegmentType.ADDED));
        ordered.verify(callback).onSegmentLine(eq("edit\rand "), eq(false));
        ordered.verify(callback).onSegmentEnd(eq(false));
        ordered.verify(callback).onHunkEnd(eq(false));
        ordered.verify(callback).onDiffEnd(eq(false));
        ordered.verifyNoMoreInteractions();
    }

    @Test
    public void testCommitNotFound() {
        RpcDiffRequest request = RpcDiffRequest.newBuilder()
                .setAncestor(ByteString.copyFromUtf8("9fbc34a0d905950131d73f352abe68520c6db2a3"))
                .setDiffOptions(DEFAULT_OPTIONS)
                .setRepository(repository.getId())
                .setRev(ByteString.copyFromUtf8("57150c54c38d6570b2fd5e6d6cfc19476de44e83"))
                .build();

        DiffCallback callback = mock(DiffCallback.class);
        CommitNotFoundException thrown =
                assertThrows(CommitNotFoundException.class, () -> diff(request,
                        new DiffStdioHandler(callback, null, 5000, 5000)));
        assertSame(Status.Code.NOT_FOUND, thrown.getStatus().getCode());
        //If the command fails (and throwing CommitNotFound should fail), ensure the callback is never used
        verifyNoInteractions(callback);
    }

    @Test
    public void testDiffAgainstNullTree() {
        RpcDiffRequest request = RpcDiffRequest.newBuilder()
                .setAncestor(ByteString.copyFromUtf8(NULL_TREE))
                .setDiffOptions(DEFAULT_OPTIONS)
                .setRepository(repository.getId())
                .setRev(ByteString.copyFromUtf8("57150c54c38d6570b2fd5e6d6cfc19476de44e84"))
                .build();

        DiffCallback callback = mock(DiffCallback.class);
        diff(request, new DiffStdioHandler(callback, null, 25, 80));

        // verify that no context or deleted lines are returned (all lines are additions compared to the null tree)
        verify(callback, never()).onSegmentStart(same(DiffSegmentType.CONTEXT));
        verify(callback, never()).onSegmentStart(same(DiffSegmentType.REMOVED));

        InOrder ordered = inOrder(callback);
        verifyDiffStart(ordered.verify(callback), null, "add_file/add_file.txt");
        ordered.verify(callback).onHunkStart(eq(0), eq(0), eq(1), eq(1), isNull());
        ordered.verify(callback).onSegmentLine(eq("this is a test file"), eq(false));
        ordered.verify(callback).onHunkEnd(eq(false));
        ordered.verify(callback).onDiffEnd(eq(false));
        verifyDiffStart(ordered.verify(callback), null, "modification/mod_file.txt");
        ordered.verify(callback).onHunkStart(eq(0), eq(0), eq(1), eq(2), isNull());
        ordered.verify(callback).onSegmentLine(eq("this is a test file"), eq(false));
        ordered.verify(callback).onSegmentLine(eq("another line"), eq(false));
        ordered.verify(callback).onHunkEnd(eq(false));
        ordered.verify(callback).onDiffEnd(eq(false));
        verifyDiffStart(ordered.verify(callback), null, "mv_file/moved_file.txt");
        ordered.verify(callback).onHunkStart(eq(0), eq(0), eq(1), eq(1), isNull());
        ordered.verify(callback).onSegmentLine(eq("This file will be moved"), eq(false));
        ordered.verify(callback).onHunkEnd(eq(false));
        ordered.verify(callback).onDiffEnd(eq(false));
        verifyDiffStart(ordered.verify(callback), null, "out_of_order/file.txt");
        ordered.verify(callback).onHunkStart(eq(0), eq(0), eq(1), eq(3), isNull());
        ordered.verify(callback).onSegmentLine(eq("out of order commits scenario"), eq(false));
        ordered.verify(callback).onSegmentLine(eq("a modification"), eq(false));
        ordered.verify(callback).onSegmentLine(eq("another modification"), eq(false));
        ordered.verify(callback).onHunkEnd(eq(false));
        ordered.verify(callback).onDiffEnd(eq(false));
        ordered.verifyNoMoreInteractions();
    }

    //Hunk and diff end callbacks happen in DiffStdioHandler.onExit, when the process is canceled
    //early due to line limits, so this test verifies those callbacks happen correctly
    @Test
    public void testDiffTruncation() {
        RpcDiffRequest request = RpcDiffRequest.newBuilder()
                .setAncestor(ByteString.copyFromUtf8(NULL_TREE))
                .setDiffOptions(DEFAULT_OPTIONS)
                .setRepository(repository.getId())
                .setRev(ByteString.copyFromUtf8("57150c54c38d6570b2fd5e6d6cfc19476de44e84"))
                .build();

        DiffCallback callback = mock(DiffCallback.class);
        diff(request, new DiffStdioHandler(callback, null, 2, 80));

        InOrder ordered = inOrder(callback);
        verifyDiffStart(ordered.verify(callback), null, "add_file/add_file.txt");
        ordered.verify(callback).onHunkStart(eq(0), eq(0), eq(1), eq(1), isNull());
        ordered.verify(callback).onSegmentLine(eq("this is a test file"), eq(false));
        ordered.verify(callback).onHunkEnd(eq(false));
        ordered.verify(callback).onDiffEnd(eq(false));
        verifyDiffStart(ordered.verify(callback), null, "modification/mod_file.txt");
        ordered.verify(callback).onHunkStart(eq(0), eq(0), eq(1), eq(2), isNull());
        ordered.verify(callback).onSegmentLine(eq("this is a test file"), eq(false));
        ordered.verify(callback).onHunkEnd(eq(true));
        ordered.verify(callback).onDiffEnd(eq(true));
        ordered.verifyNoMoreInteractions();
    }

    @Test
    public void testEmptyDiff() {
        RpcDiffRequest request = RpcDiffRequest.newBuilder()
                .addPaths(ByteString.copyFromUtf8("nonexistent/file.txt"))
                .setAncestor(ByteString.copyFromUtf8("8fb8538799b2481750eac52716931d895d029b7c"))
                .setDiffOptions(DEFAULT_OPTIONS)
                .setRepository(repository.getId())
                .setRev(ByteString.copyFromUtf8("042fb0cc198e7320b59a025689b45a2cb52f4684"))
                .build();

        DiffCallback callback = mock(DiffCallback.class);
        diff(request, new DiffStdioHandler(callback, null, 25, 132));

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

    @Test
    public void testFilteringByPath() {
        RpcDiffRequest request = RpcDiffRequest.newBuilder()
                .setAncestor(ByteString.copyFromUtf8("409a2180fd95b744df8d817e770b7f8c853f78dc"))
                .setDiffOptions(DEFAULT_OPTIONS)
                .setRepository(repository.getId())
                .setRev(ByteString.copyFromUtf8("0a943a29376f2336b78312d99e65da17048951db"))
                .build();

        DiffCallback callback = mock(DiffCallback.class);
        ImmutableSet<String> paths = ImmutableSet.of("add_file/add_file.txt", "binary/C.zip", "out_of_order/file.txt");
        diff(request, new DiffStdioHandler(callback, paths, 25, 5000));

        InOrder ordered = inOrder(callback);
        verifyDiffStart(ordered.verify(callback), null, "add_file/add_file.txt");
        ordered.verify(callback).onDiffEnd(eq(false));
        ordered.verify(callback).onBinary(isNull(), eq("binary/C.zip"));
        verifyDiffStart(ordered.verify(callback), "out_of_order/file.txt");
        ordered.verify(callback).onDiffEnd(eq(false));
        ordered.verifyNoMoreInteractions();
    }

    @Test
    public void testFilteringBySrcPath() {
        RpcDiffRequest request = RpcDiffRequest.newBuilder()
                .setAncestor(ByteString.copyFromUtf8("9c05f43f859375e392d90d23a13717c16d0fdcda"))
                .setDiffOptions(DEFAULT_OPTIONS.toBuilder()
                        .setRenames(RpcDiffRenames.RENAMES_ON))
                .setRepository(repository.getId())
                .setRev(ByteString.copyFromUtf8("2d8897c9ac29ce42c3442cf80ac977057045e7f6"))
                .build();

        DiffCallback callback = mock(DiffCallback.class);
        diff(request, new DiffStdioHandler(callback, ImmutableSet.of("binary/A.zip"), 25, 5000));

        InOrder ordered = inOrder(callback);
        verifyDiffStart(ordered.verify(callback), "binary/A.zip", "binary/B.zip");
        ordered.verify(callback).onDiffEnd(eq(false));
        ordered.verifyNoMoreInteractions();
    }

    @Test
    public void testFilteringWithNoMatches() {
        RpcDiffRequest request = RpcDiffRequest.newBuilder()
                .setAncestor(ByteString.copyFromUtf8("9c05f43f859375e392d90d23a13717c16d0fdcda"))
                .setDiffOptions(DEFAULT_OPTIONS.toBuilder()
                        .setRenames(RpcDiffRenames.RENAMES_ON))
                .setRepository(repository.getId())
                .setRev(ByteString.copyFromUtf8("2d8897c9ac29ce42c3442cf80ac977057045e7f6"))
                .build();

        DiffCallback callback = mock(DiffCallback.class);
        diff(request, new DiffStdioHandler(callback, ImmutableSet.of("non/existent.txt"), 25, 5000));

        //This diff isn't actually empty, but it doesn't include any matches for the paths we're restricting
        //to, so it should stream to the callback as if it were empty
        InOrder ordered = inOrder(callback);
        ordered.verifyNoMoreInteractions();
    }

    @Test
    public void testIgnoreWhitespace() {
        RpcDiffRequest request = RpcDiffRequest.newBuilder()
                .setDiffOptions(DEFAULT_OPTIONS.toBuilder()
                        .setWhitespace(RpcDiffWhitespace.WHITESPACE_IGNORE_ALL))
                .setRepository(repository.getId())
                .setRev(ByteString.copyFromUtf8("0a943a29376f2336b78312d99e65da17048951db"))
                .build();

        DiffCallback callback = mock(DiffCallback.class);
        diff(request, new DiffStdioHandler(callback, null, 5000, 5000));

        InOrder ordered = inOrder(callback);
        ordered.verify(callback).onBinary(isNull(), destinationCaptor.capture());
        verifyNoMoreInteractions(callback);

        assertPath("binary/D.zip", destinationCaptor.getValue());
    }

    @Test
    public void testInvalidAncestor() {
        //Without the commit-ish validation in DefaultGitDiffBuilder this request will (essentially) stream
        //the contents of both /etc/hosts and /etc/passwd
        RpcDiffRequest request = RpcDiffRequest.newBuilder()
                .addPaths(ByteString.copyFromUtf8("/etc/hosts"))
                .addPaths(ByteString.copyFromUtf8("/etc/passwd"))
                .setAncestor(ByteString.copyFromUtf8("--patience"))
                .setDiffOptions(DEFAULT_OPTIONS)
                .setRepository(repository.getId())
                .setRev(ByteString.copyFromUtf8("--no-index"))
                .build();

        StatusRuntimeException thrown =
                assertThrows(StatusRuntimeException.class, () -> diff(request,
                        new DiffStdioHandler(mock(DiffCallback.class), null, 5000, 5000)));
        assertSame(Status.Code.INVALID_ARGUMENT, thrown.getStatus().getCode());
        assertThat(thrown, HasGitError.invalidCommitish("--patience"));
    }

    @Test
    public void testInvalidAncestorWithLeadingSpace() {
        //Without the commit-ish validation in DefaultGitDiffBuilder this request will (essentially) stream
        //the contents of both /etc/hosts and /etc/passwd
        RpcDiffRequest request = RpcDiffRequest.newBuilder()
                .addPaths(ByteString.copyFromUtf8("/etc/hosts"))
                .addPaths(ByteString.copyFromUtf8("/etc/passwd"))
                .setAncestor(ByteString.copyFromUtf8(" --patience"))
                .setDiffOptions(DEFAULT_OPTIONS)
                .setRepository(repository.getId())
                .setRev(ByteString.copyFromUtf8(" --no-index"))
                .build();

        StatusRuntimeException thrown =
                assertThrows(StatusRuntimeException.class, () -> diff(request,
                        new DiffStdioHandler(mock(DiffCallback.class), null, 5000, 5000)));
        assertSame(Status.Code.INVALID_ARGUMENT, thrown.getStatus().getCode());
        assertThat(thrown, HasGitError.invalidCommitish("--patience"));
    }

    @Test
    public void testInvalidRev() {
        //Even without the commit-ish validation done in DefaultGitDiffBuilder this request would still fail,
        //since git diff realizes /etc is outside the repository. The purpose of testing this at all is just
        //to ensure rev(String)'s value gets validated, even if it's not really possible to put together any
        //interesting attack without also passing a flag for ancestor(String)
        RpcDiffRequest request = RpcDiffRequest.newBuilder()
                .addPaths(ByteString.copyFromUtf8("/etc/hosts"))
                .addPaths(ByteString.copyFromUtf8("/etc/passwd"))
                .setAncestor(ByteString.copyFromUtf8("HEAD"))
                .setDiffOptions(DEFAULT_OPTIONS)
                .setRepository(repository.getId())
                .setRev(ByteString.copyFromUtf8("--no-index"))
                .build();

        StatusRuntimeException thrown =
                assertThrows(StatusRuntimeException.class, () -> diff(request,
                        new DiffStdioHandler(mock(DiffCallback.class), null, 5000, 5000)));
        assertSame(Status.Code.INVALID_ARGUMENT, thrown.getStatus().getCode());
        assertThat(thrown, HasGitError.invalidCommitish("--no-index"));
    }

    @Test
    public void testInvalidRevWithLeadingSpace() {
        //Even without the commit-ish validation done in DefaultGitDiffBuilder this request would still fail,
        //since git diff realizes /etc is outside the repository. The purpose of testing this at all is just
        //to ensure rev(String)'s value gets validated, even if it's not really possible to put together any
        //interesting attack without also passing a flag for ancestor(String)
        RpcDiffRequest request = RpcDiffRequest.newBuilder()
                .addPaths(ByteString.copyFromUtf8("/etc/hosts"))
                .addPaths(ByteString.copyFromUtf8("/etc/passwd"))
                .setAncestor(ByteString.copyFromUtf8("HEAD"))
                .setDiffOptions(DEFAULT_OPTIONS)
                .setRepository(repository.getId())
                .setRev(ByteString.copyFromUtf8(" --no-index"))
                .build();

        StatusRuntimeException thrown =
                assertThrows(StatusRuntimeException.class, () -> diff(request,
                        new DiffStdioHandler(mock(DiffCallback.class), null, 5000, 5000)));
        assertSame(Status.Code.INVALID_ARGUMENT, thrown.getStatus().getCode());
        assertThat(thrown, HasGitError.invalidCommitish("--no-index"));
    }

    @Test
    public void testLineTruncation() {
        RpcDiffRequest request = RpcDiffRequest.newBuilder()
                .setAncestor(ByteString.copyFromUtf8("8fb8538799b2481750eac52716931d895d029b7c"))
                .setDiffOptions(DEFAULT_OPTIONS)
                .setRepository(repository.getId())
                .setRev(ByteString.copyFromUtf8("042fb0cc198e7320b59a025689b45a2cb52f4684"))
                .build();

        DiffCallback callback = mock(DiffCallback.class);
        diff(request, new DiffStdioHandler(callback, null, 25, 10));

        InOrder ordered = inOrder(callback);
        verifyDiffStart(ordered.verify(callback), null, "branch_mod_merge/file.txt");
        ordered.verify(callback).onHunkStart(eq(0), eq(0), eq(1), eq(1), isNull());
        ordered.verify(callback).onSegmentStart(same(DiffSegmentType.ADDED));
        ordered.verify(callback).onSegmentLine(eq("branch, mo"), eq(true));
        ordered.verify(callback).onSegmentEnd(eq(false));
        ordered.verify(callback).onHunkEnd(eq(false));
        ordered.verify(callback).onDiffEnd(eq(false));
        ordered.verifyNoMoreInteractions();
    }

    @Test
    public void testMoreCopies() {
        RpcDiffRequest request = RpcDiffRequest.newBuilder()
                .setAncestor(ByteString.copyFromUtf8("e0a3f6d8c804c375f49d7609328111e8dc75a4bc"))
                .setDiffOptions(RpcDiffOptions.newBuilder()
                        .setColor(RpcDiffColor.COLOR_NEVER)
                        .setContextLines(3)
                        .setDstPrefix("dst://")
                        .setRenames(RpcDiffRenames.RENAMES_MORE_COPIES)
                        .setSrcPrefix("src://")
                        .setWhitespace(RpcDiffWhitespace.WHITESPACE_SHOW))
                .setRepository(repository.getId())
                .setRev(ByteString.copyFromUtf8("0a943a29376f2336b78312d99e65da17048951db"))
                .build();

        DiffCallback callback = mock(DiffCallback.class);
        diff(request, new DiffStdioHandler(callback, null, 5000, 5000));

        //This copy is only detected by --find-copies-harder since C.zip is not modified in the same commit
        InOrder ordered = inOrder(callback);
        ordered.verify(callback).onDiffStart(eq("binary/C.zip"), eq("binary/D.zip"));
        ordered.verify(callback).onDiffEnd(eq(false));
        ordered.verifyNoMoreInteractions();
    }

    @Test
    public void testMultipleDiffs() {
        RpcDiffRequest request = RpcDiffRequest.newBuilder()
                .setAncestor(ByteString.copyFromUtf8("409a2180fd95b744df8d817e770b7f8c853f78dc"))
                .setDiffOptions(DEFAULT_OPTIONS)
                .setRepository(repository.getId())
                .setRev(ByteString.copyFromUtf8("0a943a29376f2336b78312d99e65da17048951db"))
                .build();

        DiffCallback callback = mock(DiffCallback.class);
        diff(request, new DiffStdioHandler(callback, null, 25, 5000));

        //The full complement of callback operations is verified in other tests. This focuses entirely on the
        //sequencing of onDiffStart, onDiffEnd
        InOrder ordered = inOrder(callback);
        verifyDiffStart(ordered.verify(callback), null, "add_file/add_file.txt");
        ordered.verify(callback).onDiffEnd(eq(false));
        verifyDiffStart(ordered.verify(callback), null, "basic_branching/file.txt");
        ordered.verify(callback).onDiffEnd(eq(false));
        ordered.verify(callback).onBinary(isNull(), eq("binary/B.zip"));
        ordered.verify(callback).onBinary(isNull(), eq("binary/C.zip"));
        ordered.verify(callback).onBinary(isNull(), eq("binary/D.zip"));
        verifyDiffStart(ordered.verify(callback), null, "branch_mod_merge/file.txt");
        ordered.verify(callback).onDiffEnd(eq(false));
        verifyDiffStart(ordered.verify(callback), null, "modification/mod_file.txt");
        ordered.verify(callback).onDiffEnd(eq(false));
        verifyDiffStart(ordered.verify(callback), null, "moved_dir/file.txt");
        ordered.verify(callback).onDiffEnd(eq(false));
        verifyDiffStart(ordered.verify(callback), null, "mv_file/moved_file.txt");
        ordered.verify(callback).onDiffEnd(eq(false));
        verifyDiffStart(ordered.verify(callback), "out_of_order/file.txt");
        ordered.verify(callback).onDiffEnd(eq(false));
        verifyDiffStart(ordered.verify(callback), null, "symlink/link.txt");
        ordered.verify(callback).onDiffEnd(eq(false));
        verifyDiffStart(ordered.verify(callback), null, "symlink/target.txt");
        ordered.verify(callback).onDiffEnd(eq(false));
        ordered.verifyNoMoreInteractions();
    }

    @Test
    public void testPathToDirectoryWithTrailingWhitespace() {
        RpcDiffRequest request = RpcDiffRequest.newBuilder()
                .addPaths(ByteString.copyFromUtf8("spaces/trailing-space "))
                .setAncestor(ByteString.copyFromUtf8("effbbd10c64eeb5caf3dd1724d17a1ed935c7c62^"))
                .setDiffOptions(DEFAULT_OPTIONS)
                .setRepository(qaResources.getId())
                .setRev(ByteString.copyFromUtf8("effbbd10c64eeb5caf3dd1724d17a1ed935c7c62"))
                .build();

        DiffCallback callback = mock(DiffCallback.class);
        diff(request, new DiffStdioHandler(callback, null, 10, 50));

        InOrder ordered = inOrder(callback);
        verifyDiffStart(ordered.verify(callback), null, "spaces/trailing-space /file.txt");
        ordered.verify(callback).onHunkStart(eq(0), eq(0), eq(1), eq(1), isNull());
        ordered.verify(callback).onSegmentStart(same(DiffSegmentType.ADDED));
        ordered.verify(callback).onSegmentLine(eq("Directory with trailing whitespace"), eq(false));
        ordered.verify(callback).onSegmentEnd(eq(false));
        ordered.verify(callback).onHunkEnd(eq(false));
        ordered.verify(callback).onDiffEnd(eq(false));
        ordered.verifyNoMoreInteractions();
    }

    @Test
    public void testPathToFileWithTrailingWhitespace() {
        RpcDiffRequest request = RpcDiffRequest.newBuilder()
                .addPaths(ByteString.copyFromUtf8("spaces/file.txt "))
                .setAncestor(ByteString.copyFromUtf8("4eae372719e92c48ee67819a5adfa13cc25b59c7^"))
                .setDiffOptions(DEFAULT_OPTIONS)
                .setRepository(qaResources.getId())
                .setRev(ByteString.copyFromUtf8("4eae372719e92c48ee67819a5adfa13cc25b59c7"))
                .build();

        DiffCallback callback = mock(DiffCallback.class);
        diff(request, new DiffStdioHandler(callback, null, 10, 50));

        InOrder ordered = inOrder(callback);
        verifyDiffStart(ordered.verify(callback), null, "spaces/file.txt ");
        ordered.verify(callback).onHunkStart(eq(0), eq(0), eq(1), eq(1), isNull());
        ordered.verify(callback).onSegmentStart(same(DiffSegmentType.ADDED));
        ordered.verify(callback).onSegmentLine(eq("File with trailing whitespace"), eq(false));
        ordered.verify(callback).onSegmentEnd(eq(false));
        ordered.verify(callback).onHunkEnd(eq(false));
        ordered.verify(callback).onDiffEnd(eq(false));
        ordered.verifyNoMoreInteractions();
    }

    @Test
    public void testRenamesOn() {
        RpcDiffRequest request = RpcDiffRequest.newBuilder()
                .setAncestor(ByteString.copyFromUtf8("9c05f43f859375e392d90d23a13717c16d0fdcda"))
                .setDiffOptions(DEFAULT_OPTIONS.toBuilder()
                        .setRenames(RpcDiffRenames.RENAMES_ON))
                .setRepository(repository.getId())
                .setRev(ByteString.copyFromUtf8("2d8897c9ac29ce42c3442cf80ac977057045e7f6"))
                .build();

        DiffCallback callback = mock(DiffCallback.class);
        diff(request, new DiffStdioHandler(callback, null, 25, 100));

        InOrder ordered = inOrder(callback);
        verifyDiffStart(ordered.verify(callback), "binary/A.zip", "binary/B.zip");
        ordered.verify(callback).onDiffEnd(eq(false));
        ordered.verifyNoMoreInteractions();
    }

    @Test
    public void testRenamesOnWithThreshold100() {
        RpcDiffRequest request = RpcDiffRequest.newBuilder()
                .setAncestor(ByteString.copyFromUtf8("9c05f43f859375e392d90d23a13717c16d0fdcda"))
                .setDiffOptions(DEFAULT_OPTIONS.toBuilder()
                        .setRenames(RpcDiffRenames.RENAMES_ON)
                        .setRenameThreshold(100))
                .setRepository(repository.getId())
                .setRev(ByteString.copyFromUtf8("2d8897c9ac29ce42c3442cf80ac977057045e7f6"))
                .build();

        DiffCallback callback = mock(DiffCallback.class);
        diff(request, new DiffStdioHandler(callback, null, 25, 100));

        InOrder ordered = inOrder(callback);
        verifyDiffStart(ordered.verify(callback), "binary/A.zip", "binary/B.zip");
        ordered.verify(callback).onDiffEnd(eq(false));
        ordered.verifyNoMoreInteractions();
    }

    @Test
    public void testRootCommit() {
        RpcDiffRequest request = RpcDiffRequest.newBuilder()
                .setDiffOptions(DEFAULT_OPTIONS)
                .setRepository(repository.getId())
                .setRev(ByteString.copyFromUtf8("409a2180fd95b744df8d817e770b7f8c853f78dc"))
                .build();

        DiffCallback callback = mock(DiffCallback.class);
        diff(request, new DiffStdioHandler(callback, null, 25, 100));

        InOrder ordered = inOrder(callback);
        verifyDiffStart(ordered.verify(callback), null, "out_of_order/file.txt");
        ordered.verify(callback).onHunkStart(eq(0), eq(0), eq(1), eq(1), isNull());
        ordered.verify(callback).onSegmentStart(same(DiffSegmentType.ADDED));
        ordered.verify(callback).onSegmentLine(eq("out of order commits scenario"), eq(false));
        ordered.verify(callback).onSegmentEnd(eq(false));
        ordered.verify(callback).onHunkEnd(eq(false));
        ordered.verify(callback).onDiffEnd(eq(false));
        verifyNoMoreInteractions(callback);
    }

    @Test
    public void testPatterns() {
        assertPattern(DiffStdioHandler.PATTERN_DIFF_HEADER,
                "diff --git src://test.txt dst://test.txt",
                "src://test.txt", "dst://test.txt");
        assertPattern(DiffStdioHandler.PATTERN_DIFF_HEADER,
                "diff --git src://File With Spaces.txt dst://File With Spaces.txt",
                "src://File With Spaces.txt", "dst://File With Spaces.txt");
        assertPattern(DiffStdioHandler.PATTERN_DIFF_HEADER,
                "diff --git \"src://file\\\"withquotes\" \"dst://file\\\"withquotes\"",
                "\"src://file\\\"withquotes\"", "\"dst://file\\\"withquotes\"");
        assertPattern(DiffStdioHandler.PATTERN_HUNK_HEADER,
                "@@ -1 +1,2 @@",
                "1", null, "1", "2", null);
        assertPattern(DiffStdioHandler.PATTERN_HUNK_HEADER,
                "@@ -58,7 +50,6 @@ public class FileHistoryResource {",
                "58", "7", "50", "6", "public class FileHistoryResource {");
    }

    @Test
    public void testStream() {
        RpcDiffRequest request = RpcDiffRequest.newBuilder()
                .setDiffOptions(DEFAULT_OPTIONS)
                .setRepository(repository.getId())
                .setRev(ByteString.copyFromUtf8("409a2180fd95b744df8d817e770b7f8c853f78dc"))
                .build();

        ByteArrayOutputStream stream = new ByteArrayOutputStream();
        diff(request, new com.atlassian.bitbucket.mesh.git.SupplierStdoutHandler(contentType -> stream));

        assertThat(stream.toString(), equalTo("diff --git src://out_of_order/file.txt dst://out_of_order/file.txt\n" +
                "new file mode 100644\n" +
                "index 0000000..30bda1b\n" +
                "--- /dev/null\n" +
                "+++ dst://out_of_order/file.txt\n" +
                "@@ -0,0 +1 @@\n" +
                "+out of order commits scenario\n"));
    }

    //The ancestor and rev commits only exist in the alternate repository, not the base repository
    @Test
    public void testStreamCrossRepository() {
        //The ancestor and rev commits provided here exist in "repository", not "binaryFiles", so the only way
        //this works is if the alternate is configured correctly
        RpcDiffRequest request = RpcDiffRequest.newBuilder()
                .setAncestor(ByteString.copyFromUtf8("8fb8538799b2481750eac52716931d895d029b7c"))
                .setDiffOptions(DEFAULT_OPTIONS.toBuilder()
                        .setWhitespace(RpcDiffWhitespace.WHITESPACE_IGNORE_ALL))
                .setOptions(RpcGitOptions.newBuilder()
                        .addAlternates(repository.getId()))
                .setRepository(binaryFiles.getId())
                .setRev(ByteString.copyFromUtf8("6053a1eaa1c009dd11092d09a72f3c41af1b59ad"))
                .build();

        ByteArrayOutputStream stream = new ByteArrayOutputStream();
        diff(request, new com.atlassian.bitbucket.mesh.git.SupplierStdoutHandler(contentType -> stream));

        assertThat(stream.toString(), equalTo(
                "diff --git src://branch_mod_merge/file.txt dst://branch_mod_merge/file.txt\n" +
                "new file mode 100644\n" +
                "index 0000000..668076c\n" +
                "--- /dev/null\n" +
                "+++ dst://branch_mod_merge/file.txt\n" +
                "@@ -0,0 +1,2 @@\n" +
                "+branch, mod and merge scenario\n" +
                "+a modification\n"));
    }

    @Test
    public void testStreamWithPath() {
        RpcDiffRequest request = RpcDiffRequest.newBuilder()
                .addPaths(ByteString.copyFromUtf8("out_of_order/file.txt"))
                .setDiffOptions(DEFAULT_OPTIONS)
                .setRepository(repository.getId())
                .setRev(ByteString.copyFromUtf8("409a2180fd95b744df8d817e770b7f8c853f78dc"))
                .build();

        ByteArrayOutputStream stream = new ByteArrayOutputStream();
        diff(request, new com.atlassian.bitbucket.mesh.git.SupplierStdoutHandler(contentType -> stream));

        assertThat(stream.toString(), equalTo("diff --git src://out_of_order/file.txt dst://out_of_order/file.txt\n" +
                "new file mode 100644\n" +
                "index 0000000..30bda1b\n" +
                "--- /dev/null\n" +
                "+++ dst://out_of_order/file.txt\n" +
                "@@ -0,0 +1 @@\n" +
                "+out of order commits scenario\n"));
    }

    @Test
    public void testStreamWithUnchangedPathIsEmpty() {
        RpcDiffRequest request = RpcDiffRequest.newBuilder()
                .addPaths(ByteString.copyFromUtf8("out_of_order/unchanged.txt"))
                .setDiffOptions(DEFAULT_OPTIONS)
                .setRepository(repository.getId())
                .setRev(ByteString.copyFromUtf8("409a2180fd95b744df8d817e770b7f8c853f78dc"))
                .build();

        ByteArrayOutputStream stream = new ByteArrayOutputStream();
        diff(request, new com.atlassian.bitbucket.mesh.git.SupplierStdoutHandler(contentType -> stream));

        assertThat(stream.toString(), emptyString());
    }

    private static void assertPath(String expected, String actual) {
        if (expected == null) {
            assertNull(actual);
        } else {
            assertEquals(expected, actual);
        }
    }

    private static void assertPattern(Pattern pattern, String line, String... values) {
        Matcher matcher = pattern.matcher(line);
        assertTrue("Pattern [" + pattern.pattern() + "] should have matched [" + line + "]", matcher.matches());
        assertEquals(values.length, matcher.groupCount());

        for (int i = 0; i < values.length; ++i) {
            String expected = values[i];
            String actual = matcher.group(i + 1);
            if (expected == null) {
                assertNull(actual);
            } else {
                assertEquals(expected, actual);
            }
        }
    }

    private <T> void diff(RpcDiffRequest request, ProcessStdioHandler<T> handler) {
        FragmentResponseObserver<T> responseObserver =
                new FragmentResponseObserver<>(handler, new GitProcessCompletionHandler());

        stub.diff(request, responseObserver);

        responseObserver.asResult();
    }

    private void verifyDiffStart(DiffCallback callback, String path) {
        verifyDiffStart(callback, path, path);
    }

    private void verifyDiffStart(DiffCallback callback, String src, String dst) {
        callback.onDiffStart(sourceCaptor.capture(), destinationCaptor.capture());

        assertPath(src, sourceCaptor.getValue());
        assertPath(dst, destinationCaptor.getValue());

        //Exploiting the fact that Mockito returns the direct list, rather than a copy or immutable wrapper
        destinationCaptor.getAllValues().clear();
        sourceCaptor.getAllValues().clear();
    }
}
