package com.atlassian.bitbucket.mesh.git.builder.fetch;

import com.atlassian.bitbucket.mesh.git.GitAgent;
import com.atlassian.bitbucket.mesh.git.GitRefPattern;
import com.atlassian.bitbucket.mesh.git.exception.CommandFailedException;
import com.atlassian.bitbucket.mesh.git.fetch.FetchCallback;
import com.atlassian.bitbucket.mesh.git.ref.Branch;
import com.atlassian.bitbucket.mesh.git.ref.RefUpdate;
import com.atlassian.bitbucket.mesh.git.ref.Tag;
import com.atlassian.bitbucket.mesh.process.ProcessSummary;
import com.atlassian.bitbucket.mesh.repository.Repository;
import com.atlassian.bitbucket.mesh.util.ShaUtils;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableList;
import org.apache.commons.lang3.StringUtils;
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 java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.fail;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;

@RunWith(MockitoJUnitRunner.class)
public class FetchStdioHandlerTest {
    
    @Mock
    private GitAgent agent;
    @Mock
    private FetchCallback callback;
    private FetchStdioHandler handler;
    @Mock
    private Repository repository;

    @Before
    public void setup() {
        handler = new FetchStdioHandler(agent, callback, new ArrayList<>(), "url", repository);
        when(agent.resolveBranch(eq(repository), anyString(), eq(true))).thenAnswer(invocation -> {
            String refName = invocation.getArgument(1);
            return new Branch.Builder()
                    .id(GitRefPattern.HEADS.qualify(refName))
                    .latestCommit("deadbeef")
                    .build();
        });
    }

    @Test
    public void testJagJiraAgile() {
        stderr("error: unable to resolve reference refs/heads/shipit/real-time-agile-board: Not a directory",
                "From ssh://stash.stg.internal.atlassian.com:7998/jag/jira-agile");
        stdout(" ! [new branch]      shipit/real-time-agile-board -> shipit/real-time-agile-board  (unable to update local ref)");
        stderr("error: unable to resolve reference refs/heads/shipit/sprintnavigator: Not a directory");
        stdout(" ! [new branch]      shipit/sprintnavigator -> shipit/sprintnavigator  (unable to update local ref)",
                " * [new tag]         jira-greenhopper-plugin-6.4.5-rc1 -> jira-greenhopper-plugin-6.4.5-rc1");
        stderr("error: some local refs could not be updated; try running",
                " 'git remote prune ssh://git@bitbucket.example.com:7998/jag/jira-agile.git' to remove any old, conflicting branches");

        ProcessSummary summary = new ProcessSummary.Builder("git fetch").exitCode(1).build();
        handler.onComplete(summary);

        verify(callback).onRefUpdate(any());
        verify(callback, times(2)).onFailedUpdate(any());
        verifyNoMoreInteractions(callback);
    }

