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

import com.atlassian.bitbucket.NoSuchObjectException;
import com.atlassian.bitbucket.commit.Commit;
import com.atlassian.bitbucket.commit.MinimalCommit;
import com.atlassian.bitbucket.commit.SimpleCommit;
import com.atlassian.bitbucket.commit.SimpleMinimalCommit;
import com.atlassian.bitbucket.i18n.KeyedMessage;
import com.atlassian.bitbucket.i18n.SimpleI18nService;
import com.atlassian.bitbucket.mockito.MockitoUtils;
import com.atlassian.bitbucket.pull.RescopeDetails;
import com.atlassian.bitbucket.repository.Repository;
import com.atlassian.bitbucket.scm.CommandOutputHandler;
import com.atlassian.bitbucket.scm.git.command.GitCommand;
import com.atlassian.bitbucket.scm.git.command.GitCommandBuilderFactory;
import com.atlassian.bitbucket.scm.git.command.GitScmCommandBuilder;
import com.atlassian.bitbucket.scm.git.command.merge.GitMergeBase;
import com.atlassian.bitbucket.scm.git.command.merge.GitMergeBaseBuilder;
import com.atlassian.bitbucket.scm.git.command.revlist.GitRevListBuilder;
import com.atlassian.bitbucket.scm.pull.BulkRescopeCommandParameters;
import com.atlassian.bitbucket.scm.pull.BulkRescopeContext;
import com.atlassian.bitbucket.scm.pull.PullRequestRescope;
import com.atlassian.bitbucket.user.SimplePerson;
import com.atlassian.stash.internal.scm.git.InternalGitScmConfig;
import com.atlassian.stash.internal.scm.git.command.revlist.RevListOutputHandler;
import com.atlassian.utils.process.Watchdog;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Sets;
import org.hamcrest.Description;
import org.hamcrest.TypeSafeMatcher;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.runners.MockitoJUnitRunner;

import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.concurrent.ExecutorService;
import java.util.stream.Collectors;

import static org.junit.Assert.assertEquals;
import static org.mockito.Mockito.*;

@RunWith(MockitoJUnitRunner.class)
public class BulkRescopeCommandTest {

    private static final int MAX_COMMIT_IDS = 5;
    private static final long RESCOPE_TIMEOUT = 77;

    @Mock
    private GitScmCommandBuilder commandBuilder;
    @Mock
    private GitCommandBuilderFactory commandBuilderFactory;
    @Mock
    private InternalGitScmConfig config;
    @Mock
    private BulkRescopeContext context;
    @Mock
    private GitCommand<String> mergeBaseCommand;
    @Mock
    private BulkRescopeCommandParameters parameters;
    @Mock
    private Repository repository;

    private BulkRescopeCommand command;

    // mocks that are created in the setUp method
    private GitMergeBaseBuilder mergeBaseBuilder;
    private GitRevListBuilder revListBuilder;

    // mock data
    private List<Commit> commits;
    private String mergeBaseResult;
    private List<PullRequestRescope> rescopes;

