package com.atlassian.stash.internal.scm.git;

import com.atlassian.bitbucket.commit.Commit;
import com.atlassian.bitbucket.commit.CommitCallback;
import com.atlassian.bitbucket.commit.CommitContext;
import com.atlassian.bitbucket.commit.CommitSummary;
import com.atlassian.bitbucket.compare.CompareRef;
import com.atlassian.bitbucket.compare.CompareRequest;
import com.atlassian.bitbucket.content.*;
import com.atlassian.bitbucket.hamcrest.SimpleTypeSafeMatcher;
import com.atlassian.bitbucket.i18n.I18nService;
import com.atlassian.bitbucket.repository.Repository;
import com.atlassian.bitbucket.scm.annotation.Repo;
import com.atlassian.bitbucket.scm.compare.CompareChangeCommandParameters;
import com.atlassian.bitbucket.scm.compare.CompareDiffCommandParameters;
import com.atlassian.bitbucket.scm.git.command.GitCommandBuilderFactory;
import com.atlassian.stash.internal.scm.git.util.ConfigurableGitScmConfig;
import com.google.common.collect.ImmutableList;
import org.hamcrest.Matcher;
import org.junit.Before;
import org.junit.ClassRule;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.ExpectedException;
import org.junit.rules.TemporaryFolder;
import org.mockito.InOrder;

import javax.annotation.Resource;
import java.io.IOException;
import java.util.List;

import static com.atlassian.bitbucket.util.MoreCollectors.toImmutableList;
import static com.atlassian.stash.internal.scm.git.GitFactoriesTestHelper.StringHandler;
import static com.atlassian.stash.internal.scm.git.GitFactoriesTestHelper.StringInputHandler;
import static org.hamcrest.Matchers.either;
import static org.hamcrest.Matchers.equalTo;
import static org.mockito.Matchers.argThat;
import static org.mockito.Matchers.eq;
import static org.mockito.Matchers.isNull;
import static org.mockito.Matchers.notNull;
import static org.mockito.Matchers.same;
import static org.mockito.Mockito.any;
import static org.mockito.Mockito.*;
import static org.mockito.Mockito.startsWith;

public class DefaultGitCompareCommandFactoryTest extends AbstractGitTest {

    private static final String UNCONNECTED_REF = "unconnected_ref";

    @ClassRule
    public static TemporaryFolder temporaryFolder = new TemporaryFolder();

    private static Repository cachedFork;

    @Rule
    public ExpectedException expectedException = ExpectedException.none();

    @Resource
    private GitCommandBuilderFactory builderFactory;
    private DefaultGitCompareCommandFactory compareCommandFactory;
    @Resource
    private ConfigurableGitScmConfig config;
    private Repository fork;
    @Resource
    private I18nService i18nService;
    @Repo("classpath:git/merge.zip")
    private Repository repository;
    @Resource
    private GitRepositoryConfig repositoryConfig;

    @Before
    public void setup() throws Exception {
        compareCommandFactory = new DefaultGitCompareCommandFactory(builderFactory, repositoryConfig, i18nService, config);

        // injecting repository and fork with the same target (e.g. "classpath:git/merge.zip") actually injects
        // the same repository instance twice because of the repository cache in RepoTestExecutionListener;
        // so, in order to actually test cross-repository requests, we need to manually fork the repository;
        // (see DefaultGitPullRequestCommandFactoryTest.testMergeWithDeepHierarchy() for another example)
        fork = prepareFork(repository);
    }

    @Test
    public void testChanges() throws Exception {
        for (CompareRequest request : compareRequests()) {
            ChangeCallback callback = mock(ChangeCallback.class);
            when(callback.onChange(any(Change.class))).thenReturn(true); // to prevent stopping after the first
            compareCommandFactory.changes(request,
                    new CompareChangeCommandParameters.Builder().maxChanges(10).build(),
                    callback).call();

            InOrder ordered = inOrder(callback);
            ordered.verify(callback).onStart(argThat(changeContext("017caf31eca7c46eb3d1800fcac431cfa7147a01", request.getFromRef().getId())));
            ordered.verify(callback).onChange(argThat(change(ChangeType.ADD, "a.txt")));
            ordered.verify(callback).onEnd(notNull(ChangeSummary.class));
            ordered.verifyNoMoreInteractions();
        }
    }

