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

import com.atlassian.bitbucket.mesh.config.HookScriptsConfig;
import com.atlassian.bitbucket.mesh.git.GitQuarantineRegistry;
import com.atlassian.bitbucket.mesh.git.builder.GitProcessBuilder;
import com.atlassian.bitbucket.mesh.git.builder.GitProcessBuilderFactory;
import com.atlassian.bitbucket.mesh.git.builder.GitQuarantineEnvironment;
import com.atlassian.bitbucket.mesh.git.builder.clone.GitClone;
import com.atlassian.bitbucket.mesh.git.builder.remote.GitRemote;
import com.atlassian.bitbucket.mesh.git.ref.RefUpdate;
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.layout.StorageLayout;
import com.atlassian.bitbucket.mesh.process.*;
import com.atlassian.bitbucket.mesh.repository.Repository;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Answers;
import org.mockito.ArgumentCaptor;
import org.mockito.InOrder;
import org.mockito.Mock;
import org.mockito.junit.MockitoJUnitRunner;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.Channels;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.EnumSet;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicInteger;

import static com.atlassian.bitbucket.mesh.git.GitConstants.ENV_ALTERNATE_OBJECT_DIRS;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.nio.file.attribute.PosixFilePermission.*;
import static org.junit.Assert.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;

@RunWith(MockitoJUnitRunner.class)
public class DefaultHookScriptManagerMockTest {

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

    @Mock
    private GitProcessBuilderFactory builderFactory;
    @Mock
    private HookScriptsConfig config;
    @Mock
    private HookScriptCommandLineFactory commandLineFactory;
    @Mock(answer = Answers.RETURNS_DEEP_STUBS)
    private GitClone gitClone;
    @Mock(answer = Answers.RETURNS_DEEP_STUBS)
    private GitRemote gitRemote;
    private DefaultHookScriptManager hookScriptManager;
    @Mock(answer = Answers.RETURNS_DEEP_STUBS)
    private ProcessCreator processCreator;
    @Mock
    private GitQuarantineRegistry quarantineRegistry;
    private Path root;
    @Mock
    private StorageLayout storageLayout;
    private Path tmpDir;

    @Before
    public void setup() throws IOException {
        root = tempDir.newFolder("hook-scripts");
        tmpDir = tempDir.newFolder("tmp");

        GitProcessBuilder processBuilder = mock(GitProcessBuilder.class);
        when(processBuilder.clone()).thenReturn(gitClone);
        when(processBuilder.remote()).thenReturn(gitRemote);

        when(builderFactory.builder()).thenReturn(processBuilder);
        when(config.getGcPrune()).thenReturn(Duration.ZERO);
        when(commandLineFactory.create(anyString()))
                .thenAnswer(invocation -> ImmutableList.of(invocation.getArgument(0)));
        when(quarantineRegistry.get(any(Repository.class)))
                .thenAnswer(invocation -> Optional.of(new GitQuarantineEnvironment(
                        invocation.getArgument(0), "alternates", "object-dir", "quarantine")));
        when(storageLayout.getHookScriptsDir()).thenReturn(root);
        when(storageLayout.getTempDir()).thenReturn(tmpDir);

        hookScriptManager = new DefaultHookScriptManager(builderFactory, commandLineFactory, config, processCreator,
                quarantineRegistry, storageLayout, false);
    }