    @Test
    public void testJagJiraAgileWithProgress() {
        stderr("remote: Counting objects: 1   \u001B[K",
                "remote: Counting objects: 89971   \u001B[K",
                "remote: Counting objects: 238713   \u001B[K",
                "remote: Counting objects: 387241   \u001B[K",
                "remote: Counting objects: 531422   \u001B[K",
                "remote: Counting objects: 546957, done.\u001B[K",
                "remote: Compressing objects:   0% (1/299661)   \u001B[K",
                "remote: Compressing objects:   1% (2997/299661)   \u001B[K",
                "remote: Compressing objects:   2% (5994/299661)   \u001B[K",
                "remote: Compressing objects:   3% (8990/299661)   \u001B[K",
                "remote: Compressing objects: 100% (299661/299661), done.\u001B[K",
                "Receiving objects:   0% (1/546957)   \r",
                "Receiving objects:   0% (305/546957), 36.00 KiB | 48.00 KiB/s   \r",
                "Receiving objects:   0% (725/546957), 124.00 KiB | 67.00 KiB/s   \r",
                "Receiving objects:   0% (1040/546957), 212.00 KiB | 70.00 KiB/s   \r",
                "Receiving objects:  99% (546947/546957), 312.37 MiB | 147.00 KiB/s   \r",
                "remote: Total 546957 (delta 202903), reused 525670 (delta 183000)\u001B[K",
                "Receiving objects: 100% (546957/546957), 312.37 MiB | 147.00 KiB/s   \r",
                "Receiving objects: 100% (546957/546957), 312.54 MiB | 161.00 KiB/s, done.",
                "Resolving deltas:   0% (0/202903)   \r",
                "Resolving deltas:   1% (2760/202903)   \r",
                "Resolving deltas:   2% (4064/202903)   \r",
                "Resolving deltas:  99% (200968/202903)   \r",
                "Resolving deltas: 100% (202903/202903)   \r",
                "Resolving deltas: 100% (202903/202903), done.",
                "From ssh://bitbucket.example.com:7998/jag/jira-agile");
        stdout(" * [new branch]      1000       -> 1000",
                " * [new branch]      7.0-T0-release-branch -> 7.0-T0-release-branch",
                " * [new branch]      7.0.4-D20150923T101309-release-branch -> 7.0.4-D20150923T101309-release-branch",
                " * [new branch]      shipit     -> shipit");
        stderr("error: unable to resolve reference refs/heads/shipit/real-time-agile-board: Not a directory");
        stdout(" ! [new branch]      shipit/real-time-agile-board -> shipit/real-time-agile-board  (unable to update local ref)");
        stderr("error: unable to resolve reference refs/heads/shipit/sprintnavigator: Not a directory");
        stdout(" ! [new branch]      shipit/sprintnavigator -> shipit/sprintnavigator  (unable to update local ref)",
                " * [new ref]         refs/notes/ci-and-gate/ci/ci-master-1 -> refs/notes/ci-and-gate/ci/ci-master-1",
                " * [new ref]         refs/notes/ci-and-gate/ci/ci-release70-1 -> refs/notes/ci-and-gate/ci/ci-release70-1",
                " * [new ref]         refs/notes/ci-and-gate/ci/ci-release70T0-1 -> refs/notes/ci-and-gate/ci/ci-release70T0-1",
                " * [new tag]         2011.S10   -> 2011.S10",
                " * [new tag]         2011.s01   -> 2011.s01",
                " * [new tag]         2011.s02   -> 2011.s02");
        stderr("error: some local refs could not be updated; try running",
                " 'git remote prune ssh://git@bitbucket.example.com:7998/jag/jira-agile.git' to remove any old, conflicting branches");

        ProcessSummary summary = new ProcessSummary.Builder("git fetch").exitCode(1).build();
        handler.onComplete(summary);

        assertEquals("From ssh://bitbucket.example.com:7998/jag/jira-agile\n" +
                     "error: unable to resolve reference refs/heads/shipit/real-time-agile-board: Not a directory\n" +
                     "error: unable to resolve reference refs/heads/shipit/sprintnavigator: Not a directory\n" +
                     "error: some local refs could not be updated; try running\n" +
                     " 'git remote prune ssh://git@bitbucket.example.com:7998/jag/jira-agile.git' to remove any old, conflicting branches",
                handler.getErrors());

        verify(callback, times(10)).onRefUpdate(any());
        verify(callback, times(2)).onFailedUpdate(any());
        verifyNoMoreInteractions(callback);
    }

    @Test
    public void testBadExitStatusButSomeRefsUpdatedWasSuccessful() {
        stderr("From ssh://bitbucket.example.com:7998/jag/jira-agile");
        stdout(" * [new branch]      basic_branching -> basic_branching");
        stderr("error: unable to create directory for refs/heads/basic_branching/nested",
                "error: Cannot lock the ref 'refs/heads/basic_branching/nested'.");
        stdout(" ! [new branch]      basic_branching/nested -> basic_branching/nested  (unable to update local ref)",
                " * [new branch]      branch_mod_merge -> branch_mod_merge",
                " * [new branch]      master     -> master",
                " * [new branch]      out_of_order_branch -> out_of_order_branch",
                " * [new tag]         backdated_annotated_tag -> backdated_annotated_tag",
                " * [new tag]         retagged_signed_tag -> retagged_signed_tag",
                " * [new tag]         signed_tag -> signed_tag");

        //The onRefUpdate for the added refs should be deferred until the process has completed to ensure
        //the pending ref updates have actually been written to disk.
        verify(callback, times(1)).onFailedUpdate(any());
        verifyNoMoreInteractions(callback);

        ProcessSummary summary = new ProcessSummary.Builder("git fetch").exitCode(1).build();
        handler.onComplete(summary);

        verify(callback, times(7)).onRefUpdate(any());
        verifyNoMoreInteractions(callback);
    }

