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

import com.atlassian.bitbucket.mesh.AbstractStatusException;
import com.atlassian.bitbucket.mesh.config.HooksConfig;
import com.atlassian.bitbucket.mesh.execution.ExecutionCallback;
import com.atlassian.bitbucket.mesh.execution.ExecutionContext;
import com.atlassian.bitbucket.mesh.execution.ExecutionManager;
import com.atlassian.bitbucket.mesh.io.MoreFiles;
import com.atlassian.bitbucket.mesh.junit.LogListener;
import com.atlassian.bitbucket.mesh.junit.TemporaryDirectory;
import com.atlassian.bitbucket.mesh.repository.Repository;
import com.atlassian.bitbucket.mesh.repository.RepositoryManager;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
import org.apache.commons.lang3.SystemUtils;
import org.apache.commons.lang3.mutable.MutableBoolean;
import org.hamcrest.CoreMatchers;
import org.junit.After;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.InOrder;
import org.mockito.Mock;
import org.mockito.junit.MockitoJUnitRunner;

import javax.annotation.Nonnull;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Set;
import java.util.concurrent.ScheduledExecutorService;
import java.util.stream.Stream;

import static com.atlassian.bitbucket.mesh.git.hook.GitHookInputParser.ChunkType.GIT_DIR;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.not;
import static org.junit.Assert.*;
import static org.junit.Assume.assumeFalse;
import static org.mockito.Mockito.*;

@RunWith(MockitoJUnitRunner.class)
public class DefaultGitHookServiceTest {

    @Rule
    public final LogListener logListener = new LogListener(DefaultGitHookService.class);
    @Rule
    public final TemporaryDirectory tempDir = new TemporaryDirectory();

    @Mock
    private HooksConfig config;
    @Mock
    private ExecutionContext executionContext;
    @Mock
    private ExecutionManager executionManager;
    @Mock
    private ScheduledExecutorService executor;
    @Mock
    private GitHookHandler hookHandler;
    @Mock
    private Repository repository;
    @Mock
    private RepositoryManager repositoryManager;
    private DefaultGitHookService service;

    @Before
    public void setup() throws Throwable { //Throwable to match Java 11's type inference for SecurityContext.call
        //Lenient stubbing, for calls that may or may not happen
        lenient().when(config.getBufferCapacity()).thenReturn(2048L);
        lenient().doAnswer(invocation -> {
            Runnable runnable = invocation.getArgument(0);
            runnable.run();
            return null;
        }).when(executor).execute(any());
        lenient().when(executionManager.withContext(any())).thenAnswer(invocation -> {
            ExecutionCallback<?> callback = invocation.getArgument(0);
            return callback.callWithContext(executionContext);
        });

        //Strict stubbing, for calls that should always happen
        when(executionManager.getContextOrThrow()).thenReturn(executionContext);
        when(hookHandler.handle(same(repository), any(), any())).thenReturn(true);
        when(hookHandler.getHookName()).thenReturn("git-pack-objects");
        when(repository.getPath()).thenReturn(tempDir.getRoot());
        doAnswer(invocation -> {
            Runnable runnable = invocation.getArgument(0);
            runnable.run();
            return null;
        }).when(executionContext).run(any());

        service = new DefaultGitHookService(config, executionManager, executor,
                ImmutableList.of(hookHandler), repositoryManager, tempDir.newFolder("tmp"));
    }

    @After
    public void tearDown() {
        service.shutdown();
    }

    @Test
    public void testHandleRawRequest() throws IOException {
        HookRequestHandle handle = service.registerRequest(repository);
        String requestId = handle.getRequestId();

        GitHookInputParserBuilder builder = new GitHookInputParserBuilder()
                .hookRequestId(requestId)
                .gitDir(tempDir.getRoot().toString())
                .hookType("git-pack-objects")
                .arguments("foo", "bar")
                .environment("foo", "bar")
                .stdin("one")
                .stdin("two");

        GitHookOutputWriter writer = mock(GitHookOutputWriter.class);

        service.handleRawRequest(builder.build(), writer);
        assertTrue("Hooks should have been called", handle.isCalled());
        assertTrue("The request should have been accepted", handle.isAccepted());

        InOrder inOrder = inOrder(hookHandler, writer);
        inOrder.verify(hookHandler).handle(same(repository), notNull(), same(writer));
        inOrder.verify(writer).writeExitCode(eq(0));
    }

    @Test
    public void testHandleRawRequestHandlesExceptions() throws IOException {
        HookRequestHandle handle = service.registerRequest(repository);
        String requestId = handle.getRequestId();

        GitHookInputParser parser = mock(GitHookInputParser.class);
        when(parser.readContextId()).thenReturn(HookContextId.ofHookRequestId(requestId));
        when(parser.readChunk(GIT_DIR)).thenReturn(tempDir.getRoot().toString());
        when(parser.readRequest()).thenThrow(IOException.class);

        GitHookOutputWriter writer = mock(GitHookOutputWriter.class);

        service.handleRawRequest(parser, writer);

        verify(hookHandler, never()).handle(any(), any(), any());
        verify(parser).readContextId();
        verify(parser).readChunk(GIT_DIR);
        verify(parser).readRequest();
        //An exit code should always be written
        verify(writer).writeExitCode(eq(1));
        logListener.assertError("Failed reading hook request");
    }

