package com.atlassian.bitbucket.mesh.git;

import com.atlassian.bitbucket.mesh.AbstractGrpcServiceTest;
import com.atlassian.bitbucket.mesh.git.binary.GitBinary;
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.common.collect.ImmutableList;
import com.google.protobuf.ByteString;
import io.grpc.Status;
import io.grpc.StatusRuntimeException;
import org.apache.commons.lang3.mutable.Mutable;
import org.apache.commons.lang3.mutable.MutableObject;
import org.junit.Before;
import org.junit.Test;
import org.springframework.beans.factory.annotation.Autowired;

import java.util.*;

import static com.atlassian.bitbucket.mesh.rpc.util.ByteStringUtils.toByteStringsLazily;
import static org.hamcrest.Matchers.*;

public class GrpcCommitServiceTest extends AbstractGrpcServiceTest {

    @Autowired
    private GitBinary binary;
    @Repo(GitRepositories.MERGE)
    private Repository merge;
    @Repo
    private Repository repository;
    @Repo(GitRepositories.QA_RESOURCES)
    private Repository qaResources;
    private CommitServiceGrpc.CommitServiceBlockingStub stub;
    private Version version;

    @Before
    public void setup() {
        stub = CommitServiceGrpc.newBlockingStub(managedChannel);
        version = binary.getVersion();
    }

    @Test
    public void testGetCommit() {
        RpcGetCommitResponse response = stub.getCommit(RpcGetCommitRequest.newBuilder()
                .setCommitish(ByteString.copyFromUtf8("0a943a29376f2336b78312d99e65da17048951db"))
                .setMaxMessageLength(Integer.MAX_VALUE)
                .setRepository(repository.getId())
                .build());

        RpcCommit commit = response.getCommit();
        assertEquals("0a943a29376f2336b78312d99e65da17048951db", commit.getId());
        assertEquals("Anna Buttfield", commit.getAuthor().getName().toStringUtf8());
        assertEquals("abuttfield@cascade.sydney.atlassian.com", commit.getAuthor().getEmail());
        assertEquals("Copy C.zip as D.zip\n", commit.getMessage().toStringUtf8());
        assertEquals(1, commit.getParentsCount());
        assertEquals("e0a3f6d8c804c375f49d7609328111e8dc75a4bc", commit.getParentsList().get(0));
    }

    @Test
    public void testGetCommitNotFound() {
        StatusRuntimeException thrown = assertThrows(StatusRuntimeException.class,
                () -> stub.getCommit(RpcGetCommitRequest.newBuilder()
                        .setCommitish(ByteString.copyFromUtf8("deadbeef"))
                        .setRepository(repository.getId())
                        .build()));
        assertSame(Status.Code.NOT_FOUND, thrown.getStatus().getCode());
        assertThat(thrown, HasGitError.noSuchCommit("deadbeef"));
    }

    @Test
    public void testGetCommitNonexistentFile() {
        StatusRuntimeException thrown = assertThrows(StatusRuntimeException.class,
                () -> stub.getCommit(RpcGetCommitRequest.newBuilder()
                        .setCommitish(ByteString.copyFromUtf8("0a943a29376f2336b78312d99e65da17048951db"))
                        .setPath(ByteString.copyFromUtf8("nonexistent-file.txt"))
                        .setRepository(repository.getId())
                        .build()));
        assertSame(Status.Code.NOT_FOUND, thrown.getStatus().getCode());
        assertThat(thrown, HasGitError.noSuchPath("0a943a29376f2336b78312d99e65da17048951db", "nonexistent-file.txt"));
    }

