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

import com.atlassian.bitbucket.mesh.AbstractGrpcTest;
import com.atlassian.bitbucket.mesh.MockResponseObserver;
import com.atlassian.bitbucket.mesh.git.GitAgent;
import com.atlassian.bitbucket.mesh.git.GitRepositories;
import com.atlassian.bitbucket.mesh.git.binary.GitBinary;
import com.atlassian.bitbucket.mesh.git.ref.Branch;
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.DirtiesRepo;
import com.atlassian.bitbucket.mesh.test.annotation.Repo;
import com.atlassian.bitbucket.mesh.util.Version;
import com.google.protobuf.ByteString;
import io.grpc.ManagedChannel;
import io.grpc.Status;
import io.grpc.StatusRuntimeException;
import io.grpc.stub.StreamObserver;
import org.junit.Before;
import org.junit.Test;

import javax.annotation.Resource;
import java.util.List;
import java.util.concurrent.TimeoutException;

public class MergeRequestTest extends AbstractGrpcTest {

    @Resource
    private GitAgent agent;
    @Resource
    private GitBinary binary;
    @Resource
    private ManagedChannel managedChannel;
    @Repo(GitRepositories.MERGE)
    private Repository merge;
    @Repo
    private Repository repository;
    private CommitServiceGrpc.CommitServiceStub stub;

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

    @DirtiesRepo("merge")
    @Test
    public void testMerge() throws Exception {
        RpcMergeRequest.Builder builder = createBuilder()
                .setStrategy(RpcMergeStrategy.MERGE_STRATEGY_NO_FF);

        RpcBranch branch = merge(builder).getBranch();

        assertNotNull(branch);
        assertNotEquals("e15bebda897605b1b8ab324e3c8339e4d1814c59", branch.getLatestCommit());
    }

    @DirtiesRepo("merge")
    @Test
    public void testMergeCrossRepositoryWithAllowUnrelated() throws Exception {
        RpcMergeRequest.Builder builder = createBuilder()
                .setAllowUnrelated(true)
                .setFromBranch(ByteString.copyFromUtf8("refs/heads/master"))
                .setFromCommitId("0a943a29376f2336b78312d99e65da17048951db")
                .setFromRepository(repository.getId())
                .setMessage(ByteString.copyFromUtf8("Testing simple merge-and-push operation"))
                .setStrategy(RpcMergeStrategy.MERGE_STRATEGY_NO_FF);

        RpcBranch branch = merge(builder).getBranch();

        assertNotNull(branch);
        assertNotEquals("e15bebda897605b1b8ab324e3c8339e4d1814c59", branch.getLatestCommit());
    }

    @Test
    public void testMergeCrossRepositoryWithoutAllowUnrelated() {
        RpcMergeRequest.Builder builder = createBuilder()
                .setFromBranch(ByteString.copyFromUtf8("refs/heads/master"))
                .setFromCommitId("0a943a29376f2336b78312d99e65da17048951db")
                .setFromRepository(repository.getId())
                .setMessage(ByteString.copyFromUtf8("Testing simple merge-and-push operation"))
                .setStrategy(RpcMergeStrategy.MERGE_STRATEGY_NO_FF);

        StatusRuntimeException thrown = assertMergeThrows(builder);

        assertSame(Status.Code.INVALID_ARGUMENT, thrown.getStatus().getCode());
        assertThat(thrown, HasGitError.invalidMerge(RpcInvalidMerge.Reason.UNRELATED));
    }

    @Test
    public void testMergeWhenAuthorHasNoEmail() {
        RpcMergeRequest.Builder builder = createBuilder()
                .setAuthor(RpcPerson.newBuilder()
                        .setName(ByteString.copyFromUtf8("bob"))
                        .build())
                .setStrategy(RpcMergeStrategy.MERGE_STRATEGY_NO_FF);

        StatusRuntimeException thrown = assertMergeThrows(builder);

        assertSame(Status.Code.INVALID_ARGUMENT, thrown.getStatus().getCode());
        assertThat(thrown, HasGitError.invalidMerge(RpcInvalidMerge.Reason.AUTHOR_EMAIL_BLANK));
    }