    @Before
    public void setUp() {
        rescopes = new ArrayList<>();

        when(parameters.getRescopeContext()).thenReturn(context);
        when(parameters.getRepository()).thenReturn(repository);
        when(commandBuilderFactory.builder(repository)).thenReturn(commandBuilder);
        when(config.getAutoMergeTimeout()).thenReturn(RESCOPE_TIMEOUT);
        when(context.getMaxCommitIds()).thenReturn(MAX_COMMIT_IDS);
        when(context.iterator()).thenAnswer(invocation -> rescopes.iterator());

        ExecutorService executorService = mock(ExecutorService.class);

        // mock the merge-base command
        GitMergeBase mergeBase = mock(GitMergeBase.class);
        mergeBaseBuilder = mock(GitMergeBaseBuilder.class, MockitoUtils.returnSelf(GitMergeBaseBuilder.class));
        when(mergeBase.octopus()).thenReturn(mergeBaseBuilder);
        when(mergeBaseBuilder.<String>build(any())).thenReturn(mergeBaseCommand);
        when(mergeBaseCommand.call()).thenAnswer(invocation ->
                mergeBaseResult == null ? Collections.emptySet() : Collections.singleton(mergeBaseResult));

        // mock the rev-list command
        revListBuilder = mock(GitRevListBuilder.class, MockitoUtils.returnSelf(GitRevListBuilder.class));
        when(revListBuilder.build(any())).thenAnswer(invocation -> {
            CommandOutputHandler outputHandler = (CommandOutputHandler) invocation.getArguments()[0];
            boolean withDetails = outputHandler instanceof RevListOutputHandler;
            GitCommand<Void> command = mock(GitCommand.class);
            when(command.call()).thenAnswer(inv -> {
                String revListOutput = buildRevListOutput(commits, withDetails);
                ByteArrayInputStream bis = new ByteArrayInputStream(revListOutput.getBytes(StandardCharsets.UTF_8));
                outputHandler.setWatchdog(new SimpleWatchdog());
                outputHandler.process(bis);
                return outputHandler.getOutput();
            });
            return command;
        });

        when(commandBuilder.mergeBase()).thenReturn(mergeBase);
        when(commandBuilder.revList()).thenReturn(revListBuilder);

        command = new BulkRescopeCommand(executorService, repository, commandBuilderFactory,
                new SimpleI18nService(SimpleI18nService.Mode.RETURN_KEYS), context);
    }

    @Test
    public void testCallWithEmptyContext() {
        command.call();

        verifyZeroInteractions(commandBuilder);
    }

    @SuppressWarnings("unchecked")
    @Test
    public void testMixedUpdateScenario() {
        //
        //           -- d --- g            <- branch@repo            Start:
        //         /           \                                     x master@repo = b
        //  a --- b --- f ------ h         <- master@repo            x branch@repo = d
        //    \                                                      x master@fork = c
        // ----\------------------------------------------------------------------------
        //      - c --- e                  <- master@fork
        //
        // Changes:                                                  Pull Requests:
        // t1. master@fork [c -> e]                                   x pullRequest1 [branch@repo -> master@repo]
        // t2. master@repo [b -> f]                                   x pullRequest2 [master@fork -> master@repo]
        // t3. branch@repo [d -> g]
        // t4. master@repo [f -> h], branch@repo deleted
        //
        // Rescopes [from-repo, to-repo, oldFrom, oldTo, newFrom, newTo]
        // t1. pr1: -, pr2: [fork, repo, d, b, e, b]
        // t2. pr1: [repo, repo, d, b, d, f], pr2: [fork, repo, e, b, e, f]
        // t3. pr1: [repo, repo, d, f, g, f], pr2: -
        // t4. pr1: [repo, repo, g, f, null, h], pr2: [fork, repo, e, f, e, h]
        Repository fork = mock(Repository.class);

        PullRequestRescope rescopeT1Pr2 = rescope(fork, repository, "c", "b", "e", "b");
        PullRequestRescope rescopeT2Pr1 = rescope(repository, repository, "d", "b", "d", "f");
        PullRequestRescope rescopeT2Pr2 = rescope(fork, repository, "e", "b", "e", "f");
        PullRequestRescope rescopeT3Pr1 = rescope(repository, repository, "d", "f", "g", "f");
        PullRequestRescope rescopeT4Pr1 = rescope(repository, repository, "g", "f", null, "h");
        PullRequestRescope rescopeT4Pr2 = rescope(fork, repository, "e", "f", "e", "h");
        rescopes = ImmutableList.of(rescopeT1Pr2, rescopeT2Pr1, rescopeT2Pr2, rescopeT3Pr1, rescopeT4Pr1, rescopeT4Pr2);

        // mock the git command results
        mergeBaseResult = "a";
        mockCommits(commit("h", "f", "g"), commit("g", "d"), commit("f", "b"), commit("e", "c"), commit("d", "b"),
                commit("c", "a"), commit("b", "a"));

        command.call();

        // verify callback results
        verify(context).iterator();
        verify(context).update(eq(rescopeT1Pr2), argThat(isDetails("e")), argThat(isDetails()));
        verify(context).update(eq(rescopeT2Pr1), argThat(isDetails()), argThat(isDetails()));
        verify(context).update(eq(rescopeT2Pr2), argThat(isDetails()), argThat(isDetails()));
        verify(context).update(eq(rescopeT3Pr1), argThat(isDetails("g")), argThat(isDetails()));
        verify(context).merge(eq(rescopeT4Pr1), eq("h"));
        verify(context).update(eq(rescopeT4Pr2), argThat(isDetails()), argThat(isDetails()));
        verify(context, atLeastOnce()).getMaxCommitIds();
        verifyNoMoreInteractions(context);

        // verify that the alternates are set correctly
        Set<Repository> expectedAlternates = ImmutableSet.of(fork);
        ArgumentCaptor<Iterable> alternates = ArgumentCaptor.forClass(Iterable.class);

        // verify merge-base alternates
        verify(mergeBaseBuilder).alternates(alternates.capture());
        assertEquals(expectedAlternates, ImmutableSet.copyOf((Iterable<Repository>) alternates.getValue()));

        // verify rev-list alternates
        alternates = ArgumentCaptor.forClass(Iterable.class);
        verify(revListBuilder, times(2)).alternates(alternates.capture());
        assertEquals(expectedAlternates, ImmutableSet.copyOf((Iterable<Repository>) alternates.getValue()));
    }