    @Test
    public void testChangesWithIdenticalRefs() throws Exception {
        for (CompareRequest request : identicalRefs(compareRequests())) {
            ChangeCallback callback = mock(ChangeCallback.class);
            compareCommandFactory.changes(request,
                    new CompareChangeCommandParameters.Builder().maxChanges(10).build(),
                    callback).call();

            InOrder ordered = inOrder(callback);
            ordered.verify(callback).onStart(argThat(changeContext(
                    either(equalTo(request.getFromRef().getId())).// skips of the resolution of the merge base when the two (identical) refs are full hashes or on the same repository
                           or(equalTo("642a6300c7f029f08da5583a54486c9cbfe03ab9")),
                    equalTo(request.getToRef().getId()))));
            ordered.verify(callback).onEnd(notNull(ChangeSummary.class));
            ordered.verifyNoMoreInteractions();
        }
    }

    @Test
    public void testChangesWithNoCommonAncestor() throws Exception {
        ChangeCallback callback = mock(ChangeCallback.class);
        when(callback.onChange(any(Change.class))).thenReturn(true);
        CompareRequest request = noCommonAncestorRequest("017caf31eca7c46eb3d1800fcac431cfa7147a01", repository);
        compareCommandFactory.changes(request,
                new CompareChangeCommandParameters.Builder().maxChanges(10).build(),
                callback).call();

        InOrder ordered = inOrder(callback);
        ordered.verify(callback).onStart(argThat(changeContext("4b825dc642cb6eb9a060e54bf8d69288fbee4904", request.getFromRef().getId())));
        ordered.verify(callback).onChange(argThat(change(ChangeType.ADD, "Weekly_9843.png")));
        ordered.verify(callback).onEnd(notNull(ChangeSummary.class));
        ordered.verifyNoMoreInteractions();
    }

    @Test
    public void testCommits() throws Exception {
        for (CompareRequest request : compareRequests()) {
            CommitCallback callback = mock(CommitCallback.class);
            when(callback.onCommit(any(Commit.class))).thenReturn(true); // to prevent stopping after the first
            compareCommandFactory.commits(request, callback).call();

            InOrder ordered = inOrder(callback);
            ordered.verify(callback).onStart(notNull(CommitContext.class));
            ordered.verify(callback).onCommit(argThat(commit("Geovanny Lang", "Commiting file A")));
            ordered.verify(callback).onEnd(notNull(CommitSummary.class));
            ordered.verifyNoMoreInteractions();
        }
    }

    @Test
    public void testCommitsWithIdenticalRefs() throws Exception {
        for (CompareRequest request : identicalRefs(compareRequests())) {
            CommitCallback callback = mock(CommitCallback.class);
            compareCommandFactory.commits(request, callback).call();

            InOrder ordered = inOrder(callback);
            ordered.verify(callback).onStart(notNull(CommitContext.class));
            ordered.verify(callback).onEnd(notNull(CommitSummary.class));
            ordered.verifyNoMoreInteractions();
        }
    }

    @Test
    public void testDiff() throws Exception {
        for (CompareRequest request : compareRequests()) {
            DiffContentCallback callback = mock(DiffContentCallback.class);
            compareCommandFactory.diff(request,
                    new CompareDiffCommandParameters.Builder()
                            .contextLines(1)
                            .maxLineLength(80)
                            .maxLines(10)
                            .path("a.txt")
                            .whitespace(DiffWhitespace.SHOW)
                            .build(),
                    callback
            ).call();

            InOrder ordered = inOrder(callback);
            ordered.verify(callback).onStart(argThat(diffContext("017caf31eca7c46eb3d1800fcac431cfa7147a01", request.getFromRef().getId())));
            ordered.verify(callback).onDiffStart(isNull(Path.class), argThat(path("a.txt")));
            ordered.verify(callback).onHunkStart(eq(0), eq(0), eq(1), eq(1));
            ordered.verify(callback).onSegmentStart(same(DiffSegmentType.ADDED));
            ordered.verify(callback).onSegmentLine(eq("File on branch A"), isNull(ConflictMarker.class), eq(false));
            ordered.verify(callback).onHunkEnd(eq(false));
            ordered.verify(callback).onDiffEnd(eq(false));
            ordered.verify(callback).onEnd(notNull(DiffSummary.class));
            ordered.verifyNoMoreInteractions();
        }
    }

