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

import com.atlassian.bitbucket.mesh.git.builder.GitProcess;
import com.atlassian.bitbucket.mesh.git.builder.GitProcessBuilder;
import com.atlassian.bitbucket.mesh.git.builder.GitProcessBuilderFactory;
import com.atlassian.bitbucket.mesh.git.builder.revlist.CallbackRevListStdioHandler;
import com.atlassian.bitbucket.mesh.git.builder.revlist.GitRevListBuilder;
import com.atlassian.bitbucket.mesh.git.commit.Commit;
import com.atlassian.bitbucket.mesh.process.OsProcess;
import com.atlassian.bitbucket.mesh.process.ProcessStdioHandler;
import com.atlassian.bitbucket.mesh.repository.Repository;
import com.atlassian.bitbucket.mesh.util.Person;
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.junit.MockitoJUnitRunner;

import javax.annotation.Nonnull;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.nio.ByteBuffer;
import java.nio.channels.Channels;
import java.nio.channels.ReadableByteChannel;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Set;

import static com.atlassian.bitbucket.mesh.test.MockitoUtils.returnSelf;
import static java.util.Objects.requireNonNull;
import static org.junit.Assert.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
import static org.mockito.hamcrest.MockitoHamcrest.argThat;

@RunWith(MockitoJUnitRunner.class)
public class BatchRescopeOperationTest {

    private static final int MAX_COMMIT_IDS = 5;
    
    @Mock
    private GitProcessBuilderFactory builderFactory;
    @Mock
    private PullRequestRescopeCallback callback;
    @Mock
    private GitProcessBuilder processBuilder;
    @Mock
    private Repository repository;

    // mocks that are created in the setup method
    private GitRevListBuilder revListBuilder;

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

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

        when(builderFactory.builder(repository)).thenReturn(processBuilder);

        // mock the rev-list command
        revListBuilder = mock(GitRevListBuilder.class, returnSelf(GitRevListBuilder.class));
        when(revListBuilder.build(any())).thenAnswer(invocation -> {
            ProcessStdioHandler<?> stdioHandler = invocation.getArgument(0);

            @SuppressWarnings("unchecked")
            GitProcess<Void> command = mock(GitProcess.class);
            when(command.run()).thenAnswer(inv -> {
                String revListOutput = buildRevListOutput(commits, stdioHandler instanceof CallbackRevListStdioHandler);
                mockRevListProcess(stdioHandler, revListOutput.getBytes(StandardCharsets.UTF_8));
                return stdioHandler.getResult();
            });
            return command;
        });

        when(processBuilder.revList()).thenReturn(revListBuilder);
    }

    @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);
        mockCommits(commit("e", "d", "a"), commit("d", "0"), commit("c", "a"), commit("b", "a"), commit("-a", "0"));

        operation().call();

        verify(callback).onUpdated(eq(rescope), argThat(commitsList()), eq(0), argThat(commitsList()), eq(0));
        verifyNoMoreInteractions(callback);
    }

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

        verifyNoInteractions(processBuilder);
    }

    @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);
        mockCommits(commit("ddd", "ccc"), commit("ccc", "bbb"), commit("bbb", "aaa"));

        operation().call();

        verify(callback).onUpdated(eq(rescope1), argThat(commitsList("ddd")), eq(1), argThat(commitsList()), eq(0));
        verify(callback).onUpdated(eq(rescope2), argThat(commitsList()), eq(0), argThat(commitsList("ddd")), eq(1));
        verifyNoMoreInteractions(callback);
    }

    @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
        mockCommits(commit("h", "f", "g"), commit("g", "d"), commit("f", "b"), commit("e", "c"), commit("d", "b"),
                commit("c", "a"), commit("b", "a"));

        operation().call();

        // verify callback results
        verify(callback).onUpdated(eq(rescopeT1Pr2), argThat(commitsList("e")), eq(1), argThat(commitsList()), eq(0));
        verify(callback).onUpdated(eq(rescopeT2Pr1), argThat(commitsList()), eq(0), argThat(commitsList()), eq(0));
        verify(callback).onUpdated(eq(rescopeT2Pr2), argThat(commitsList()), eq(0), argThat(commitsList()), eq(0));
        verify(callback).onUpdated(eq(rescopeT3Pr1), argThat(commitsList("g")), eq(1), argThat(commitsList()), eq(0));
        verify(callback).onMerged(eq(rescopeT4Pr1), eq("h"));
        verify(callback).onUpdated(eq(rescopeT4Pr2), argThat(commitsList()), eq(0), argThat(commitsList()), eq(0));
        verifyNoMoreInteractions(callback);

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

        // verify rev-list alternates
        verify(revListBuilder, times(2)).alternates(alternates.capture());
        assertEquals(expectedAlternates, ImmutableSet.copyOf(alternates.getValue()));
    }

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

        operation().call();

        verify(callback).onUpdated(eq(rescope), argThat(commitsList("d")), eq(1), argThat(commitsList()), eq(0));
        verifyNoMoreInteractions(callback);
    }

    private static Commit commit(String hash, String parent, String... moreParents) {
        return new Commit.Builder()
                .id(hash)
                .author(new Person("user", "user@email.com"))
                .authorTimestamp(System.currentTimeMillis())
                .parents(parent, moreParents)
                .build();
    }

    private static String buildRevListOutput(List<Commit> commits, boolean withDetails) {
        char sep = withDetails ? '\02' : ' ';
        String commitDetails = "";
        if (withDetails) {
            String user = sep + "Some User" + sep + "user@email.com" + sep + "1350978270";

            // user is included twice here, once as author and once as committer
            commitDetails = user + user + "\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(String.join(" ", commit.getParents()))
                    .append(commitDetails)
                    .append('\n');
        }
        return builder.toString();
    }

    public static void mockRevListProcess(@Nonnull ProcessStdioHandler<?> handler, byte[] stdout) {
        requireNonNull(handler, "handler");

        OsProcess process = mock(OsProcess.class);
        handler.onPreStart(process);
        handler.onStart(process);

        try (InputStream data = new ByteArrayInputStream(stdout);
             ReadableByteChannel channel = Channels.newChannel(requireNonNull(data, "data"))) {
            ByteBuffer buffer = ByteBuffer.allocate(15);

            boolean isEndOfStream;

            do {
                isEndOfStream = channel.read(buffer) == -1;
                buffer.flip();

                handler.onStdout(buffer, isEndOfStream);
                buffer.compact();
            } while (!isEndOfStream);
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
    }

    private static 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.getOldFromCommit()).thenReturn(oldFrom);
        when(rescope.getOldToCommit()).thenReturn(oldTo);
        when(rescope.getNewFromCommit()).thenReturn(newFrom);
        when(rescope.getNewToCommit()).thenReturn(newTo);

        return rescope;
    }
    // sets commits that will be provided (in order) by ScmCommandFactory.commits

    private void mockCommits(Commit... commits) {
        this.commits = Arrays.asList(commits);
    }

    private BatchRescopeOperation operation() {
        BatchRescopePullRequestsParameters parameters = new BatchRescopePullRequestsParameters.Builder()
                .maxCommitsPerUpdate(MAX_COMMIT_IDS)
                .rescopes(rescopes)
                .build();
        return new BatchRescopeOperation(builderFactory, callback, parameters, 1024);
    }

    private static TypeSafeMatcher<List<Commit>> commitsList(String... commitIds) {

        return new TypeSafeMatcher<List<Commit>>() {

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

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

}