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

import com.atlassian.bitbucket.mesh.AbstractWiredExecutionContextTest;
import com.atlassian.bitbucket.mesh.config.ApplicationConfig;
import com.atlassian.bitbucket.mesh.git.builder.GitProcessBuilder;
import com.atlassian.bitbucket.mesh.git.builder.GitProcessBuilderFactory;
import com.atlassian.bitbucket.mesh.git.builder.packobjects.PackObjectsStdioHandler;
import com.atlassian.bitbucket.mesh.git.cache.PackCacheExpiryStrategy;
import com.atlassian.bitbucket.mesh.git.cache.PackCacheService;
import com.atlassian.bitbucket.mesh.git.hook.*;
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.process.OsProcess;
import com.atlassian.bitbucket.mesh.process.handler.PathStdioHandler;
import com.atlassian.bitbucket.mesh.process.handler.StringStdioHandler;
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.throttle.ThrottleService;
import com.atlassian.bitbucket.mesh.throttle.Ticket;
import com.atlassian.util.contentcache.*;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import org.apache.commons.io.output.TeeOutputStream;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.mockito.Mock;
import org.mockito.Spy;
import org.mockito.invocation.InvocationOnMock;
import org.mockito.stubbing.Answer;
import org.springframework.beans.factory.annotation.Autowired;

import javax.annotation.Nonnull;
import javax.annotation.Resource;
import java.io.IOException;
import java.io.OutputStream;
import java.io.UncheckedIOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.concurrent.TimeUnit;

import static com.atlassian.bitbucket.mesh.git.GitConstants.ENV_THROTTLED_RESOURCE;
import static com.atlassian.bitbucket.mesh.throttle.ThrottledResource.MIRROR_PACK;
import static com.atlassian.bitbucket.mesh.throttle.ThrottledResource.PACK;
import static java.nio.channels.Channels.newChannel;
import static java.nio.file.StandardOpenOption.*;
import static org.hamcrest.Matchers.is;
import static org.mockito.Mockito.*;

public class CachingPackObjectsHookHandlerTest extends AbstractWiredExecutionContextTest {

    private static final String FETCH_UNIX =
            "Do you wanna know\n" +
                    "why I use a\n" +
                    "--not\n" +
                    "knife?";
    private static final String FETCH_UNIX_ADD_LINE = FETCH_UNIX + "\n";
    private static final String FETCH_UNIX_ADD_LINES = FETCH_UNIX + "\n\n";
    private static final String CLONE_UNIX =
            "Do you wanna know\n" +
                    "why I use a\n" +
                    "knife?\n" +
                    "--not";
    private static final String CLONE_UNIX_ADD_LINE = CLONE_UNIX + "\n";
    private static final String CLONE_UNIX_ADD_LINES = CLONE_UNIX + "\n\n";
    private static final String SINGLE_LINE = "--not";
    private static final String SINGLE_LINE_WITH_BREAK = "--not\n";
    private static final List<String> ARGS_FULL_CLONE = new ArrayList<String>() {{
        add("git");
        add("pack-objects");
        add("--revs");
        add("--thin");
        add("--stdout");
        add("--progress");
        add("--delta-base-offset");
    }};
    private static final String INPUT_FULL_CLONE = "0a943a29376f2336b78312d99e65da17048951db\n" +
            "--not\n\n";
    private static final String INPUT_FETCH = "0a943a29376f2336b78312d99e65da17048951db\n" +
            "--not\n" +
            "6053a1eaa1c009dd11092d09a72f3c41af1b59ad\n\n";
    private static final String PACK_SHA_CLONE = "pack\t539cf1050a0da7a7976dadd88a2671d15ac4e3a3";
    private static final String PACK_SHA_FETCH = "pack\t1a265eeffe363286a53491cde5ab398ff1687533";

    @Rule
    public final LogListener logListener = new LogListener(CachingPackObjectsHookHandler.class); //Suppress logging
    @Rule
    public final TemporaryDirectory tempDir = new TemporaryDirectory();

    @Autowired
    @Spy
    private GitProcessBuilderFactory builderFactory;
    private GitProcessBuilder builder;
    @Resource
    private ApplicationConfig config;
    private GitHookOutputWriter hookWriter;
    @Repo
    private Repository repository;
    @Mock
    private ThrottleService throttleService;
    @Mock
    private Ticket ticketHeld;
    @Mock
    private Ticket ticketAcquired;

    /* marshalled git output that is written to cache */
    private Path cachedOutput;
    /* marshalled data from the cache that is supposed to be written to hook socket */
    private Path hookOutput;

    private Path cachedStderr;
    private Path cachedStdout;
    private Path hookStdout;