    @Test
    public void testMergeWhenAuthorHasNoName() {
        RpcMergeRequest.Builder builder = createBuilder()
                .setAuthor(RpcPerson.getDefaultInstance())
                .setStrategy(RpcMergeStrategy.MERGE_STRATEGY_NO_FF);

        StatusRuntimeException thrown = assertMergeThrows(builder);

        assertSame(Status.Code.INVALID_ARGUMENT, thrown.getStatus().getCode());
        assertThat(thrown, HasGitError.invalidMerge(RpcInvalidMerge.Reason.AUTHOR_NAME_BLANK));
    }

    @Test
    public void testMergeWhenFromBranchIsInvalid() {
        RpcMergeRequest.Builder builder = createBuilder()
                .setFromBranch(ByteString.copyFromUtf8("from-branch-does-not-exist"))
                .setStrategy(RpcMergeStrategy.MERGE_STRATEGY_NO_FF);

        StatusRuntimeException thrown = assertMergeThrows(builder);

        assertSame(Status.Code.NOT_FOUND, thrown.getStatus().getCode());
        assertThat(thrown, HasGitError.noSuchObject("from-branch-does-not-exist"));
    }

    @Test
    public void testMergeWhenMessageIsEmpty() {
        RpcMergeRequest.Builder builder = createBuilder()
                .setMessage(ByteString.EMPTY)
                .setStrategy(RpcMergeStrategy.MERGE_STRATEGY_NO_FF);

        StatusRuntimeException thrown = assertMergeThrows(builder);

        assertSame(Status.Code.INVALID_ARGUMENT, thrown.getStatus().getCode());
        assertThat(thrown, HasGitError.invalidMerge(RpcInvalidMerge.Reason.COMMIT_MESSAGE_BLANK));
    }

    @Test
    public void testMergeWhenToBranchIsInvalid() {
        RpcMergeRequest.Builder builder = createBuilder()
                .setToBranch(ByteString.copyFromUtf8("to-branch-does-not-exist"))
                .setStrategy(RpcMergeStrategy.MERGE_STRATEGY_NO_FF);

        StatusRuntimeException thrown = assertMergeThrows(builder);

        assertSame(Status.Code.NOT_FOUND, thrown.getStatus().getCode());
        assertThat(thrown, HasGitError.noSuchObject("to-branch-does-not-exist"));
    }

    @DirtiesRepo("merge") // only ref update is prevented, the merge commit is still fetched to the 'merge' repo
    @Test
    public void testMergeWithoutUpdateRef() throws Exception {
        MockResponseObserver<RpcMergeResponseFragment> responseObserver = new MockResponseObserver<>();
        StreamObserver<RpcMergeFragment> observer = stub.merge(responseObserver);

        RpcMergeRequest.Builder builder = createBuilder()
                .setMessage(ByteString.copyFromUtf8("Testing merge cancellation - should be fetched"))
                .setStrategy(RpcMergeStrategy.MERGE_STRATEGY_NO_FF);

        observer.onNext(RpcMergeFragment.newBuilder()
                .setMergeRequest(builder.build())
                .build());

        observer.onCompleted();
        responseObserver.await();

        List<RpcMergeResponseFragment> fragments = responseObserver.getFragments();
        assertEquals(1, fragments.size());

        Branch branch = agent.resolveBranch(merge, "branch_that_has_modified_file_no_merge_conflict_trgt");
        assertNotNull("The test branch could not be resolved", branch);
        assertEquals("e15bebda897605b1b8ab324e3c8339e4d1814c59", branch.getLatestCommit());
    }