    @Test
    public void testDiffWithIdenticalRefs() throws Exception {
        for (CompareRequest request : identicalRefs(compareRequests())) {
            DiffContentCallback callback = mock(DiffContentCallback.class);
            compareCommandFactory.diff(request,
                    new CompareDiffCommandParameters.Builder()
                            .contextLines(1)
                            .path("a.txt")
                            .build(),
                    callback)
            .call();

            InOrder ordered = inOrder(callback);
            ordered.verify(callback).onStart(argThat(diffContext(
                    either(equalTo(request.getFromRef().getId())).// skips of the resolution of the merge base when the two (identical) refs are full hashes or on the same repository
                           or(equalTo("642a6300c7f029f08da5583a54486c9cbfe03ab9")),
                    equalTo(request.getToRef().getId()))));
            ordered.verify(callback).onEnd(notNull(DiffSummary.class));
            ordered.verifyNoMoreInteractions();
        }
    }

    @Test
    public void testDiffWithNonExistentFile() throws Exception {
        for (CompareRequest request : compareRequests()) {
            DiffContentCallback callback = mock(DiffContentCallback.class);
            compareCommandFactory.diff(request,
                    new CompareDiffCommandParameters.Builder()
                            .contextLines(1)
                            .path("file-that-does-not-exist-at-revision")
                            .build(),
                    callback
            ).call();

            InOrder ordered = inOrder(callback);
            ordered.verify(callback).onStart(argThat(diffContext("017caf31eca7c46eb3d1800fcac431cfa7147a01", request.getFromRef().getId())));
            ordered.verify(callback).onEnd(notNull(DiffSummary.class));
            ordered.verifyNoMoreInteractions();
        }
    }

    @Test
    public void testDiffWithNoCommonAncestor() throws Exception {
        DiffContentCallback callback = mock(DiffContentCallback.class);
        CompareRequest request = noCommonAncestorRequest("017caf31eca7c46eb3d1800fcac431cfa7147a01", repository);
        compareCommandFactory.diff(request,
                new CompareDiffCommandParameters.Builder()
                        .contextLines(1)
                        .maxLineLength(300)
                        .maxLines(10)
                        .path("Weekly_9843.png")
                        .whitespace(DiffWhitespace.SHOW)
                        .build(),
                callback
        ).call();

        InOrder ordered = inOrder(callback);
        ordered.verify(callback).onStart(argThat(diffContext("4b825dc642cb6eb9a060e54bf8d69288fbee4904", request.getFromRef().getId())));
        ordered.verify(callback).onDiffStart(isNull(Path.class), argThat(path("Weekly_9843.png")));
        ordered.verify(callback).onHunkStart(eq(0), eq(0), eq(1), eq(7));
        ordered.verify(callback).onSegmentStart(same(DiffSegmentType.ADDED));
        ordered.verify(callback).onSegmentLine(startsWith("Atque vel dolorem."), isNull(ConflictMarker.class), eq(false));
        ordered.verify(callback).onSegmentLine(startsWith("Modi dolor velit ea"), isNull(ConflictMarker.class), eq(false));
        ordered.verify(callback).onSegmentLine(startsWith("Optio corporis vero"), isNull(ConflictMarker.class), eq(false));
        ordered.verify(callback).onSegmentLine(startsWith("Ut consequuntur in"), isNull(ConflictMarker.class), eq(false));
        ordered.verify(callback).onSegmentLine(startsWith("Eum voluptas velit."), isNull(ConflictMarker.class), eq(false));
        ordered.verify(callback).onSegmentLine(startsWith("Voluptatum ab qui"), isNull(ConflictMarker.class), eq(false));
        ordered.verify(callback).onSegmentLine(startsWith("Soluta necessitatibus"), isNull(ConflictMarker.class), eq(false));
        ordered.verify(callback).onHunkEnd(eq(false));
        ordered.verify(callback).onDiffEnd(eq(false));
        ordered.verify(callback).onEnd(notNull(DiffSummary.class));
        ordered.verifyNoMoreInteractions();
    }

    private CompareRequest compareRequest(String from, Repository fromRepository, String to, Repository toRepository) {
        return new CompareRequest.Builder()
                .fromRef(new CompareRef(from, fromRepository))
                .toRef(new CompareRef(to, toRepository))
                .build();
    }

    /**
     * Generate equivalent requests with each commit specified either
     * as a qualified ref, a unqualified ref, its full SHA1 or a partial SHA1.
     * <p>
     * Additionally, each request is generated 'twice', once as a single-repository
     * request and once as a cross-repository request.
     */
    private List<CompareRequest> compareRequests() {
        // each column refers to the same commit
        String[][] commits = {
            /* qualified ref */   {"refs/heads/branch_a", "refs/heads/branch_c"},
            /* unqualified ref */ {"branch_a", "branch_c"},
            /* full sha */        {"642a6300c7f029f08da5583a54486c9cbfe03ab9", "a457cb5da400dc38403459ab8a77d70d15eb0094"},
            /* short sha */       {"642a630", "a457cb5"}
        };

        ImmutableList.Builder<CompareRequest> requests = ImmutableList.builder();
        for (String[] commit : commits) {
            requests.add(compareRequest(commit[0], repository, commit[1], repository));
            requests.add(compareRequest(commit[0], repository, commit[1], fork));
        }
        return requests.build();
    }