    @Test
    public void testCall() {
        //Prepare by writing 3 script files
        String script1 = toHookScriptId(1234, 1);
        String script2 = toHookScriptId(1235, 1);
        String script3 = toHookScriptId(1236, 1);
        hookScriptManager.put(script1, os -> os.write("something".getBytes(UTF_8)));
        hookScriptManager.put(script2, os -> os.write("something else".getBytes(UTF_8)));
        hookScriptManager.put(script3, os -> os.write("script 3".getBytes(UTF_8)));

        //Mock the hook process
        ByteArrayOutputStream stdin = new ByteArrayOutputStream();
        AtomicInteger processCount = new AtomicInteger(0);
        when(processCreator.run(any())).thenAnswer(invocation -> {
            int processNum = processCount.incrementAndGet();
            if (processNum == 3) {
                throw new RuntimeException("Mocking a process execution failure");
            }
            ProcessParameters<Void> parameters = invocation.getArgument(0);
            ProcessStdioHandler<Void> handler = parameters.getStdioHandler();
            ProcessCompletionHandler completionHandler = parameters.getCompletionHandler();
            OsProcess process = mock(OsProcess.class);
            handler.onPreStart(process);
            handler.onStart(process);

            //Record the process stdin and add it to stdin
            ByteBuffer buffer = ByteBuffer.allocate(32 * 1024);
            handler.onStdinReady(buffer);
            Channels.newChannel(stdin).write(buffer);

            handler.onStdout(ByteBuffer.wrap((processNum + ":stdout").getBytes(UTF_8)), true);
            handler.onStderr(ByteBuffer.wrap((processNum + ":stderr").getBytes(UTF_8)), true);
            int exitCode = processNum % 2;
            handler.onExit(exitCode);
            ProcessSummary summary = mock(ProcessSummary.class);
            when(summary.getExitCode()).thenReturn(exitCode);
            completionHandler.onComplete(summary);
            return null;
        });

        Repository repository = mock(Repository.class);
        HookScriptCallback callback = mock(HookScriptCallback.class);
        hookScriptManager.call(ImmutableList.of(script1, script2, script3),
                new CallHookScriptParameters.Builder(repository)
                        .idleTimeout(Duration.ofSeconds(60))
                        .executionTimeout(Duration.ofSeconds(120))
                        .refUpdates(ImmutableList.of(
                                RefUpdate.ofAdd("refs/heads/created", "1cc935d8c5eb870dadcd9191d568019d66e2c279"),
                                RefUpdate.ofDelete("refs/heads/deleted", "f31e6abf9dc2cfaff796076eea048439ac87b901"),
                                RefUpdate.of("refs/heads/updated", "9b77a6d4f9dba1555494a8e1e6c372a52271de72",
                                        "d805a145a26eb96334fffe6dcdad706d4715c225")))
                        .environment("BB_KEY", "BB_VALUE")
                        .build(),
                callback);

        //Has the quarantine environment been configured for each of the hook script invocations?
        verify(quarantineRegistry, times(3)).get(same(repository));

        //Validate that we have gotten the callbacks we expected
        ArgumentCaptor<ProcessSummary> summaryCaptor = ArgumentCaptor.forClass(ProcessSummary.class);
        InOrder inOrder = inOrder(callback);
        inOrder.verify(callback).isCanceled(); //Leading isCanceled before setting up sandbox
        inOrder.verify(callback).onStartScript(script1);
        inOrder.verify(callback).onStdout(ByteBuffer.wrap("1:stdout".getBytes(UTF_8)), true);
        inOrder.verify(callback).onStderr(ByteBuffer.wrap("1:stderr".getBytes(UTF_8)), true);
        inOrder.verify(callback).onComplete(summaryCaptor.capture());
        inOrder.verify(callback).isCanceled(); //After script1
        inOrder.verify(callback).onStartScript(script2);
        inOrder.verify(callback).onStdout(ByteBuffer.wrap("2:stdout".getBytes(UTF_8)), true);
        inOrder.verify(callback).onStderr(ByteBuffer.wrap("2:stderr".getBytes(UTF_8)), true);
        inOrder.verify(callback).onComplete(summaryCaptor.capture());
        inOrder.verify(callback).isCanceled(); //After script2
        inOrder.verify(callback).onStartScript(script3);
        inOrder.verify(callback).onStderr(ByteBuffer.wrap(("Hook script " + script3 + " failed").getBytes(UTF_8)), true);
        inOrder.verify(callback).onComplete(summaryCaptor.capture());
        inOrder.verify(callback).isCanceled(); //After script3
        verifyNoMoreInteractions(callback);

        assertEquals(1, summaryCaptor.getAllValues().get(0).getExitCode());
        assertEquals(0, summaryCaptor.getAllValues().get(1).getExitCode());
        assertEquals(1, summaryCaptor.getAllValues().get(2).getExitCode());

        //Validate the following stdin has been read twice (once per script)
        String expected =
                "0000000000000000000000000000000000000000 1cc935d8c5eb870dadcd9191d568019d66e2c279 refs/heads/created\n" +
                "f31e6abf9dc2cfaff796076eea048439ac87b901 0000000000000000000000000000000000000000 refs/heads/deleted\n" +
                "9b77a6d4f9dba1555494a8e1e6c372a52271de72 d805a145a26eb96334fffe6dcdad706d4715c225 refs/heads/updated\n";
        assertArrayEquals((expected + expected).getBytes(UTF_8), stdin.toByteArray());

        @SuppressWarnings("unchecked")
        ArgumentCaptor<ProcessParameters<Void>> parameterCaptor = ArgumentCaptor.forClass(ProcessParameters.class);
        verify(processCreator, times(3)).run(parameterCaptor.capture());

        ProcessParameters<Void> processParameters = parameterCaptor.getValue();
        assertEquals(ImmutableMap.of(
                "BB_KEY", "BB_VALUE",
                ENV_ALTERNATE_OBJECT_DIRS, "quarantine"), processParameters.getEnvironment());
        assertEquals(Duration.ofSeconds(60), processParameters.getIdleInterval());
        assertEquals(Duration.ofSeconds(120), processParameters.getExecutionInterval());
        //Verify that the script is executed in a temporary directory
        assertNotNull(processParameters.getWorkDir());
        assertTrue(processParameters.getWorkDir().startsWith(tmpDir));
        //Verify that the sandbox directory has been cleaned up
        assertFalse(Files.exists(processParameters.getWorkDir()));
    }