    @Test
    public void testMergeWithDivergentDirectoryRename() throws Exception {
        RpcMergeRequest.Builder builder = createBuilder("branch_that_has_folder_moved_to_different_path",
                "508e5cd08c1264d5d12e5199d39dfe149e6598a4", RpcMergeStrategy.MERGE_STRATEGY_NO_FF)
                .setRepository(merge.getId());

        MockResponseObserver<RpcMergeResponseFragment> responseObserver = new MockResponseObserver<>();
        StreamObserver<RpcMergeFragment> observer = stub.merge(responseObserver);

        observer.onNext(RpcMergeFragment.newBuilder()
                .setMergeRequest(builder.build())
                .build());

        observer.onCompleted();
        responseObserver.await();

        List<RpcMergeResponseFragment> fragments = responseObserver.getFragments();
        assertEquals(1, fragments.size());

        RpcMergeConflicts conflicts = fragments.get(0).getMergeResponse().getConflicts();
        Version version = binary.getVersion();
        if (version.compareTo(new Version(2, 18)) < 0 || version.compareTo(new Version(2, 34)) >= 0) {
            //Git 2.18 and earlier don't have directory rename detection
            //Git 2.34 and newer use a newer merge algorithm which doesn't output a directory rename conflict
            //for this case, only rename/rename conflicts for file1.txt and file2.txt
            assertEquals(2, conflicts.getConflictsCount());
        } else {
            //Git 2.18-2.33 detect the same two rename/rename conflicts for file1.txt and file2.txt as other
            //versions, but they also output a directory rename conflict for src -> dest1 vs. src -> dest2
            assertEquals(3, conflicts.getConflictsCount());
        }
        conflicts.getConflictsList()
                .forEach(conflict -> assertTrue(conflict.getMessage().startsWith("CONFLICT (rename/rename)")));
    }

    @DirtiesRepo("merge")
    @Test
    public void testMergeWithFastForwardOnlyStrategy() throws Exception {
        RpcMergeRequest.Builder builder = createBuilder(RpcMergeStrategy.MERGE_STRATEGY_FF_ONLY);

        RpcBranch branch = merge(builder).getBranch();

        assertNotNull(branch);
        assertEquals("306d584aa574fa74901d3e288b2931b2c4f0e927", branch.getLatestCommit());
    }

    @Test
    public void testMergeWithFastForwardOnlyStrategyIsRejected() {
        RpcMergeRequest.Builder builder = createBuilder()
                .setStrategy(RpcMergeStrategy.MERGE_STRATEGY_FF_ONLY);

        StatusRuntimeException thrown = assertMergeThrows(builder);

        assertSame(Status.Code.ABORTED, thrown.getStatus().getCode());
        assertThat(thrown, HasGitError.nonFastForwardMerge("1a572938f1ebffaaca33c86a57deb2efcddabb90"));
    }

    @DirtiesRepo("merge")
    @Test
    public void testMergeWithFastForwardStrategy() throws Exception {
        RpcMergeRequest.Builder builder = createBuilder(RpcMergeStrategy.MERGE_STRATEGY_FF);

        RpcBranch branch = merge(builder).getBranch();

        assertNotNull(branch);
        assertEquals("306d584aa574fa74901d3e288b2931b2c4f0e927", branch.getLatestCommit());
    }

    @DirtiesRepo("merge")
    @Test
    public void testMergeWithRebaseFastForwardOnlyStrategy() throws Exception {
        RpcMergeRequest.Builder builder = createBuilder("branch_that_has_same_text_file_modified_on_both_clean_merge",
                "306d584aa574fa74901d3e288b2931b2c4f0e927", RpcMergeStrategy.MERGE_STRATEGY_REBASE_FF_ONLY);

        RpcBranch branch = merge(builder).getBranch();

        assertNotNull(branch);
        assertNotEquals("7fa77c6e34c6c53de0bb880fc2b0b7924a601cd2", branch.getLatestCommit());
    }