    @Test
    public void testLongHashesAreReturned() {
        //
        // a --- b              <- master
        //         \
        //          -- c --- d  <- branch [c -> d -> c]
        //
        // mock are rescope that includes a short hash (d instead of ddd)
        PullRequestRescope rescope1 = rescope(repository, repository, "ccc", "bbb", "d", "bbb");
        PullRequestRescope rescope2 = rescope(repository, repository, "d", "bbb", "c", "bbb");
        rescopes = ImmutableList.of(rescope1, rescope2);
        mergeBaseResult = "bbb";
        mockCommits(commit("ddd", "ccc"), commit("ccc", "bbb"), commit("bbb", "aaa"));

        command.call();

        verify(context).iterator();
        verify(context).update(eq(rescope1), argThat(isDetails("ddd")), argThat(isDetails()));
        verify(context).update(eq(rescope2), argThat(isDetails()), argThat(isDetails("ddd")));
        verify(context, atLeastOnce()).getMaxCommitIds();
        verifyNoMoreInteractions(context);
    }

    @Test
    public void testBoundaryHashesAreNormalized() {
        // V0
        // 0 --- a --- b
        //        \
        //         c
        //
        // V1 (Rebase)
        //   / - d - \
        //  /         \
        // 0 --- a --- e
        //        \
        //         c
        //
        // the --boundary argument passed to rev-list makes it include the boundary commits
        // however they are flagged by leading '-' which needs to be stripped so that they
        // can match the same hash showing up as the parent of a commit in the rev-list output
        PullRequestRescope rescope = rescope(repository, repository, "c", "b", "c", "e");
        rescopes = ImmutableList.of(rescope);
        mergeBaseResult = "a";
        mockCommits(commit("e", "d", "a"), commit("d", "0"), commit("c", "a"), commit("b", "a"), commit("-a", "0"));

        command.call();

        verify(context).iterator();
        verify(context).update(eq(rescope), argThat(isDetails()), argThat(isDetails()));
        verify(context, atLeastOnce()).getMaxCommitIds();
        verifyNoMoreInteractions(context);
    }

    @Test
    public void testSimpleUpdateWithMergeBaseError() {
        testSimpleUpdateScenario(() -> {
            NoSuchObjectException exception = new NoSuchObjectException(mock(KeyedMessage.class), "c");
            when(mergeBaseCommand.call()).thenThrow(exception);
        });
    }