    @Test
    public void testHandleRawRequestTooLarge() throws IOException {
        HookRequestHandle handle = service.registerRequest(repository);
        String requestId = handle.getRequestId();

        GitHookInputParser parser = new GitHookInputParserBuilder()
                .hookRequestId(requestId)
                .gitDir(tempDir.getRoot().toString())
                .hookType("hook1")
                .arguments("foo")
                .stdin("Even one byte is too much")
                .build(1L);
        GitHookOutputWriter writer = mock(GitHookOutputWriter.class);

        service.handleRawRequest(parser, writer);

        verify(writer).writeStderr(eq("The hook request is too large to process."));
        verify(writer).writeExitCode(eq(1));
    }

    @Test
    public void testHandleRequestAccepted() {
        assertThat(handleRequest(), equalTo(true));
    }

    @Test
    public void testHandleRequestForUnknownHook() {
        DefaultGitHookService.HookContext hookContext = mock(DefaultGitHookService.HookContext.class, RETURNS_MOCKS);

        HookRequest request = mock(HookRequest.class);
        when(request.getHookName()).thenReturn("git-pre-receive");

        assertThat(service.handleRequest(hookContext, request, mock(GitHookOutputWriter.class)), equalTo(false));

        verify(hookHandler, never()).handle(any(), any(), any());
    }

    @Test
    public void testHandleRequestPropagatesExceptions() {
        doThrow(AbstractStatusException.class).when(hookHandler).handle(notNull(), notNull(), notNull());

        assertThrows(AbstractStatusException.class, this::handleRequest);
    }

    @Test
    public void testHandleRequestRejected() {
        when(hookHandler.handle(notNull(), notNull(), notNull())).thenReturn(false);

        assertThat(handleRequest(), equalTo(false));
    }

    @Test
    public void testInstallsNativeCallbackIfNativeCallbackEnabled() throws IOException {
        assumeFalse("Native hooks are not available on Apple silicon", isAppleSilicon());

        when(config.isNativeCallbackEnabled()).thenReturn(true);
        Path hooksDir = tempDir.newFolder("hooks");

        service.startup();
        service.installHooks(hooksDir);

        ImmutableSet.Builder<Path> builder = ImmutableSet.builder();
        try (Stream<Path> stream = Files.list(hooksDir)) {
            stream.forEach(builder::add);
        }
        Set<Path> hooks = builder.build();

        //Verify that the native hook callback has been tested
        verify(executor).execute(any());

        //Verify that all hooks have been installed
        assertEquals(ImmutableSet.of(
                hooksDir.resolve("pack-objects"),
                hooksDir.resolve("pre-receive"),
                hooksDir.resolve("post-receive"),
                hooksDir.resolve("reference-transaction")), hooks);

        for (Path hook : hooks) {
            assertTrue("hook " + hook + " is not executable", Files.isExecutable(hook));
            assertThat(MoreFiles.toString(hook, UTF_8), not(CoreMatchers.startsWith("#!/usr/bin/env perl")));
        }
    }

    @Test
    public void testInstallsPerlCallbackIfNativeCallbackDisabled() throws IOException {
        when(config.isNativeCallbackEnabled()).thenReturn(false);
        Path hooksDir = tempDir.newFolder("hooks");

        service.startup();
        service.installHooks(hooksDir);

        ImmutableSet.Builder<Path> builder = ImmutableSet.builder();
        try (Stream<Path> stream = Files.list(hooksDir)) {
            stream.forEach(builder::add);
        }
        Set<Path> hooks = builder.build();

        //Verify that all hooks have been installed
        assertEquals(ImmutableSet.of(
                hooksDir.resolve("pack-objects"),
                hooksDir.resolve("pre-receive"),
                hooksDir.resolve("post-receive"),
                hooksDir.resolve("reference-transaction")), hooks);

        boolean posix = MoreFiles.isPosixSupported(hooksDir);
        for (Path hook : hooks) {
            if (posix) {
                assertTrue("hook " + hook + " is not executable", Files.isExecutable(hook));
            }
            assertThat(MoreFiles.toString(hook, UTF_8), CoreMatchers.startsWith("#!/usr/bin/env perl"));
        }
    }

    @Test
    public void testSetsUpHookHandlerOverride() throws Exception {
        String hookName = "git-pack-objects";
        MutableBoolean overrideRun = new MutableBoolean(false);

        HookRequestHandle handle = service.registerRequest(repository, new GitHookHandler() {
            @Nonnull
            @Override
            public String getHookName() {
                return hookName;
            }

            @Override
            public boolean handle(@Nonnull Repository repository, @Nonnull HookRequest request,
                                  @Nonnull GitHookOutputWriter outputWriter) {
                overrideRun.setTrue();
                return true;
            }
        });

        GitHookInputParserBuilder builder = new GitHookInputParserBuilder()
                .hookRequestId(handle.getRequestId())
                .gitDir(tempDir.getRoot().toString())
                .hookType(hookName)
                .arguments("foo", "bar")
                .environment("foo", "bar")
                .stdin("one")
                .stdin("two");

        service.handleRawRequest(builder.build(), mock(GitHookOutputWriter.class));

        assertTrue(overrideRun.getValue());
    }

    private static boolean isAppleSilicon() {
        return SystemUtils.IS_OS_MAC && "aarch64".equals(SystemUtils.OS_ARCH);
    }

    private boolean handleRequest() {
        DefaultGitHookService.HookContext hookContext = mock(DefaultGitHookService.HookContext.class, RETURNS_MOCKS);
        when(hookContext.getRepository()).thenReturn(repository);

        GitHookOutputWriter writer = mock(GitHookOutputWriter.class);

        HookRequest request = mock(HookRequest.class);
        when(request.getHookName()).thenReturn("git-pack-objects");

        try {
            return service.handleRequest(hookContext, request, writer);
        } finally {
            verify(hookHandler).handle(same(repository), same(request), same(writer));
        }
    }
}
