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

import com.atlassian.bitbucket.mesh.AbstractWiredExecutionContextTest;
import com.atlassian.bitbucket.mesh.git.GitRepositories;
import com.atlassian.bitbucket.mesh.git.builder.GitProcessBuilderFactory;
import com.atlassian.bitbucket.mesh.git.exception.CommandFailedException;
import com.atlassian.bitbucket.mesh.io.IoConsumer;
import com.atlassian.bitbucket.mesh.junit.LogListener;
import com.atlassian.bitbucket.mesh.process.handler.AbstractProcessStdioHandler;
import com.atlassian.bitbucket.mesh.process.handler.LineDecoder;
import com.atlassian.bitbucket.mesh.process.handler.LoggingStdioHandler;
import com.atlassian.bitbucket.mesh.repository.Repository;
import com.atlassian.bitbucket.mesh.test.annotation.DirtiesRepo;
import com.atlassian.bitbucket.mesh.test.annotation.Repo;
import com.atlassian.bitbucket.mesh.util.Version;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Iterables;
import org.junit.After;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import org.mockito.ArgumentCaptor;
import org.mockito.InOrder;
import org.slf4j.LoggerFactory;

import javax.annotation.Nonnull;
import javax.annotation.Resource;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.stream.Collectors;

import static org.hamcrest.Matchers.allOf;
import static org.hamcrest.Matchers.containsString;
import static org.junit.Assume.assumeTrue;
import static org.mockito.Mockito.*;

public class GitHookInvocationTest extends AbstractWiredExecutionContextTest {

    private static final Version VERSION_REFERENCE_TRANSACTION = new Version(2, 28);

    @Rule
    public final TemporaryFolder temporaryFolder = new TemporaryFolder();
    @Rule
    public final LogListener logListener = new LogListener(DefaultGitHookService.class);

    @Resource
    private GitProcessBuilderFactory builderFactory;
    @Resource
    private ConfigurableGitHookService hookService;
    @Repo(GitRepositories.MERGE)
    private Repository mergeRepository;
    @Repo
    private Repository repository;

    @After
    public void tearDown() {
        hookService.reset();
    }

    @DirtiesRepo
    @Test
    public void testReferenceTransactionCanCancel() {
        assumeTrue(gitSupportsReferenceTransaction());

        GitHookHandler handler = addHandler("git-reference-transaction", false,
                writer -> writer.writeStderr("blocked by test"));

        CommandFailedException e = assertThrows(CommandFailedException.class, () -> builderFactory.builder(repository)
                .updateRef()
                .set("new-ref", "master")
                .build()
                .run());

        assertThat(e.getStderr(), containsString("blocked by test"));

        ArgumentCaptor<HookRequest> requestCaptor = ArgumentCaptor.forClass(HookRequest.class);
        verify(handler, times(2)).handle(eq(repository), requestCaptor.capture(), any());
        assertEquals(ImmutableList.of("prepared", "aborted"), requestCaptor.getAllValues().stream()
                .map(request -> Iterables.getFirst(request.getArguments(), ""))
                .collect(Collectors.toList()));
    }

    @DirtiesRepo
    @Test
    public void testReferenceTransactionTriggeredForUnregisteredRequest() {
        assumeTrue(gitSupportsReferenceTransaction());

        GitHookHandler handler = addHandler("git-reference-transaction", true, null);
        builderFactory.builder(repository)
                .updateRef()
                .set("new-ref", "master")
                .build()
                .run();

        ArgumentCaptor<HookRequest> requestCaptor = ArgumentCaptor.forClass(HookRequest.class);
        verify(handler, times(2)).handle(eq(repository), requestCaptor.capture(), any());
        assertEquals(ImmutableList.of("prepared", "committed"), requestCaptor.getAllValues().stream()
                .map(request -> Iterables.getFirst(request.getArguments(), ""))
                .collect(Collectors.toList()));
    }

    @DirtiesRepo
    @Test
    public void testReferenceTransactionWithRegisteredHandler() {
        assumeTrue(gitSupportsReferenceTransaction());

        GitHookHandler handler = mockHandler("git-reference-transaction", true, null);
        try (HookRequestHandle handle = hookService.registerRequest(repository, handler)) {
            handle.configure(builderFactory.builder(repository))
                    .updateRef()
                    .set("new-ref", "master")
                    .build()
                    .run();
        }

        ArgumentCaptor<HookRequest> requestCaptor = ArgumentCaptor.forClass(HookRequest.class);
        verify(handler, times(2)).handle(eq(repository), requestCaptor.capture(), any());
        assertEquals(ImmutableList.of("prepared", "committed"), requestCaptor.getAllValues().stream()
                .map(request -> Iterables.getFirst(request.getArguments(), ""))
                .collect(Collectors.toList()));
    }