    private List<CompareRequest> identicalRefs(List<CompareRequest> compareRequests) {
        return compareRequests.stream()
                .map(request ->
                        new CompareRequest.Builder()
                                .fromRef(request.getFromRef())
                                .toRef(request.getFromRef().getId(), request.getToRef().getRepository())
                                .build())
                .collect(toImmutableList());
    }

    private CompareRequest noCommonAncestorRequest(String from, Repository fromRepository) {
        return compareRequest(from, fromRepository, UNCONNECTED_REF, fork);
    }

    private static Matcher<Change> change(final ChangeType type, final String path) {
        return new SimpleTypeSafeMatcher<Change>() {

            @Override
            protected boolean matchesSafely(Change change) {
                return change.getPath().toString().equals(path)
                        && change.getType() == type
                        && change.getConflict() == null;
            }
        };
    }

    private static Matcher<ChangeContext> changeContext(final String fromHash, final String toHash) {
        return changeContext(equalTo(fromHash), equalTo(toHash));
    }

    private static Matcher<ChangeContext> changeContext(final Matcher<String> fromHash, final Matcher<String> toHash) {
        return new SimpleTypeSafeMatcher<ChangeContext>() {

            @Override
            protected boolean matchesSafely(ChangeContext context) {
                return fromHash.matches(context.getFromHash())
                        && toHash.matches(context.getToHash());
            }
        };
    }

    private static Matcher<Commit> commit(final String authorName, final String message) {
        return new SimpleTypeSafeMatcher<Commit>() {

            @Override
            protected boolean matchesSafely(Commit commit) {
                return authorName.equals(commit.getAuthor().getName())
                        && message.equals(commit.getMessage());
            }
        };
    }

    private static Matcher<DiffContext> diffContext(final String fromHash, final String toHash) {
        return diffContext(equalTo(fromHash), equalTo(toHash));
    }

    private static Matcher<DiffContext> diffContext(final Matcher<String> fromHash, final Matcher<String> toHash) {
        return new SimpleTypeSafeMatcher<DiffContext>() {

            @Override
            protected boolean matchesSafely(DiffContext context) {
                return fromHash.matches(context.getFromHash())
                        && toHash.matches(context.getToHash());
            }
        };
    }

    private static Matcher<Path> path(final String path) {
        return new SimpleTypeSafeMatcher<Path>() {

            @Override
            protected boolean matchesSafely(Path actual) {
                return actual.toString().equals(path);
            }
        };
    }

    private Repository prepareFork(Repository origin) throws IOException {
        if (cachedFork == null) {
            Repository fork = mock(Repository.class);
            when(fork.getHierarchyId()).thenReturn(origin.getHierarchyId());
            when(fork.getId()).thenReturn(10000);
            when(fork.getOrigin()).thenReturn(origin);
            when(fork.getProject()).thenReturn(origin.getProject());
            when(fork.getSlug()).thenReturn("fork");

            config.setRepositoryDir(fork, temporaryFolder.newFolder("fork"));
            builderFactory.builder().clone()
                    .bare()
                    .directory(config.getRepositoryDir(fork))
                    .origin(config.getRepositoryDir(origin))
                    .shared(true)
                    .build()
                    .call();

            // create a unconnected ref
            String commit = builderFactory.builder(fork).command("commit-tree")
                    .argument(/* tree at branch_a */ "2a8393b63259bac96e96bf743e764568e6190762")
                    .inputHandler(new StringInputHandler("create ref " + UNCONNECTED_REF))
                    .withEnvironment("GIT_AUTHOR_NAME", "Compare Command Factory Test")
                    .withEnvironment("GIT_AUTHOR_EMAIL", "stash-dev@atlassian.com")
                    .withEnvironment("GIT_COMMITTER_NAME", "Compare Command Factory Test")
                    .withEnvironment("GIT_COMMITTER_EMAIL", "stash-dev@atlassian.com")
                    .build(new StringHandler())
                    .call();
            assertNotNull("commit-tree did not return a commit ID", commit);
            builderFactory.builder(fork).branch().create(UNCONNECTED_REF)
                    .startPoint(commit)
                    .build()
                    .call();

            cachedFork = fork;
        }

        return cachedFork;
    }
}