    @Test
    public void testGetCommitReturnsFullMessageWhenMessageLengthUnset() {
        RpcGetCommitResponse response = stub.getCommit(RpcGetCommitRequest.newBuilder()
                .setCommitish(ByteString.copyFromUtf8("0a943a29376f2336b78312d99e65da17048951db"))
                .setRepository(repository.getId())
                .build());

        RpcCommit commit = response.getCommit();
        assertEquals("0a943a29376f2336b78312d99e65da17048951db", commit.getId());
        assertEquals("Anna Buttfield", commit.getAuthor().getName().toStringUtf8());
        assertEquals("abuttfield@cascade.sydney.atlassian.com", commit.getAuthor().getEmail());
        assertEquals(1, commit.getParentsCount());
        assertEquals("e0a3f6d8c804c375f49d7609328111e8dc75a4bc", commit.getParentsList().get(0));
        assertEquals("Copy C.zip as D.zip\n", commit.getMessage().toStringUtf8());
    }

    @Test
    public void testGetCommits() {
        Iterator<RpcGetCommitsResponseFragment> responses = stub.getCommits(RpcGetCommitsRequest.newBuilder()
                .addIncludes(ByteString.copyFromUtf8("master"))
                .setOrder(RpcRevListOrder.ORDER_TOPOLOGICAL)
                .setRepository(qaResources.getId())
                .build());

        Map<String, RpcChange> changesByCommit = new HashMap<>();
        List<String> commitIds = new ArrayList<>(400);
        responses.forEachRemaining(response -> {
            changesByCommit.putAll(response.getRenameChangesMap());

            response.getCommitsList().forEach(commit -> commitIds.add(commit.getId()));
        });

        assertThat(changesByCommit, anEmptyMap()); //Without --follow, no renames should be noted
        assertThat(commitIds, containsInRelativeOrder(
                //These are the first three commits
                "4eae372719e92c48ee67819a5adfa13cc25b59c7",
                "effbbd10c64eeb5caf3dd1724d17a1ed935c7c62",
                "03b585639e86076d30a0b3284bf4f0c90b8c7ae5",
                //These are the last three commits
                "3e95f2982fc1ddab45fa66a3d34815d05355d5b0",
                "9da010a7b6238c00a1e14504f385b4c2f1f87264",
                "395d400195edc4d1ef9c59a66fa385de76f0135f"));
        assertThat(commitIds, hasSize(400)); //Can't allOf these together due to mismatched type bounds
    }

    @Test
    public void testGetCommonAncestor() {
        RpcGetCommonAncestorResponse response = stub.getCommonAncestor(RpcGetCommonAncestorRequest.newBuilder()
                .addAllCommitishes(toByteStringsLazily(ImmutableList.of(
                        "d6edcbf924697ab811a867421dab60d954ccad99",   // basic_branching
                        "0a943a29376f2336b78312d99e65da17048951db",   // master
                        "9b4e781dea0769888fe270e06ad76675f73851b1"))) // resigned_signed_tag
                .setRepository(repository.getId())
                .build());
        assertEquals("8fb8538799b2481750eac52716931d895d029b7c", response.getCommitId());
    }

    @Test
    public void testLastModified() {
        Iterator<RpcLastModifiedResponseFragment> fragments = stub.lastModified(RpcLastModifiedRequest.newBuilder()
                .setCommitish(ByteString.copyFromUtf8("0a943a29376f2336b78312d99e65da17048951db"))
                .setMaxMessageLength(0)
                .setPath(ByteString.copyFromUtf8("binary"))
                .setRepository(repository.getId())
                .build());

        Map<String, String> commitsByFile = new HashMap<>();
        Mutable<RpcCommit> latestCommit = new MutableObject<>();
        fragments.forEachRemaining(fragment -> {
            if (fragment.hasResponse()) {
                for (RpcLastModified lastModified : fragment.getResponse().getModificationsList()) {
                    commitsByFile.put(lastModified.getPath().toStringUtf8(), lastModified.getCommit().getId());
                }
            } else if (fragment.hasLatestCommit()) {
                latestCommit.setValue(fragment.getLatestCommit());
            } else {
                fail("Unexpected fragment: " + fragment.getFragmentOneofCase());
            }
        });

        assertThat(commitsByFile, allOf(
                hasEntry("B.zip", "2d8897c9ac29ce42c3442cf80ac977057045e7f6"),
                hasEntry("C.zip", "e0a3f6d8c804c375f49d7609328111e8dc75a4bc"),
                hasEntry("D.zip", "0a943a29376f2336b78312d99e65da17048951db"),
                aMapWithSize(3)));
        assertThat(latestCommit.getValue(), hasProperty("id", equalTo("0a943a29376f2336b78312d99e65da17048951db")));
    }