    @Test
    public void testBadExitStatusAndNoRefsUpdatedWasFailure() {
        String[] output = {
                "From ssh://bitbucket.example.com:7998/jag/jira-agile",
                "error: unable to create directory for some reason"
        };

        for (String line : output) {
            stderr(line);
        }

        verifyNoMoreInteractions(callback);
        try {
            handler.onComplete(new ProcessSummary.Builder("git fetch").exitCode(1).build());
            fail("Exception expected");
        } catch (CommandFailedException e) {
            assertEquals("From ssh://bitbucket.example.com:7998/jag/jira-agile\n" +
                         "error: unable to create directory for some reason",
                    e.getStderr());
        }
    }

    @Test
    public void testDeleteRefAndUpdateTag() {
        Branch outOfOrderBranch = new Branch.Builder()
                .id("refs/heads/out_of_order_branch")
                .latestCommit(StringUtils.repeat('a', 40))
                .build();
        Tag tag = new Tag.Builder()
                .id("refs/tags/retagged_signed_tag")
                .hash(StringUtils.repeat('b', 40))
                .latestCommit(StringUtils.repeat('c', 40))
                .build();
        handler = new FetchStdioHandler(agent, callback, ImmutableList.of(outOfOrderBranch, tag), "url", repository);
        stdout(" - [deleted]         (none)     -> out_of_order_branch",
                " t [tag update]      retagged_signed_tag -> retagged_signed_tag");

        ArgumentCaptor<RefUpdate> refChangeCaptor = ArgumentCaptor.forClass(RefUpdate.class);

        verify(callback, times(2)).onRefUpdate(refChangeCaptor.capture());
        verifyNoMoreInteractions(callback);

        List<RefUpdate> refUpdates = refChangeCaptor.getAllValues();
        assertEquals(2, refUpdates.size());

        RefUpdate refUpdate = refUpdates.get(0);
        assertEquals("refs/heads/out_of_order_branch", refUpdate.getRefId());
        assertEquals(ShaUtils.NULL_SHA1, refUpdate.getNewHash());

        refUpdate = refUpdates.get(1);
        assertEquals("refs/tags/retagged_signed_tag", refUpdate.getRefId());
    }

    @Test
    public void testErrorsTruncatedToMostRecent64k() {
        for (int i = 0; i <= 64; i++) {
            stderr(Strings.repeat(Character.toString((char) (((int) '0') + i)), 1024));
        }

        verifyNoMoreInteractions(callback);

        String errors = handler.getErrors();
        // 64k + 63 '\n' characters (there is no trailing '\n')
        assertEquals((64 * 1024) + 63, errors.length());
        // the starting line of '0's was lopped off
        assertEquals((char) ((int) '0' + 1), errors.charAt(0));
        // the final line is one of 'p's
        assertEquals((char) (((int) '0') + 64), errors.charAt(errors.length() - 1));
    }

    @Test
    public void testFirstErrorLineOver64kIsHandled() {
        // this line should still be retained even though it tips us over
        stderr(Strings.repeat("0", 65 * 1024));

        String errors = handler.getErrors();
        assertEquals(65 * 1024, errors.length());

        // this should eject the first line
        stderr("a small line");

        errors = handler.getErrors();
        assertEquals("a small line", errors);
    }

    private static void streamAsByteBufferChunks(String[] lines, Consumer<ByteBuffer> consumer) {
        byte[] buffer = new byte[25];
        ByteBuffer byteBuffer = ByteBuffer.wrap(buffer);
        for (String line : lines) {
            byte[] bytes = (line + '\n').getBytes(StandardCharsets.UTF_8);
            int offset = 0;
            while (offset < bytes.length) {
                int remaining = byteBuffer.remaining();
                int length = Math.min(remaining, bytes.length - offset);
                byteBuffer.put(bytes, offset, length);
                byteBuffer.flip();
                consumer.accept(byteBuffer);
                byteBuffer.compact();
                offset += length;
            }
            if (byteBuffer.remaining() == 0) {
                byteBuffer.flip();
                consumer.accept(byteBuffer);
                byteBuffer.compact();
            }
        }
        byteBuffer.flip();
        consumer.accept(byteBuffer);
    }

    private void stdout(String... lines) {
        streamAsByteBufferChunks(lines, buffer -> handler.onStdout(buffer, false));
    }

    private void stderr(String... lines) {
        streamAsByteBufferChunks(lines, buffer -> handler.onStderr(buffer, false));
    }
}