    @DirtiesRepo("merge")
    @Test
    public void testMergeWithRebaseFastForwardOnlyStrategyIgnoresPatternLikeFiles() throws Exception {
        //df2fc89 has a file called "AAA\bbb", which, because sparse-info entries are treated as patterns,
        //results in the checkout not including any files. ShinyGitPorcelain should ignore such files, so
        //the checkout (and subsequent rebase) should work correctly.
        RpcMergeRequest.Builder builder = createBuilder("branch_that_has_sparse_filename_filter",
                "ee85213ec42077581cc408fee21cf2dc2278858b", RpcMergeStrategy.MERGE_STRATEGY_REBASE_FF_ONLY);

        RpcBranch branch = merge(builder).getBranch();

        assertNotNull(branch);
        assertNotEquals("df2fc897281251b855c7a8ab663ff7c45689afbb", branch.getLatestCommit());
        //testMergeWithRebaseFastForwardOnlyStrategy already covers this strategy, and what its result looks
        //like. What matters for this test is that the rebase completes successfully
    }

    //Note: Rebase "merges" use a different MergeStrategy, so this test is necessary to ensure the proper error
    //      handling is still performed by the handlers being used.
    @Test
    public void testMergeWithRebaseFastForwardOnlyStrategyThrowsOnConflicts() {
        String branchBase = "branch_that_has_same_text_file_modified";
        String fromHash = "5bb214e6dd15643dabfe4b6b0739becfb1e59d9f";

        RpcMergeRequest.Builder builder =
                createBuilder(branchBase, fromHash, RpcMergeStrategy.MERGE_STRATEGY_REBASE_FF_ONLY);

        StatusRuntimeException thrown = assertMergeThrows(builder);

        assertSame(Status.Code.FAILED_PRECONDITION, thrown.getStatus().getCode());
        assertThat(thrown, HasGitError.rebaseConflicted(fromHash));
    }

    @Test
    public void testMergeWithRebaseFastForwardOnlyStrategyThrowsOnEmptyRebase() {
        RpcMergeRequest.Builder builder = createBuilder("refs/heads/branch_that_differ_by_empty_commit",
                "017caf31eca7c46eb3d1800fcac431cfa7147a01", RpcMergeStrategy.MERGE_STRATEGY_REBASE_FF_ONLY);

        StatusRuntimeException thrown = assertMergeThrows(builder);

        assertSame(Status.Code.FAILED_PRECONDITION, thrown.getStatus().getCode());
        assertThat(thrown, HasGitError.emptyRebaseMerge());
    }

    /**
     * Verifies the {@link GitMergeStrategy#REBASE_NO_FF "Rebase and merge"} strategy.
     * <p>
     * Note that, unlike {@link GitMergeStrategy#REBASE_FF_ONLY "Rebase and fast-forward}, there aren't additional
     * tests for empty rebases or conflicts. The rebased commits are <i>always</i> fast-forward from the toRef, so
     * {@code git merge --no-ff} should never fail due to conflicts.
     */
    @DirtiesRepo("merge")
    @Test
    public void testMergeWithRebaseNoFastForwardStrategy() throws Exception {
        String toId = "7fa77c6e34c6c53de0bb880fc2b0b7924a601cd2";

        RpcMergeRequest.Builder builder = createBuilder("branch_that_has_same_text_file_modified_on_both_clean_merge",
                "306d584aa574fa74901d3e288b2931b2c4f0e927", RpcMergeStrategy.MERGE_STRATEGY_REBASE_NO_FF)
                .setToCommitId(toId);

        RpcBranch branch = merge(builder).getBranch();

        //306d584 should be rebased onto 7fa77c6 and create a new commit, and then that new commit should be
        //(redundantly) merged to 7fa77c6
        assertNotNull(branch);
        assertNotEquals(toId, branch.getLatestCommit());
    }

    @DirtiesRepo("merge")
    @Test
    public void testMergeWithSquashFastForwardOnlyStrategy() throws Exception {
        RpcMergeRequest.Builder builder = createBuilder(RpcMergeStrategy.MERGE_STRATEGY_SQUASH_FF_ONLY);

        RpcBranch branch = merge(builder).getBranch();

        assertNotNull(branch);
        assertNotEquals("017caf31eca7c46eb3d1800fcac431cfa7147a01", branch.getLatestCommit());
    }