    @Test
    public void testSimpleUpdateWithNoMergeBaseFound() {
        testSimpleUpdateScenario(() -> mergeBaseResult = null);
    }

    private static Commit commit(String hash, String...parentHashes) {
        return new SimpleCommit.Builder(hash)
                .author(new SimplePerson("user", "user@domain"))
                .authorTimestamp(new Date())
                .parents(Arrays.stream(parentHashes)
                        .map(parent -> new SimpleMinimalCommit.Builder(parent).build())
                        .collect(Collectors.toList()))
                .build();
    }

    private static String buildRevListOutput(List<Commit> commits, boolean withDetails) {
        char sep = withDetails ? '\02' : ' ';
        String commitDetails = "";
        if (withDetails) {
            commitDetails = sep + "Some User" + sep + "user@email.com" + sep + "1350978270\nCommit message\n\03END\04";
        }
        StringBuilder builder = new StringBuilder();
        for (Commit commit : commits) {
            if (withDetails) {
                builder.append("commit ").append(commit.getId()).append('\n');
            }
            builder.append(commit.getId())
                    .append(sep).append(commit.getParents().stream()
                    .map(MinimalCommit::getId)
                    .collect(Collectors.joining(" ")))
                    .append(commitDetails)
                    .append('\n');
        }
        return builder.toString();
    }

    // sets commits that will be provided (in order) by ScmCommandFactory.commits
    private void mockCommits(Commit... commits) {
        this.commits = Arrays.asList(commits);
    }

    private PullRequestRescope rescope(Repository fromRepo, Repository toRepo, String oldFrom, String oldTo,
                                       String newFrom, String newTo) {
        PullRequestRescope rescope = mock(PullRequestRescope.class);
        when(rescope.getFromRepository()).thenReturn(fromRepo);
        when(rescope.getToRepository()).thenReturn(toRepo);
        when(rescope.getOldFromHash()).thenReturn(oldFrom);
        when(rescope.getOldToHash()).thenReturn(oldTo);
        when(rescope.getNewFromHash()).thenReturn(newFrom);
        when(rescope.getNewToHash()).thenReturn(newTo);

        return rescope;
    }

    private void testSimpleUpdateScenario(Runnable customConfiguration) {
        //
        // a --- b               <- master
        //         \
        //          -- c --- d   <- branch [c -> d]
        //
        PullRequestRescope rescope = rescope(repository, repository, "c", "b", "d", "b");
        rescopes = ImmutableList.of(rescope);
        mergeBaseResult = "b";
        mockCommits(commit("d", "c"), commit("c", "b"), commit("b", "a"));

        customConfiguration.run();

        command.call();

        verify(context).iterator();
        verify(context).update(eq(rescope), argThat(isDetails("d")), argThat(isDetails()));
        verify(context, atLeastOnce()).getMaxCommitIds();
        verifyNoMoreInteractions(context);
    }

    private static TypeSafeMatcher<RescopeDetails> isDetails(String... commitIds) {
       return new TypeSafeMatcher<RescopeDetails>() {
            @Override
            protected boolean matchesSafely(RescopeDetails item) {
                Set<String> ids = Sets.newHashSet(commitIds);
                if (item.getTotal() != Math.min(MAX_COMMIT_IDS, ids.size())) {
                    return false;
                }
                if (item.getCommits().size() > MAX_COMMIT_IDS) {
                    return false;
                }
                for (Commit commit : item.getCommits()) {
                    if (!ids.remove(commit.getId())) {
                        return false;
                    }
                }
                return true;
            }

            @Override
            public void describeTo(Description description) {
            }
        };
    }

    private static class SimpleWatchdog implements Watchdog {
        private volatile boolean canceled;

        @Override
        public void cancel() {
            canceled = true;
        }

        @Override
        public boolean isCanceled() {
            return canceled;
        }

        @Override
        public void resetWatchdog() {
        }
    }
}