package com.atlassian.bitbucket.mesh.git;

import com.atlassian.bitbucket.mesh.git.hook.script.CallHookScriptParameters;
import com.atlassian.bitbucket.mesh.git.hook.script.HookScriptCallback;
import com.atlassian.bitbucket.mesh.git.hook.script.HookScriptManager;
import com.atlassian.bitbucket.mesh.git.ref.RefUpdate;
import com.atlassian.bitbucket.mesh.process.ProcessSummary;
import com.atlassian.bitbucket.mesh.repository.Repository;
import com.atlassian.bitbucket.mesh.repository.RepositoryManager;
import com.atlassian.bitbucket.mesh.rpc.v1.*;
import com.atlassian.bitbucket.mesh.util.ShaUtils;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.protobuf.ByteString;
import io.grpc.stub.StreamObserver;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.*;
import org.mockito.junit.MockitoJUnitRunner;

import java.nio.ByteBuffer;
import java.time.Duration;
import java.util.List;

import static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.Collections.singletonList;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.containsInAnyOrder;
import static org.junit.Assert.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;

@RunWith(MockitoJUnitRunner.class)
public class CallHookScriptsRequestObserverTest {

    private static final String SCRIPT_1 = "hook-scripts/1/1";
    private static final String SCRIPT_2 = "hook-scripts/2/3";

    @Captor
    private ArgumentCaptor<RpcCallHookScriptsResponseFragment> captor;
    @Mock
    private HookScriptManager hookScriptManager;
    @InjectMocks
    private CallHookScriptsRequestObserver observer;
    @Mock
    private StreamObserver<RpcCallHookScriptsResponseFragment> responseObserver;
    @Mock
    private Repository repository;
    @Mock
    private RepositoryManager repositoryManager;

    @Before
    public void setUp() {
        when(repositoryManager.getById(anyString())).thenReturn(repository);

        doAnswer(invocation -> {
            List<String> ids = invocation.getArgument(0);
            HookScriptCallback callback = invocation.getArgument(2);

            for (String id : ids) {
                callback.onStartScript(id);
                Thread.sleep(25L);
                callback.onStdout(ByteBuffer.wrap((id + ":stdout line 1\n").getBytes(UTF_8)), true);
                callback.onStdout(ByteBuffer.wrap((id + ":stdout line 2\n").getBytes(UTF_8)), true);
                ProcessSummary summary = mock(ProcessSummary.class);
                callback.onComplete(summary);
            }
            return null;
        }).when(hookScriptManager).call(anyList(), any(), any());
    }

    @Test
    public void testNoMissingScripts() {
        when(hookScriptManager.exists(anyString())).thenReturn(true);

        observer.onNext(requestFragment());

        InOrder inOrder = inOrder(responseObserver);
        inOrder.verify(responseObserver, times(3)).onNext(captor.capture()); // 2 data chunks + 1 result fragment
        inOrder.verify(responseObserver).onCompleted();
        inOrder.verifyNoMoreInteractions();

        //Verify the data chunks
        verifyHookScriptChunk(SCRIPT_1, captor.getAllValues().get(0).getChunk());
        verifyHookScriptChunk(SCRIPT_2, captor.getAllValues().get(1).getChunk());

        RpcCallHookScriptsResponseFragment fragment = captor.getValue();
        assertFalse(fragment.hasMissing());
        assertTrue(fragment.hasResponse());
        RpcCallHookScriptsResponse response = fragment.getResponse();
        assertEquals(2, response.getResponsesCount());

        //Verify individual hook script responses
        verifyHookScriptResult(SCRIPT_1, response.getResponses(0));
        verifyHookScriptResult(SCRIPT_2, response.getResponses(1));

        //Verify that all request parameters have been passed correctly to HookScriptManager.call
        ArgumentCaptor<CallHookScriptParameters> paramsCaptor = ArgumentCaptor.forClass(CallHookScriptParameters.class);
        verify(hookScriptManager).call(anyList(), paramsCaptor.capture(), any());
        CallHookScriptParameters parameters = paramsCaptor.getValue();
        assertTrue(parameters.isAbortOnFirstVeto());
        assertEquals(ImmutableMap.of("BB_KEY", "BB_VALUE"), parameters.getEnvironment());
        assertEquals(Duration.ofSeconds(120L), parameters.getExecutionTimeout());
        assertEquals(Duration.ofSeconds(60L), parameters.getIdleTimeout());
        assertEquals(repository, parameters.getRepository());
        assertEquals(ImmutableList.of(
                RefUpdate.ofAdd("refs/heads/created", "1cc935d8c5eb870dadcd9191d568019d66e2c279"),
                RefUpdate.ofDelete("refs/heads/deleted", "f31e6abf9dc2cfaff796076eea048439ac87b901"),
                RefUpdate.of("refs/heads/updated", "9b77a6d4f9dba1555494a8e1e6c372a52271de72",
                        "d805a145a26eb96334fffe6dcdad706d4715c225")),
                parameters.getRefUpdates());
    }