    @Test
    public void testTryMerge() {
        RpcMergeRequest request = createMergeRequestBuilder()
                .setFromBranch(ByteString.copyFromUtf8("branch_that_has_modified_file_no_merge_conflict_src"))
                .setFromCommitId("1a572938f1ebffaaca33c86a57deb2efcddabb90")
                .setRepository(merge.getId())
                .setStrategy(RpcMergeStrategy.MERGE_STRATEGY_NO_FF)
                .setToBranch(ByteString.copyFromUtf8("branch_that_has_modified_file_no_merge_conflict_trgt"))
                .build();

        RpcTryMergeResponse response = stub.tryMerge(request);
        assertFalse(response.hasConflicts());
    }

    @Test
    public void testTryMergeWithConflict() {
        String fromCommitId = "956766bea961e476bd50d8384a5a205ee1c899c0";

        RpcMergeRequest request = createMergeRequestBuilder()
                .setFromBranch(ByteString.copyFromUtf8("branch_that_has_different_files_with_same_name_added_src"))
                .setFromCommitId(fromCommitId)
                .setRepository(merge.getId())
                .setStrategy(RpcMergeStrategy.MERGE_STRATEGY_NO_FF)
                .setToBranch(ByteString.copyFromUtf8("branch_that_has_different_files_with_same_name_added_trgt"))
                .setToCommitId("6d45f46d4c9347b3fd38b650f75ac43d6e4bb8b0")
                .build();

        RpcTryMergeResponse response = stub.tryMerge(request);
        assertTrue(response.hasConflicts());
        RpcMergeConflict conflict = response.getConflicts().getConflicts(0);
        assertEquals("CONFLICT (add/add): Merge conflict in file.txt", conflict.getMessage());
        //Git 2.21+ includes an "Auto-merging" line, while earlier versions do not
        assertThat(conflict.getMoreMessagesList(),
                either(emptyCollectionOf(String.class)).or(contains("Auto-merging file.txt")));
    }

    @DirtiesRepo("merge")
    @Test
    public void testTryRebase() {
        RpcRebaseRequest request = createRebaseRequestBuilder("branch_that_has_same_text_file_modified_on_both_clean_merge_src")
                .setRepository(merge.getId())
                .setUpstream(ByteString.copyFromUtf8("7fa77c6e34c6c53de0bb880fc2b0b7924a601cd2"))
                .build();

        stub.tryRebase(request);
    }

    @Test
    public void testTryRebaseAllowsDiscardingAllCommitsByDefault() {
        RpcRebaseRequest request = createRebaseRequestBuilder("branch_that_differ_by_empty_commit_src")
                .setRepository(merge.getId())
                .setUpstream(ByteString.copyFromUtf8("298924b8c403240eaf89dcda0cce7271620ab2f6"))
                .build();

        stub.tryRebase(request);
    }

    @Test
    public void testTryRebaseCrossRepository() {
        RpcRebaseRequest request = createRebaseRequestBuilder("refs/heads/master")
                .setRepository(merge.getId())
                .setUpstream(ByteString.copyFromUtf8("0a943a29376f2336b78312d99e65da17048951db"))
                .setUpstreamRepository(repository.getId())
                .build();

        stub.tryRebase(request);
    }