    @Test
    public void testMergeWithSquashFastForwardOnlyStrategyThrowsInsteadOfMerging() {
        String branchBase = "branch_that_has_same_text_file_modified_on_both_clean_merge";
        String fromHash = "306d584aa574fa74901d3e288b2931b2c4f0e927";

        //git merge --ff-only should exit non-zero on non-fast-forward merges, triggering a GitMergeException
        RpcMergeRequest.Builder builder =
                createBuilder(branchBase, fromHash, RpcMergeStrategy.MERGE_STRATEGY_SQUASH_FF_ONLY);

        StatusRuntimeException thrown = assertMergeThrows(builder);

        assertSame(Status.Code.ABORTED, thrown.getStatus().getCode());
        assertThat(thrown, HasGitError.nonFastForwardMerge(fromHash));
    }

    @DirtiesRepo("merge")
    @Test
    public void testMergeWithSquashStrategy() throws Exception {
        String toId = "7fa77c6e34c6c53de0bb880fc2b0b7924a601cd2";

        RpcMergeRequest.Builder builder = createBuilder("branch_that_has_same_text_file_modified_on_both_clean_merge",
                "306d584aa574fa74901d3e288b2931b2c4f0e927", RpcMergeStrategy.MERGE_STRATEGY_SQUASH)
                .setToCommitId(toId);

        RpcBranch branch = merge(builder).getBranch();

        assertNotNull(branch);
        assertNotEquals(toId, branch.getLatestCommit());
    }

    //Note: Squash merges use a different MergeStrategy, so this test is necessary to ensure the proper error handling
    //      is still performed by the output handler being used.
    @Test
    public void testMergeWithSquashStrategyThrowsOnConflicts() throws Exception {
        String branchBase = "branch_that_has_same_text_file_modified";
        String fromHash = "5bb214e6dd15643dabfe4b6b0739becfb1e59d9f";

        MockResponseObserver<RpcMergeResponseFragment> responseObserver = new MockResponseObserver<>();
        StreamObserver<RpcMergeFragment> observer = stub.merge(responseObserver);

        RpcMergeRequest.Builder builder = createBuilder(branchBase, fromHash, RpcMergeStrategy.MERGE_STRATEGY_SQUASH);

        observer.onNext(RpcMergeFragment.newBuilder()
                .setMergeRequest(builder.build())
                .build());

        observer.onCompleted();
        responseObserver.await();

        List<RpcMergeResponseFragment> fragments = responseObserver.getFragments();
        assertEquals(1, fragments.size());

        RpcMergeConflicts conflicts = fragments.get(0).getMergeResponse().getConflicts();
        assertEquals(fromHash, conflicts.getFromCommitId());
        assertEquals(1, conflicts.getConflictsCount());

        RpcMergeConflict conflict = conflicts.getConflicts(0);
        assertEquals("CONFLICT (content): Merge conflict in file.txt", conflict.getMessage());
        assertEquals(1, conflict.getMoreMessagesCount());
        assertEquals("Squash commit -- not updating HEAD", conflict.getMoreMessages(0));
    }

    @Test
    public void testMergeWithSquashStrategyThrowsOnEmptyCommit() {
        RpcMergeRequest.Builder builder = createBuilder("refs/heads/branch_that_differ_by_empty_commit",
                "017caf31eca7c46eb3d1800fcac431cfa7147a01", RpcMergeStrategy.MERGE_STRATEGY_SQUASH);

        StatusRuntimeException thrown = assertMergeThrows(builder);

        assertSame(Status.Code.FAILED_PRECONDITION, thrown.getStatus().getCode());
        assertThat(thrown, HasGitError.emptySquashMerge());
    }