    @Before
    public void setup() throws Exception {
        cachedOutput = tempDir.newFile("cache.out");
        hookOutput = tempDir.newFile("hook.out");

        cachedStderr = tempDir.newFile("cache-stderr.out");
        cachedStdout = tempDir.newFile("cache-stdout.out");
        hookStdout = tempDir.newFile("hook-stdout.out");

        hookWriter = spy(new GitHookOutputWriter(FileChannel.open(hookOutput, APPEND, WRITE)));
        doAnswer(new CaptureByteBufferAnswer(tempDir.newFile("hook-stderr.out")))
                .when(hookWriter).writeStderr(any(ByteBuffer.class));
        doAnswer(new CaptureByteBufferAnswer(hookStdout)).when(hookWriter).writeStdout(any(ByteBuffer.class));

        when(builderFactory.builder(repository)).thenAnswer(i -> {
            builder = spy((GitProcessBuilder) i.callRealMethod());
            return builder;
        });
        when(throttleService.getTicket()).thenReturn(Optional.of(ticketHeld));
        when(throttleService.acquireTicket(any())).thenReturn(ticketAcquired);
    }

    @Test
    public void testCalculateKey() {
        CachingPackObjectsHookHandler handler = makeHandler(null);
        String a = handler.calculateKey(ImmutableList.of("ab", "c"), asStdin("foobar"));
        String b = handler.calculateKey(ImmutableList.of("a", "bc"), asStdin("foobar"));
        String c = handler.calculateKey(ImmutableList.of("ab", "cf"), asStdin("oobar"));
        assertNotEquals(a, b);
        assertNotEquals(a, c);
        assertNotEquals(b, c);
    }

    @Test
    public void testCalculateKeyIgnoresWantOrder() {
        CachingPackObjectsHookHandler handler = makeHandler(null);
        String first = handler.calculateKey(ImmutableList.of("a", "c"), asStdin("foo\nbar"));
        String second = handler.calculateKey(ImmutableList.of("a", "c"), asStdin("bar\nfoo"));
        assertEquals(first, second);
    }

    @DirtiesRepo
    @Test
    public void testCloneGoesThroughCache() throws Exception {
        CachingPackObjectsHookHandler handler = makeHandler(getRepositoryCache());

        handleRequestAndVerifyPackFile(handler, INPUT_FULL_CLONE, PACK_SHA_CLONE, cachedStdout);
        assertEquals(content(cachedOutput), content(hookOutput));
    }

    @DirtiesRepo
    @Test
    public void testDoesNotRunGitInCaseOfCacheHit() throws Exception {
        CachingPackObjectsHookHandler handler = makeHandler(getRepositoryCache());

        // first call, receives CACHE:MISS, writes data to cache and to cacheOutput file.
        handleRequestAndVerifyPackFile(handler, INPUT_FULL_CLONE, PACK_SHA_CLONE, cachedStdout);

        verify(hookWriter).setCacheResult(CacheResult.MISS);

        // second call, supposed to receive CACHE:HIT, should stream data from cache and avoid running GIT.

        // Normally, the handler is not supposed to be re-used between git commands
        // but there is no reason for it to not work in this test.

        hookWriter = spy(new GitHookOutputWriter(FileChannel.open(hookOutput, TRUNCATE_EXISTING, WRITE)));
        handleRequestAndVerifyPackFile(handler, INPUT_FULL_CLONE, PACK_SHA_CLONE, cachedStdout);

        // Verifies build(...) was called exactly once. If the second invocation misses
        // the cache, this verification will fail
        verify(builder).build(any());
        verify(hookWriter).setCacheResult(CacheResult.HIT);

        assertEquals(content(cachedOutput), content(hookOutput));
    }

    @DirtiesRepo
    @Test
    public void testFetchBypassesCache() throws Exception {
        CachingPackObjectsHookHandler handler = makeHandler(getRepositoryCache());
        handleRequestAndVerifyPackFile(handler, INPUT_FETCH, PACK_SHA_FETCH, hookStdout);
    }

    @DirtiesRepo
    @Test
    public void testResourceNameDefaultsToScmHosting() throws Exception {
        Map<String, String> env = spy(new HashMap<>());
        HookRequest request = mock(HookRequest.class);
        when(request.getEnvironment()).thenReturn(env);
        when(request.getStdin()).thenReturn(asStdin(INPUT_FETCH));
        when(request.getArguments()).thenReturn(new ArrayList<>(ARGS_FULL_CLONE));

        CachingPackObjectsHookHandler handler = makeHandler(getRepositoryCache());
        handler.handle(repository, request, hookWriter);

        verify(builder).throttled(eq(PACK));
    }