    @Test
    public void testOneMissingScript() {
        when(hookScriptManager.exists(SCRIPT_2)).thenReturn(true);

        //Client sends initial request
        observer.onNext(requestFragment());

        verify(responseObserver).onNext(captor.capture());
        verifyNoMoreInteractions(responseObserver); // request should not be completed yet

        //Verify that the observer has sent back a RpcMissingHookScripts fragment
        RpcCallHookScriptsResponseFragment fragment = captor.getValue();
        assertTrue(fragment.hasMissing());
        assertEquals(singletonList(SCRIPT_1), fragment.getMissing().getHookScriptList());

        //Client sends the missing scripts
        reset(responseObserver);
        observer.onNext(hookScriptFragment(SCRIPT_1));
        observer.onCompleted();

        //Verify that the hooks scripts are now invoked
        InOrder inOrder = inOrder(responseObserver);
        captor = ArgumentCaptor.forClass(RpcCallHookScriptsResponseFragment.class);
        inOrder.verify(responseObserver, times(3)).onNext(captor.capture()); // output script 1, 2 and result
        inOrder.verify(responseObserver).onCompleted();
        inOrder.verifyNoMoreInteractions();

        //Verify the data chunks
        verifyHookScriptChunk(SCRIPT_1, captor.getAllValues().get(0).getChunk());
        verifyHookScriptChunk(SCRIPT_2, captor.getAllValues().get(1).getChunk());

        fragment = captor.getValue();
        assertTrue(fragment.hasResponse());
        RpcCallHookScriptsResponse response = fragment.getResponse();
        assertEquals(2, response.getResponsesCount());

        //Verify individual hook script responses
        verifyHookScriptResult(SCRIPT_1, response.getResponses(0));
        verifyHookScriptResult(SCRIPT_2, response.getResponses(1));
    }

    @Test
    public void testTwoMissingScripts() {
        //Client sends initial request
        observer.onNext(requestFragment());

        verify(responseObserver).onNext(captor.capture());
        verifyNoMoreInteractions(responseObserver); // request should not be completed yet

        //Verify that the observer has sent back a RpcMissingHookScripts fragment with both scripts
        RpcCallHookScriptsResponseFragment fragment = captor.getValue();
        assertTrue(fragment.hasMissing());
        assertThat(fragment.getMissing().getHookScriptList(), containsInAnyOrder(SCRIPT_1, SCRIPT_2));

        //Client sends the first missing script
        observer.onNext(hookScriptFragment(SCRIPT_1));

        //Verify that the hook scripts have not been invoked yet
        verify(hookScriptManager, never()).call(anyList(), any(), any());

        //Client sends the second missing script
        reset(responseObserver);
        observer.onNext(hookScriptFragment(SCRIPT_2));

        //Verify that the hooks scripts are now invoked
        InOrder inOrder = inOrder(responseObserver);
        captor = ArgumentCaptor.forClass(RpcCallHookScriptsResponseFragment.class);
        inOrder.verify(responseObserver, times(3)).onNext(captor.capture()); // 2 data chunks + 1 result fragment
        inOrder.verify(responseObserver).onCompleted();
        inOrder.verifyNoMoreInteractions();

        //Verify the data chunks
        verifyHookScriptChunk(SCRIPT_1, captor.getAllValues().get(0).getChunk());
        verifyHookScriptChunk(SCRIPT_2, captor.getAllValues().get(1).getChunk());

        fragment = captor.getValue();
        assertTrue(fragment.hasResponse());
        RpcCallHookScriptsResponse response = fragment.getResponse();
        assertEquals(2, response.getResponsesCount());

        //Verify individual hook script responses
        verifyHookScriptResult(SCRIPT_1, response.getResponses(0));
        verifyHookScriptResult(SCRIPT_2, response.getResponses(1));
    }