    @Test
    public void testMergeWithUnsupportedMergeStrategy() {
        RpcMergeRequest.Builder builder = createBuilder()
                .setStrategyValue(99);

        StatusRuntimeException thrown = assertMergeThrows(builder);

        assertSame(Status.Code.INVALID_ARGUMENT, thrown.getStatus().getCode());
        assertThat(thrown, HasGitError.unsupportedMergeStrategy(RpcMergeStrategy.UNRECOGNIZED));
    }

    private StatusRuntimeException assertMergeThrows(RpcMergeRequest.Builder builder) {
        return assertThrows(StatusRuntimeException.class, () -> merge(builder));
    }

    private RpcMergeRequest.Builder createBuilder() {
        return RpcMergeRequest.newBuilder()
                .setAuthor(RpcPerson.newBuilder()
                        .setEmail("jdoe@test.com")
                        .setName(ByteString.copyFromUtf8("John Doe"))
                        .build())
                .setCommitter(RpcPerson.newBuilder()
                        .setEmail("janed@test.com")
                        .setName(ByteString.copyFromUtf8("Jane Doe"))
                        .build())
                .setFromBranch(ByteString.copyFromUtf8("branch_that_has_modified_file_no_merge_conflict_src"))
                .setFromCommitId("1a572938f1ebffaaca33c86a57deb2efcddabb90")
                .setMessage(ByteString.copyFromUtf8("This merge should succeed and be fetched"))
                .setRepository(merge.getId())
                .setToBranch(ByteString.copyFromUtf8("branch_that_has_modified_file_no_merge_conflict_trgt"));
    }

    private RpcMergeRequest.Builder createBuilder(RpcMergeStrategy strategy) {
        return createBuilder("branch_that_has_modified_file_no_merge_conflict_src",
                "306d584aa574fa74901d3e288b2931b2c4f0e927", "master", strategy);
    }

    private RpcMergeRequest.Builder createBuilder(String base, String fromHash, RpcMergeStrategy strategy) {
        return createBuilder(base + "_src", fromHash, base + "_trgt", strategy);
    }

    private RpcMergeRequest.Builder createBuilder(String fromBranch, String fromHash, String toBranch,
                                                  RpcMergeStrategy strategy) {
        return RpcMergeRequest.newBuilder()
                .setAuthor(RpcPerson.newBuilder()
                        .setEmail("jdoe@test.com")
                        .setName(ByteString.copyFromUtf8("John Doe"))
                        .build())
                .setCommitter(RpcPerson.newBuilder()
                        .setEmail("janed@test.com")
                        .setName(ByteString.copyFromUtf8("Jane Doe"))
                        .build())
                .setFromBranch(ByteString.copyFromUtf8(fromBranch))
                .setFromCommitId(fromHash)
                .setLog(20)
                .setMessage(ByteString.copyFromUtf8("This merge should succeed and be fetched"))
                .setRepository(merge.getId())
                .setStrategy(strategy)
                .setToBranch(ByteString.copyFromUtf8(toBranch));
    }

    private RpcMergeResponse merge(RpcMergeRequest.Builder builder)
            throws TimeoutException, InterruptedException {
        MockResponseObserver<RpcMergeResponseFragment> responseObserver = new MockResponseObserver<>();
        StreamObserver<RpcMergeFragment> observer = stub.merge(responseObserver);

        observer.onNext(RpcMergeFragment.newBuilder()
                .setMergeRequest(builder.build())
                .build());

        observer.onNext(RpcMergeFragment.newBuilder()
                .setUpdateRef(RpcUpdateRef.getDefaultInstance())
                .build());

        observer.onCompleted();
        responseObserver.await();

        List<RpcMergeResponseFragment> fragments = responseObserver.getFragments();
        //Fragment 0 is the merge hooks fragment, fragment 1 is the merge result
        assertNotNull(fragments);
        assertEquals(2, fragments.size());
        RpcMergeResponseFragment fragment = fragments.get(1);
        assertTrue(fragment.hasMergeResponse());
        return fragment.getMergeResponse();
    }
}