    @DirtiesRepo
    @Test
    public void testResourceNameIsMirrorHostingForMirrorUser() throws Exception {
        Map<String, String> env = ImmutableMap.of(ENV_THROTTLED_RESOURCE, MIRROR_PACK.getId());
        HookRequest request = mock(HookRequest.class);
        when(request.getEnvironment()).thenReturn(env);
        when(request.getStdin()).thenReturn(asStdin(INPUT_FETCH));
        when(request.getArguments()).thenReturn(new ArrayList<>(ARGS_FULL_CLONE));

        CachingPackObjectsHookHandler handler = makeHandler(getRepositoryCache());
        handler.handle(repository, request, hookWriter);

        verify(builder).throttled(eq(MIRROR_PACK));
    }

    @Test
    public void testShouldCacheWhenNoDashDashNotInTheInput() {
        CachingPackObjectsHookHandler handler = makeHandler(null);

        assertThat(handler.shouldCache(asStdin("howdy")), is(true));
        assertThat(handler.shouldCache(asStdin("howdy\ncowboy")), is(true));
        assertThat(handler.shouldCache(asStdin("howdy\r\ncowboy")), is(true));
    }

    @Test
    public void testShouldCacheWithDashDashNotIsInTheInput() {
        CachingPackObjectsHookHandler handler = makeHandler(null);

        assertThat(handler.shouldCache(asStdin(CLONE_UNIX)), is(true));
        assertThat(handler.shouldCache(asStdin(CLONE_UNIX_ADD_LINE)), is(true));
        assertThat(handler.shouldCache(asStdin(CLONE_UNIX_ADD_LINES)), is(true));

        assertThat(handler.shouldCache(asStdin(SINGLE_LINE)), is(true));
        assertThat(handler.shouldCache(asStdin(SINGLE_LINE_WITH_BREAK)), is(true));

        assertThat(handler.shouldCache(asStdin(FETCH_UNIX)), is(false));
        assertThat(handler.shouldCache(asStdin(FETCH_UNIX_ADD_LINE)), is(false));
        assertThat(handler.shouldCache(asStdin(FETCH_UNIX_ADD_LINES)), is(false));
    }

    @DirtiesRepo
    @Test
    public void testStreamsFomGitWhenIsUnableToGenerateCacheKey() throws Exception {
        CachingPackObjectsHookHandler handler = new CachingPackObjectsHookHandler(builderFactory,
                config.getGit().getHooks(), getRepositoryCache(), throttleService) {

            @Override
            String calculateKey(List<String> args, HookStdin stdin) {
                throw new UnsupportedOperationException("I told you, I don't want to join your super secret boy band.");
            }
        };

        handleRequestAndVerifyPackFile(handler, INPUT_FULL_CLONE, PACK_SHA_CLONE, hookStdout);

        logListener.assertWarn("Unable to calculate cache key");
    }

    @DirtiesRepo
    @Test
    public void testStreamsFromGitIfCacheAccessResultIsBypass() throws Exception {
        CacheAccess access = mock(CacheAccess.class);
        when(access.getResult()).thenReturn(CacheResult.BYPASS);

        ContentCache cache = mock(ContentCache.class);
        when(cache.access(anyString(), any(OutputStream.class), any(ContentProvider.class))).thenReturn(access);

        CachingPackObjectsHookHandler handler = makeHandler(getRepositoryCache(cache));
        handleRequestAndVerifyPackFile(handler, INPUT_FULL_CLONE, PACK_SHA_CLONE, hookStdout);

        verify(hookWriter).setCacheResult(CacheResult.BYPASS);
    }

    @DirtiesRepo
    @Test
    public void testStreamsFromGitIfCacheAccessThrows() throws Exception {
        ContentCache cache = mock(ContentCache.class);
        when(cache.access(anyString(), any(OutputStream.class), any(ContentProvider.class)))
                .thenThrow(new IOException("Let's face it, this is not the worst thing you've caught me doing."));

        CachingPackObjectsHookHandler handler = new CachingPackObjectsHookHandler(builderFactory,
                config.getGit().getHooks(), getRepositoryCache(cache), throttleService);
        handleRequestAndVerifyPackFile(handler, INPUT_FULL_CLONE, PACK_SHA_CLONE, hookStdout);

        logListener.assertError("Failed to set up cache access");
    }

    @DirtiesRepo
    @Test(expected = UncheckedIOException.class)
    public void testThrowsIfUnableToStreamFromCache() throws Exception {
        CacheAccess access = mock(CacheAccess.class);
        when(access.getResult()).thenReturn(CacheResult.MISS);
        doThrow(new IOException("Better clench up, Legolas."))
                .when(access).stream();

        ContentCache cache = mock(ContentCache.class);
        when(cache.access(anyString(), any(OutputStream.class), any(ContentProvider.class))).thenReturn(access);

        CachingPackObjectsHookHandler handler = makeHandler(getRepositoryCache(cache));
        handler.handle(repository, makeRequest(INPUT_FULL_CLONE), hookWriter);
    }