    private static RpcCallHookScriptsFragment hookScriptFragment(String scriptId) {
        return RpcCallHookScriptsFragment.newBuilder()
                .setHookScript(RpcHookScript.newBuilder()
                        .setId(scriptId)
                        .setContent(ByteString.copyFrom("content: " + scriptId, UTF_8)))
                .build();
    }

    private static RpcCallHookScriptsFragment requestFragment() {
        return RpcCallHookScriptsFragment.newBuilder()
                .setRequest(RpcCallHookScriptsRequest.newBuilder()
                        .addHookScript(SCRIPT_1)
                        .addHookScript(SCRIPT_2)
                        .addRefUpdates(RpcRefUpdate.newBuilder()
                                .setOldHash(ShaUtils.NULL_SHA1)
                                .setNewHash("1cc935d8c5eb870dadcd9191d568019d66e2c279")
                                .setRefId(ByteString.copyFromUtf8("refs/heads/created")))
                        .addRefUpdates(RpcRefUpdate.newBuilder()
                                .setOldHash("f31e6abf9dc2cfaff796076eea048439ac87b901")
                                .setNewHash(ShaUtils.NULL_SHA1)
                                .setRefId(ByteString.copyFromUtf8("refs/heads/deleted")))
                        .addRefUpdates(RpcRefUpdate.newBuilder()
                                .setOldHash("9b77a6d4f9dba1555494a8e1e6c372a52271de72")
                                .setNewHash("d805a145a26eb96334fffe6dcdad706d4715c225")
                                .setRefId(ByteString.copyFromUtf8("refs/heads/updated")))
                        .setAbortOnFirstVeto(true)
                        .setHookScriptOptions(RpcHookScriptOptions.newBuilder()
                                .putEnvironment("BB_KEY", ByteString.copyFromUtf8("BB_VALUE"))
                                .setMaxOutputLength(2048)
                                .setTimeouts(RpcGitTimeouts.newBuilder()
                                        .setIdle(60)
                                        .setExecution(120)
                                        .build())))
                .build();
    }

    private static void verifyHookScriptResult(String scriptId, RpcHookScriptResponse response) {
        assertEquals(scriptId, response.getHookScript());
        assertEquals(RpcHookResult.HOOK_RESULT_ACCEPTED, response.getResult());
        assertTrue(response.getDurationMs() > 0L);
        assertFalse(response.getTimedOut());
    }

    private static void verifyHookScriptChunk(String scriptId, RpcHookScriptChunk hookScriptChunk) {
        assertEquals(RpcHookScriptChunk.newBuilder()
                .setChunk(RpcHookChunk.newBuilder()
                    .setChannel(RpcHookChunk.Channel.CHANNEL_STDOUT)
                    .setData(ByteString.copyFrom(scriptId + ":stdout line 1\n" + scriptId + ":stdout line 2\n", UTF_8)))
                .setHookScript(scriptId)
                .build(),
                hookScriptChunk);
    }
}