    @Test
    public void testCallReturnsIfCanceledEarly() {
        HookScriptCallback callback = mock(HookScriptCallback.class);
        when(callback.isCanceled()).thenReturn(true);

        CallHookScriptParameters parameters = mock(CallHookScriptParameters.class);

        hookScriptManager.call(ImmutableList.of(toHookScriptId(1300, 1)), parameters, callback);

        //Since call should bail out immediately, there shouldn't be any interactions with any mocks
        verifyNoInteractions(builderFactory, commandLineFactory, parameters);
    }

    @Test
    public void testCollectGarbage() {
        //Prepare by writing 3 script files, this also creates a keep-alive marker
        String script1Id = toHookScriptId(1234, 1);
        String script2Id = toHookScriptId(1235, 1);
        String script3Id = toHookScriptId(1236, 1);
        hookScriptManager.put(script1Id, os -> os.write("something".getBytes(UTF_8)));
        hookScriptManager.put(script2Id, os -> os.write("something else".getBytes(UTF_8)));
        hookScriptManager.put(script3Id, os -> os.write("script 3".getBytes(UTF_8)));

        Path script1Path = hookScriptManager.getPath(script1Id);
        Path script2Path = hookScriptManager.getPath(script2Id);
        Path script3Path = hookScriptManager.getPath(script3Id);

        //Trigger GC. This shouldn't delete any of the scripts, but will clear the keep-alive markers
        hookScriptManager.collectGarbage();

        assertTrue(Files.isRegularFile(script1Path));
        assertTrue(Files.isRegularFile(script2Path));
        assertTrue(Files.isRegularFile(script3Path));

        //Simulate script1 and script3 being used
        assertTrue(hookScriptManager.exists(script1Id));
        assertTrue(hookScriptManager.exists(script3Id));

        //Trigger GC. This should delete script2
        hookScriptManager.collectGarbage();

        logListener.assertInfo("stale hook script " + script2Path.getFileName());
        assertTrue(Files.isRegularFile(script1Path));
        assertFalse(Files.isRegularFile(script2Path));
        assertTrue(Files.isRegularFile(script3Path));

        //Trigger GC. This should delete script1 and script3
        hookScriptManager.collectGarbage();

        logListener.assertInfo("stale hook script " + script1Path.getFileName());
        logListener.assertInfo("stale hook script " + script3Path.getFileName());
        assertFalse(Files.isRegularFile(script1Path));
        assertFalse(Files.isRegularFile(script3Path));
    }

    @Test
    public void testExists() throws IOException {
        assertFalse(hookScriptManager.exists(toHookScriptId(123, 4)));

        String scriptId = toHookScriptId(1248, 2);
        Path path = hookScriptManager.getPath(scriptId);
        Files.write(path, "test".getBytes(UTF_8));
        assertTrue(hookScriptManager.exists(scriptId));
    }

    @Test
    public void testGetPath() {
        assertEquals(root.resolve("123-4"), hookScriptManager.getPath(toHookScriptId(123, 4)));
        assertEquals(root.resolve("99-0"), hookScriptManager.getPath(toHookScriptId(99, 0)));
    }

    @Test(expected = InvalidHookScriptIdException.class)
    public void testGetPathThrowsForInvalidId() {
        hookScriptManager.getPath("something else");
    }

    @Test
    public void testPut() throws IOException {
        String scriptId = toHookScriptId(12, 34);
        Path path = hookScriptManager.getPath(scriptId);
        //Pre-condition check; the file should not exist yet
        assertFalse(Files.exists(path));

        hookScriptManager.put(scriptId, os -> os.write("test-content".getBytes(UTF_8)));

        assertTrue(Files.exists(path));
        assertEquals("test-content", MoreFiles.toString(path, UTF_8));
        if (MoreFiles.isPosixSupported(path)) {
            //If POSIX is supported, the file should be rwx for the current user
            assertEquals(EnumSet.of(OWNER_READ, OWNER_WRITE, OWNER_EXECUTE), Files.getPosixFilePermissions(path));
        }
    }

    @Test(expected = HookScriptWriteFailedException.class)
    public void testPutThrowsIfWriteFails() {
        String scriptId = toHookScriptId(12, 34);
        Path path = hookScriptManager.getPath(scriptId);
        //Pre-condition check; the file should not exist yet
        assertFalse(Files.exists(path));

        //This setup isn't very realistic, but it's a straightforward way to make file creation fail
        MoreFiles.deleteQuietly(root);

        try {
            hookScriptManager.put(scriptId, os -> os.write("test-content".getBytes(UTF_8)));
        } finally {
            logListener.assertError("Failed to write hook script");
        }
    }

    private static String toHookScriptId(long id, int version) {
        return "hook-scripts/" + id + "/" + version;
    }
}