    private static HookStdin asStdin(String value) {
        return ByteStringHookStdin.of(value);
    }

    private void assertPack(Path packfile, String expectedId) {
        String actualId = builderFactory.builder(repository).command("index-pack")
                .argument("--fix-thin")
                .argument("--stdin")
                .build(new StringStdioHandler(StandardCharsets.UTF_8) {

                    private final PathStdioHandler stdinHandler = PathStdioHandler.reading(packfile);

                    @Override
                    public void onExit(int exitCode) {
                        stdinHandler.onExit(exitCode);
                    }

                    @Override
                    public void onStart(@Nonnull OsProcess process) {
                        stdinHandler.onStart(process);
                    }

                    @Override
                    public boolean onStdinReady(@Nonnull ByteBuffer buffer) {
                        return stdinHandler.onStdinReady(buffer);
                    }
                })
                .run();
        assertEquals(expectedId, actualId);
    }

    private static String content(Path path) throws IOException {
        return MoreFiles.toString(path);
    }

    private static String createKey(Repository repository) {
        //Create a key that is safe to use as a filename
        return repository.getSlug();
    }

    private static HookRequest makeRequest(String input) {
        GitHookRequest.Builder builder = new GitHookRequest.Builder("git-pack-objects")
                .stdin(asStdin(input));
        ARGS_FULL_CLONE.forEach(builder::argument);

        return builder.build();
    }

    private PackCacheService getRepositoryCache(ContentCache cache) {
        PackCacheService cacheService = mock(PackCacheService.class);
        when(cacheService.getCache(any())).thenReturn(cache);
        return cacheService;
    }

    private PackCacheService getRepositoryCache() throws Exception {
        PackCacheService cacheService = mock(PackCacheService.class);

        Path contentCache = tempDir.newFolder();

        FileContentCacheManager cacheManager = (new FileContentCacheManager.Builder(contentCache))
                .evictionFreeSpaceBytes(1024L * 1024L * 1024L * 6)
                .evictionStrategy(new LruEvictionStrategy())
                .expiryCheckInterval(30, TimeUnit.SECONDS)
                .expiryStrategy(new PackCacheExpiryStrategy(config.getPackCache()))
                .hysteresis(1024L * 1024L * 1024L)
                .minFreeSpaceBytes(1024L * 1024L * 1024L)
                .streamPumper(new BackgroundThreadStreamPumper("scm-cache-streamer"))
                .build();

        when(cacheService.getCache(any()))
                .thenAnswer(i -> cacheManager.getCache(createKey(i.getArgument(0))));

        return cacheService;
    }

    private void handleRequestAndVerifyPackFile(CachingPackObjectsHookHandler handler,
                                                String input, String packFile, Path stdout) {
        // handler's job is to pump Git's output data through itself.
        // here, we're running git-pack-objects for a known repository
        // and then we use git-index-pack to verify that handler layer
        // with or without caching didn't break it.
        HookRequest request = makeRequest(input);

        assertTrue(handler.handle(repository, request, hookWriter));

        // we do not verify content of stderr here,
        // we just verify that whatever comes through our handler and/or caching layer
        // ends up being the same as what we received
        assertPack(stdout, packFile);
    }

    private CachingPackObjectsHookHandler makeHandler(PackCacheService cacheService) {
        CachingPackObjectsHookHandler handler = spy(new CachingPackObjectsHookHandler(builderFactory,
                config.getGit().getHooks(), cacheService, throttleService));

        doAnswer(i -> {
            HookStdin stdin = i.getArgument(0);
            OutputStream cache = new TeeOutputStream(i.getArgument(1), Files.newOutputStream(cachedOutput));

            GitHookOutputWriter cacheWriter = spy(new GitHookOutputWriter(newChannel(cache)));
            doAnswer(new CaptureByteBufferAnswer(cachedStderr)).when(cacheWriter).writeStderr(any(ByteBuffer.class));
            doAnswer(new CaptureByteBufferAnswer(cachedStdout)).when(cacheWriter).writeStdout(any(ByteBuffer.class));

            return new PackObjectsStdioHandler(stdin, cacheWriter);
        }).when(handler).newHandler(any(), any(OutputStream.class));

        return handler;
    }

    private static class CaptureByteBufferAnswer implements Answer<Object> {

        private final Path path;

        CaptureByteBufferAnswer(Path path) {
            this.path = path;
        }

        @Override
        public Object answer(InvocationOnMock invocation) throws Throwable {
            ByteBuffer buffer = invocation.getArgument(0);

            try (FileChannel channel = FileChannel.open(path, APPEND, WRITE)) {
                channel.write(buffer.slice());
            }

            return invocation.callRealMethod();
        }
    }
}