    @Test
    public void testTryRebaseWhenCommitterHasNoEmail() {
        RpcRebaseRequest request = createRebaseRequestBuilder("branch_that_differ_by_empty_commit_src")
                .setCommitter(RpcPerson.newBuilder()
                        .setName(ByteString.copyFromUtf8("bob"))
                        .build())
                .setRepository(merge.getId())
                .build();

        StatusRuntimeException thrown = assertThrows(StatusRuntimeException.class, () -> stub.tryRebase(request));

        assertSame(Status.Code.INVALID_ARGUMENT, thrown.getStatus().getCode());
        assertThat(thrown, HasGitError.invalidRebase(RpcInvalidRebase.Reason.COMMITTER_EMAIL_BLANK));
    }

    @Test
    public void testTryRebaseWhenCommitterHasNoName() {
        RpcRebaseRequest request = createRebaseRequestBuilder("branch_that_differ_by_empty_commit_src")
                .setCommitter(RpcPerson.getDefaultInstance())
                .setRepository(merge.getId())
                .build();

        StatusRuntimeException thrown = assertThrows(StatusRuntimeException.class, () -> stub.tryRebase(request));

        assertSame(Status.Code.INVALID_ARGUMENT, thrown.getStatus().getCode());
        assertThat(thrown, HasGitError.invalidRebase(RpcInvalidRebase.Reason.COMMITTER_NAME_BLANK));
    }

    @Test
    public void testTryRebaseThrowsOnConflicts() {
        RpcRebaseRequest request = createRebaseRequestBuilder("branch_that_has_same_text_file_modified_src")
                .setRepository(merge.getId())
                .setUpstream(ByteString.copyFromUtf8("c1654113b22f65f988f5a570d8cf32c7288ee7d2"))
                .build();

        StatusRuntimeException thrown = assertThrows(StatusRuntimeException.class, () -> stub.tryRebase(request));

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

    @Test
    public void testTryRebaseThrowsIfAllCommitsAreDiscardedIfRequested() {
        RpcRebaseRequest request = createRebaseRequestBuilder("branch_that_differ_by_empty_commit_src")
                .setAllowEmpty(false)
                .setRepository(merge.getId())
                .setUpstream(ByteString.copyFromUtf8("298924b8c403240eaf89dcda0cce7271620ab2f6"))
                .build();

        StatusRuntimeException thrown = assertThrows(StatusRuntimeException.class, () -> stub.tryRebase(request));

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

    @DirtiesRepo("merge")
    @Test
    public void testTryRebaseThrowsOnEmptyCommit() {
        //The two branches used must not be fast-forward from each other, or the rebase is skipped. These two
        //branches are diverged, each with one commit the other doesn't have, with "empty_commit_trgt" having
        //a single empty commit that's not present on "one_file_src" (7549846524f)
        RpcRebaseRequest request = createRebaseRequestBuilder("branch_that_differ_by_empty_commit_trgt")
                .setAllowEmpty(false)
                .setRepository(merge.getId())
                .setUpstream(ByteString.copyFromUtf8("7549846524f8aed2bd1c0249993ae1bf9d3c9998"))
                .build();

        //Git 2.25 and earlier use the "apply" (formerly "am") backend for rebase. 2.26+ default to the "merge"
        //backend instead, which changes the behavior
        boolean isApplyBased = version.compareTo(new Version(2, 26)) < 0;
        if (isApplyBased) {
            StatusRuntimeException thrown = assertThrows(StatusRuntimeException.class, () -> stub.tryRebase(request));

            assertSame(Status.Code.FAILED_PRECONDITION, thrown.getStatus().getCode());
            assertThat(thrown, HasGitError.emptyRebase());
        } else {
            stub.tryRebase(request);
        }
    }

    private static RpcMergeRequest.Builder createMergeRequestBuilder() {
        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());
    }

    private RpcRebaseRequest.Builder createRebaseRequestBuilder(String branch) {
        return RpcRebaseRequest.newBuilder()
                .setAllowEmpty(true)
                .setCommitter(RpcPerson.newBuilder()
                        .setEmail("janed@test.com")
                        .setName(ByteString.copyFromUtf8("Jane Doe"))
                        .build())
                .setBranch(ByteString.copyFromUtf8(branch));
    }
}