    @DirtiesRepo
    @Test
    public void testPushTriggersHooks() {
        //Create a local clone
        Path localClone = temporaryFolder.getRoot().toPath().resolve("repo");
        builderFactory.builder()
                .clone()
                .bare()
                .directory(localClone)
                .origin(repository)
                .build()
                .run();

        GitHookHandler preReceive = mockHandler("git-pre-receive", true, writer -> {
            writer.writeStdout("stdout: pre-receive\n");
            writer.writeStderr("stderr: pre-receive\n");
        });
        GitHookHandler postReceive = mockHandler("git-post-receive", true, writer -> {
            writer.writeStdout("stdout: post-receive\n");
            writer.writeStderr("stderr: post-receive\n");
        });
        GitHookHandler refTx = mockHandler("git-reference-transaction", true, null);

        StdoutStderrHandler handler = new StdoutStderrHandler();
        try (HookRequestHandle handle = hookService.registerRequest(repository, preReceive, postReceive, refTx)) {
            handle.configure(builderFactory.builder())
                    .workDir(localClone)
                    .push()
                    .repository(repository)
                    .refspec("master:new-master-from-local")
                    .build(handler)
                    .run();
        }

        InOrder inOrder = inOrder(preReceive, postReceive, refTx);
        inOrder.verify(preReceive).handle(eq(repository), any(), any());
        if (gitSupportsReferenceTransaction()) {
            inOrder.verify(refTx, times(2)).handle(eq(repository), any(), any()); //prepared and committed
        }
        inOrder.verify(postReceive).handle(eq(repository), any(), any());
        assertThat(handler.getStderr(), allOf(
                containsString("remote: stdout: pre-receive"),
                containsString("remote: stderr: pre-receive"),
                containsString("remote: stdout: post-receive"),
                containsString("remote: stderr: post-receive")
        ));
        assertEquals("", handler.getStdout());
    }

    @DirtiesRepo
    @Test
    public void testPushFromLocalClone() {
        //Create a local clone
        Path localClone = temporaryFolder.getRoot().toPath().resolve("repo");
        builderFactory.builder()
                .clone()
                .bare()
                .directory(localClone)
                .origin(repository)
                .build()
                .run();

        //Push a branch from the local clone to repository. This should be declined because no STASH_REPOSITORY_ID is
        //set when the hook is triggered
        CommandFailedException exception = assertThrows(CommandFailedException.class, () ->
                builderFactory.builder()
                    .workDir(localClone)
                    .push()
                    .repository(repository)
                    .refspec("master:new-master-from-local")
                    .build(new LoggingStdioHandler(LoggerFactory.getLogger(GitHookInvocationTest.class)))
                    .run());

        assertThat(exception.getStderr(), containsString("no repository or hook ID set"));
    }

    @DirtiesRepo
    @Test
    public void testPushFromOtherRepo() {
        //Push a branch from one repository to another. This should result in warnings because the GIT_DIR and the
        //repository_id do not match
        builderFactory.builder(mergeRepository)
                .push()
                .repository(repository)
                .refspec("master:new-master-from-other-repo")
                .build(new LoggingStdioHandler(LoggerFactory.getLogger(GitHookInvocationTest.class)))
                .run();

        logListener.assertWarn("git-pre-receive hook invocation detected");
        logListener.assertWarn("git-post-receive hook invocation detected");
    }

    private static GitHookHandler mockHandler(@Nonnull String hookName, boolean outcome,
                                              IoConsumer<GitHookOutputWriter> action) {
        GitHookHandler handler = mock(GitHookHandler.class);
        when(handler.getHookName()).thenReturn(hookName);
        when(handler.handle(any(), any(), any())).thenAnswer(invocation -> {
            if (action != null) {
                GitHookOutputWriter writer = invocation.getArgument(2);
                if (writer != null) {
                    action.accept(writer);
                }
            }
            return outcome;
        });
        return handler;
    }

    private GitHookHandler addHandler(@Nonnull String hookName, boolean outcome,
                                      IoConsumer<GitHookOutputWriter> action) {
        GitHookHandler handler = mockHandler(hookName, outcome, action);
        hookService.addHandler(handler);
        return handler;
    }

    private boolean gitSupportsReferenceTransaction() {
        return builderFactory.builder().isGitVersionAtLeast(VERSION_REFERENCE_TRANSACTION);
    }

    private static class StdoutStderrHandler extends AbstractProcessStdioHandler<Void> {

        private final LineDecoder stderrDecoder = new LineDecoder(StandardCharsets.UTF_8, LineDecoder.Mode.LF);
        private final LineDecoder stdoutDecoder = new LineDecoder(StandardCharsets.UTF_8, LineDecoder.Mode.LF);
        private final StringBuilder stderr = new StringBuilder();
        private final StringBuilder stdout = new StringBuilder();

        @Override
        public Void getResult() {
            return null;
        }

        @Override
        public void onStderr(@Nonnull ByteBuffer buffer, boolean closed) {
            stderrDecoder.forEach(buffer, closed, (line, ignored) -> {
                stderr.append(line).append('\n');
                return true;
            });
        }

        @Override
        public void onStdout(@Nonnull ByteBuffer buffer, boolean closed) {
            stdoutDecoder.forEach(buffer, closed, (line, ignored) -> {
                stdout.append(line).append('\n');
                return true;
            });
        }

        @Nonnull
        String getStderr() {
            return stderr.toString();
        }

        @Nonnull
        String getStdout() {
            return stdout.toString();
        }
    }
}
