package com.atlassian.stash.internal.scm.git;

import com.atlassian.bitbucket.NoSuchEntityException;
import com.atlassian.bitbucket.ServerException;
import com.atlassian.bitbucket.io.SingleLineOutputHandler;
import com.atlassian.bitbucket.repository.Repository;
import com.atlassian.bitbucket.scm.CommandResult;
import com.atlassian.bitbucket.scm.annotation.Repo;
import com.atlassian.bitbucket.scm.bulk.*;
import com.atlassian.bitbucket.scm.git.command.GitCommandBuilderFactory;
import com.atlassian.stash.internal.scm.git.GitFactoriesTestHelper.NoOpHandler;
import com.atlassian.stash.internal.scm.git.util.ConfigurableGitScmConfig;
import com.google.common.io.ByteStreams;
import com.google.common.io.Files;
import org.apache.commons.lang3.StringUtils;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;

import javax.annotation.Nonnull;
import javax.annotation.Resource;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.text.Normalizer;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.ExecutorService;

import static org.hamcrest.Matchers.*;
import static org.mockito.Mockito.mock;

public class DefaultGitBulkContentCommandFactoryTest extends AbstractGitTest {

    @Rule
    public TemporaryFolder folder = new TemporaryFolder();

    @Resource
    private GitCommandBuilderFactory builderFactory;
    @Resource
    private ConfigurableGitScmConfig config;
    @Resource
    private ExecutorService executorService;

    private DefaultGitBulkContentCommandFactory factory;

    @Repo
    private Repository repository;
    @Repo("classpath:git/subrepositories.zip")
    private Repository submoduleRepository;

    @Before
    public void setup() {
        factory = new DefaultGitBulkContentCommandFactory(builderFactory, config, executorService);
    }

    @Test
    public void testFullContentSingleFile() {
        BulkContentCommandParameters parameters = untilCommitId("409a2180fd95b744df8d817e770b7f8c853f78dc").build();

        TestBulkContentCallback callback = callExpectSuccess(parameters);

        File file = file("out_of_order/file.txt", 30, BulkChangeType.ADD, "30bda1ba1e8e99781b4fafcc450da84f97a29630");
        assertThat(callback.files, contains(file));
        assertThat(callback.contents, contains(content(file, "out of order commits scenario\n")));
    }

    @Test
    public void testFullContentMultipleFiles() {
        BulkContentCommandParameters parameters = untilCommitId("e26cab95a50f6e99d2c46c498a23cee307cc8e27").build();

        TestBulkContentCallback callback = callExpectSuccess(parameters);

        File file1 = file("modification/mod_file.txt", 20, BulkChangeType.ADD, "493021b1c9e0ecfebcd5ebce646a44138bc60b39");
        File file2 = file("out_of_order/file.txt", 66, BulkChangeType.ADD, "2e6b03cbbe90d3c9afcfc98967a5a71b4c9cff1a");
        assertThat(callback.files, contains(file1, file2));
        assertThat(callback.contents, contains(
                content(file1, "this is a test file\n"),
                content(file2, "out of order commits scenario\na modification\nanother modification\n")));
    }

    @Test
    public void testIncrementalContentAdd() {
        BulkContentCommandParameters parameters = untilCommitId("e26cab95a50f6e99d2c46c498a23cee307cc8e27")
                .sinceCommitId("2e10dd2d1d5eea9291b296e78312e8a703964a95")
                .build();

        TestBulkContentCallback callback = callExpectSuccess(parameters);

        File file = file("modification/mod_file.txt", 20, BulkChangeType.ADD, "493021b1c9e0ecfebcd5ebce646a44138bc60b39");
        assertThat(callback.files, contains(file));
        assertThat(callback.contents, contains(content(file, "this is a test file\n")));
    }

    @Test
    public void testIncrementalContentModify() {
        BulkContentCommandParameters parameters = untilCommitId("c2608f5dff150e2b26b4b68d9e22369581b39b0c")
                .sinceCommitId("409a2180fd95b744df8d817e770b7f8c853f78dc")
                .build();

        TestBulkContentCallback callback = callExpectSuccess(parameters);

        File file = file("out_of_order/file.txt", 45, BulkChangeType.MODIFY, "6dd09bff5c2957b9fc4f40169c3873a27a14ec9d");
        assertThat(callback.files, contains(file));
        assertThat(callback.contents, contains(content(file, "out of order commits scenario\na modification\n")));
    }

    @Test
    public void testIncrementalContentDeleted() {
        BulkContentCommandParameters parameters = untilCommitId("6d8e9736357657738d15cd29069f21bf44553e19")
                .sinceCommitId("4f9290ae1a9fcde7acd56664a22cc65eb76540f3")
                .build();

        TestBulkContentCallback callback = callExpectSuccess(parameters);

        File file = file("del_file/del_file.txt", 0, BulkChangeType.DELETE, "bdea48ee65c869eb0b86b1283069d76cce0a7254");
        assertThat(callback.files, contains(file));
        assertThat(callback.contents, hasSize(0));
    }

    @Test
    public void testIncrementalContentMoved() {
        BulkContentCommandParameters parameters = untilCommitId("57150c54c38d6570b2fd5e6d6cfc19476de44e84")
                .sinceCommitId("9fbc34a0d905950131d73f352abe68520c6db2a3")
                .build();

        TestBulkContentCallback callback = callExpectSuccess(parameters);

        File file1 = file("mv_file/moved_file.txt", 24, BulkChangeType.ADD, "f49abb7d6c366e6f83470e36439bc162234d7963");
        File file2 = file("mv_file/mv_file.txt", 0, BulkChangeType.DELETE, "f49abb7d6c366e6f83470e36439bc162234d7963");
        assertThat(callback.files, contains(file1, file2));
        assertThat(callback.contents, contains(content(file1, "This file will be moved\n")));
    }

    @Test
    public void testIncrementalContentBinaryFile() {
        BulkContentCommandParameters parameters = untilCommitId("9c05f43f859375e392d90d23a13717c16d0fdcda")
                .sinceCommitId("6053a1eaa1c009dd11092d09a72f3c41af1b59ad")
                .build();

        TestBulkContentCallback callback = callExpectSuccess(parameters);

        File file1 = file("binary/A.zip", 167, BulkChangeType.ADD, "927b01c060cf0dc5036e19abeab8e332d9075ea0");
        assertThat(callback.files, contains(file1));
        assertThat(callback.contents, contains(content(file1, new byte[]{
                80, 75, 3, 4, 20, 0, 0, 0, 8, 0, 78, 127, 67, 62, 36, -6, 66, -96, 7, 0, 0, 0, 64, 0, 0, 0, 5, 0, 28, 0,
                65, 46, 116, 120, 116, 85, 84, 9, 0, 3, -13, 53, 74, 77, -12, 53, 74, 77, 117, 120, 11, 0, 1, 4, -11, 1,
                0, 0, 4, -11, 1, 0, 0, 115, 116, -92, 8, 112, 1, 0, 80, 75, 1, 2, 30, 3, 20, 0, 0, 0, 8, 0, 78, 127, 67,
                62, 36, -6, 66, -96, 7, 0, 0, 0, 64, 0, 0, 0, 5, 0, 24, 0, 0, 0, 0, 0, 1, 0, 0, 0, -92, -127, 0, 0, 0,
                0, 65, 46, 116, 120, 116, 85, 84, 5, 0, 3, -13, 53, 74, 77, 117, 120, 11, 0, 1, 4, -11, 1, 0, 0, 4, -11,
                1, 0, 0, 80, 75, 5, 6, 0, 0, 0, 0, 1, 0, 1, 0, 75, 0, 0, 0, 70, 0, 0, 0, 0, 0})));
    }

    @Test
    public void testFullContentFiltering() {
        BulkContentCommandParameters parameters = untilCommitId("e26cab95a50f6e99d2c46c498a23cee307cc8e27").build();

        TestBulkContentCallback callback = new TestBulkContentCallback() {
            @Nonnull
            @Override
            public BulkContentDisposition accept(@Nonnull BulkFile file) {
                super.accept(file);
                if (file.getPath().startsWith("out_of_order/")) {
                    return BulkContentDisposition.SKIP;
                }
                return BulkContentDisposition.STREAM;
            }
        };

        callExpectSuccess(parameters, callback);

        File file1 = file("modification/mod_file.txt", 20, BulkChangeType.ADD, "493021b1c9e0ecfebcd5ebce646a44138bc60b39");
        File file2 = file("out_of_order/file.txt", 66, BulkChangeType.ADD, "2e6b03cbbe90d3c9afcfc98967a5a71b4c9cff1a");
        assertThat(callback.files, contains(file1, file2));
        assertThat(callback.contents, contains(content(file1, "this is a test file\n")));
    }

    @Test
    public void testFullContentDone() {
        BulkContentCommandParameters parameters = untilCommitId("e26cab95a50f6e99d2c46c498a23cee307cc8e27").build();

        TestBulkContentCallback callback = new TestBulkContentCallback() {
            @Nonnull
            @Override
            public BulkContentDisposition accept(@Nonnull BulkFile file) {
                super.accept(file);
                return BulkContentDisposition.DONE;
            }
        };

        callExpectSuccess(parameters, callback);

        File file = file("modification/mod_file.txt", 20, BulkChangeType.ADD, "493021b1c9e0ecfebcd5ebce646a44138bc60b39");
        assertThat(callback.files, contains(file));
        assertThat(callback.contents, hasSize(0));
    }

    @Test
    public void testIncrementalContentDone() {
        BulkContentCommandParameters parameters = untilCommitId("57150c54c38d6570b2fd5e6d6cfc19476de44e84")
                .sinceCommitId("9fbc34a0d905950131d73f352abe68520c6db2a3")
                .build();

        TestBulkContentCallback callback = new TestBulkContentCallback() {
            @Nonnull
            @Override
            public BulkContentDisposition accept(@Nonnull BulkFile file) {
                super.accept(file);
                return BulkContentDisposition.DONE;
            }
        };

        callExpectSuccess(parameters, callback);

        File file = file("mv_file/moved_file.txt", 24, BulkChangeType.ADD, "f49abb7d6c366e6f83470e36439bc162234d7963");
        assertThat(callback.files, contains(file));
        assertThat(callback.contents, hasSize(0));
    }

    @Test
    public void testIncrementalContentDoneAfterDeleted() {
        BulkContentCommandParameters parameters = untilCommitId("2d8897c9ac29ce42c3442cf80ac977057045e7f6")
                .sinceCommitId("9c05f43f859375e392d90d23a13717c16d0fdcda")
                .build();

        TestBulkContentCallback callback = new TestBulkContentCallback() {
            @Nonnull
            @Override
            public BulkContentDisposition accept(@Nonnull BulkFile file) {
                super.accept(file);
                return BulkContentDisposition.DONE;
            }
        };

        callExpectSuccess(parameters, callback);

        File file = file("binary/A.zip", 0, BulkChangeType.DELETE, "927b01c060cf0dc5036e19abeab8e332d9075ea0");
        assertThat(callback.files, contains(file));
        assertThat(callback.contents, hasSize(0));
    }

    @Test
    public void testCallbackThatDoesNotConsumeContent() {
        BulkContentCommandParameters parameters = untilCommitId("e26cab95a50f6e99d2c46c498a23cee307cc8e27").build();

        TestBulkContentCallback callback = new TestBulkContentCallback() {
            @Override
            public void onFile(@Nonnull BulkFile file, @Nonnull InputStream content) {
                // Don't consume the stream
            }
        };

        callExpectSuccess(parameters, callback);

        File file1 = file("modification/mod_file.txt", 20, BulkChangeType.ADD, "493021b1c9e0ecfebcd5ebce646a44138bc60b39");
        File file2 = file("out_of_order/file.txt", 66, BulkChangeType.ADD, "2e6b03cbbe90d3c9afcfc98967a5a71b4c9cff1a");
        assertThat(callback.files, contains(file1, file2));
    }

    @Test
    public void testNonAsciiInPath() throws IOException {
        Repository newRepository = mock(Repository.class);
        config.setRepositoryDir(newRepository, folder.getRoot());

        // "A", followed by:
        // - U+00E4 LATIN SMALL LETTER A WITH DIAERESIS (pre-composed, from basic multilingual plane)
        // - U+1F600 GRINNING FACE (from supplementary multilingual plane)
        String fileName = "A\u00E4\uD83D\uDE00.txt";
        Path filePath = folder.getRoot().toPath().resolve(fileName);
        Files.write("unicode\n", filePath.toFile(), StandardCharsets.UTF_8);

        builderFactory.builder().init()
                .directory(folder.getRoot().getAbsolutePath())
                .build(new NoOpHandler())
                .call();
        builderFactory.builder(newRepository).add()
                .path(".")
                .build()
                .call();
        builderFactory.builder(newRepository).commit()
                .message("Add unicode file name")
                .author("Bulk Content Test", "test@example.org")
                .build()
                .call();

        String commitId = builderFactory.builder(newRepository).revList()
                .limit(1)
                .rev("HEAD")
                .build(new SingleLineOutputHandler())
                .call();
        String lsTree = builderFactory.builder(newRepository).command("ls-tree")
                .argument("--name-only")
                .argument("-z")
                .argument("HEAD")
                .build(new SingleLineOutputHandler())
                .call();
        assertNotNull(lsTree);
        // This is the file name as it is stored in Git. The reason why it can be different from the name we originally
        // used is because some operating systems normalize paths (in the case of OS X, to NFD-like).
        String actualFileName = StringUtils.removeEnd(lsTree, "\0");
        assertEquals("File name must not have been mangled (except for normalization differences)",
                Normalizer.normalize(fileName, Normalizer.Form.NFC),
                Normalizer.normalize(actualFileName, Normalizer.Form.NFC));

        BulkContentCommandParameters parameters = untilCommitId(commitId).build();
        TestBulkContentCallback callback = new TestBulkContentCallback();
        factory.contents(newRepository, parameters, callback).call();

        File file = file(actualFileName, 8, BulkChangeType.ADD, "4de4f936336736200e7a59438ef4d31ed10f684d");
        assertThat(callback.files, contains(file));
        assertThat(callback.contents, contains(content(file, "unicode\n")));
    }

    @Test
    public void testSubmoduleInFullContent() throws IOException {
        BulkContentCommandParameters parameters = untilCommitId("ecf42d8adc4d7eb4386e37882d053704067712d4").build();
        TestBulkContentCallback callback = new TestBulkContentCallback();
        factory.contents(submoduleRepository, parameters, callback).call();

        // Make sure we find at least a normal file
        File gitmodules = file(".gitmodules", 346, BulkChangeType.ADD, "c0de405f50ea72f9d2b973dd504671ba63ea7189");
        assertThat(callback.files, hasItem(gitmodules));
        for (File file : callback.files) {
            assertThat("Expected to not find a submodule entry", file.path, not(containsString("submodule/stash")));
        }
    }

    @Test
    public void testSubmoduleInIncrementalContent() throws IOException {
        BulkContentCommandParameters parameters = untilCommitId("ecf42d8adc4d7eb4386e37882d053704067712d4")
                .sinceCommitId("0a943a29376f2336b78312d99e65da17048951db")
                .build();
        TestBulkContentCallback callback = new TestBulkContentCallback();
        factory.contents(submoduleRepository, parameters, callback).call();

        // Make sure we find at least a normal file
        File gitmodules = file(".gitmodules", 346, BulkChangeType.ADD, "c0de405f50ea72f9d2b973dd504671ba63ea7189");
        assertThat(callback.files, hasItem(gitmodules));
        for (File file : callback.files) {
            assertThat("Expected to not find a submodule entry", file.path, not(containsString("submodule/stash")));
        }
    }

    @Test(expected = ServerException.class)
    public void testCallbackThatThrowsInAccept() {
        BulkContentCommandParameters parameters = untilCommitId("e26cab95a50f6e99d2c46c498a23cee307cc8e27").build();

        TestBulkContentCallback callback = new TestBulkContentCallback() {
            @Nonnull
            @Override
            public BulkContentDisposition accept(@Nonnull BulkFile file) {
                throw new RuntimeException("Thrown to check exception handling");
            }
        };

        call(parameters, callback);
    }

    @Test(expected = ServerException.class)
    public void testCallbackThatThrowsInOnFile() {
        BulkContentCommandParameters parameters = untilCommitId("e26cab95a50f6e99d2c46c498a23cee307cc8e27").build();

        TestBulkContentCallback callback = new TestBulkContentCallback() {
            @Override
            public void onFile(@Nonnull BulkFile file, @Nonnull InputStream content) {
                throw new RuntimeException("Thrown to check exception handling");
            }
        };

        call(parameters, callback);
    }

    @Test(expected = NoSuchEntityException.class)
    public void testInvalidUntilCommitId() {
        BulkContentCommandParameters parameters = untilCommitId("0000000000000000000000000000000000000000").build();
        TestBulkContentCallback callback = new TestBulkContentCallback();
        call(parameters, callback);
    }

    @Test(expected = NoSuchEntityException.class)
    public void testInvalidSinceCommitId() {
        String untilCommitId = "e26cab95a50f6e99d2c46c498a23cee307cc8e27";
        BulkContentCommandParameters parameters = untilCommitId(untilCommitId)
                .sinceCommitId("0000000000000000000000000000000000000000")
                .build();
        TestBulkContentCallback callback = new TestBulkContentCallback();
        call(parameters, callback);
    }

    private static BulkContentCommandParameters.Builder untilCommitId(String untilCommitId) {
        return new BulkContentCommandParameters.Builder(untilCommitId);
    }

    private static File file(String path, int size, BulkChangeType changeType, String contentId) {
        return new File(path, size, changeType, contentId);
    }

    private static FileContent content(File file, byte[] content) {
        return new FileContent(file, content);
    }

    private static FileContent content(File file, String content) {
        return new FileContent(file, content);
    }

    private TestBulkContentCallback callExpectSuccess(BulkContentCommandParameters parameters) {
        TestBulkContentCallback callback = new TestBulkContentCallback();
        return callExpectSuccess(parameters, callback);
    }

    private TestBulkContentCallback callExpectSuccess(BulkContentCommandParameters parameters,
                                                      TestBulkContentCallback callback) {
        call(parameters, callback);
        assertThat(callback.summary.getResult(), is(CommandResult.SUCCEEDED));
        return callback;
    }

    private void call(BulkContentCommandParameters parameters, BulkContentCallback callback) {
        factory.contents(repository, parameters, callback).call();
    }

    private static class TestBulkContentCallback implements BulkContentCallback {

        private final List<File> files = new ArrayList<>();
        private final List<FileContent> contents = new ArrayList<>();
        private BulkContentSummary summary;

        @Nonnull
        @Override
        public BulkContentDisposition accept(@Nonnull BulkFile file) {
            // Always get the providedContent
            files.add(new File(file));
            return BulkContentDisposition.STREAM;
        }

        @Override
        public void onFile(@Nonnull BulkFile file, @Nonnull InputStream content) {
            try {
                byte[] bytes = ByteStreams.toByteArray(content);
                contents.add(new FileContent(new File(file), bytes));
            } catch (IOException e) {
                throw new UncheckedIOException(e);
            }
        }

        @Override
        public void onEnd(@Nonnull BulkContentSummary summary) {
            this.summary = summary;
        }
    }

    private static class File {

        private final String path;
        private final long size;
        private final BulkChangeType changeType;
        private final String contentId;

        public File(String path, long size, BulkChangeType changeType, String contentId) {
            this.path = path;
            this.size = size;
            this.changeType = changeType;
            this.contentId = contentId;
        }

        public File(BulkFile file) {
            this.path = file.getPath();
            this.size = file.getSize();
            this.changeType = file.getChangeType();
            this.contentId = file.getContentId();
        }

        @Override
        public String toString() {
            return "File{path=" + path + ", size=" + size + ", changeType=" + changeType + ", contentId=" + contentId + "}";
        }

        @Override
        public int hashCode() {
            return Objects.hash(path, size, changeType, contentId);
        }

        @Override
        public boolean equals(Object obj) {
            if (obj instanceof File) {
                File that = (File) obj;
                return Objects.equals(this.path, that.path) &&
                        Objects.equals(this.size, that.size) &&
                        Objects.equals(this.changeType, that.changeType) &&
                        Objects.equals(this.contentId, that.contentId);
            }
            return false;
        }
    }

    private static class FileContent {

        private final File file;
        private final byte[] content;

        public FileContent(File file, byte[] content) {
            this.file = file;
            this.content = content;
        }

        public FileContent(File file, String content) {
            this.file = file;
            this.content = content.getBytes(StandardCharsets.UTF_8);
        }

        @Override
        public String toString() {
            // For easier comparison in failed assertions
            String decoded = new String(content, StandardCharsets.UTF_8);
            return "FileContent{file=" + file + ", content=" + Arrays.toString(content) + ", decoded=" + decoded + "}";
        }

        @Override
        public int hashCode() {
            return Objects.hash(file, Arrays.hashCode(content));
        }

        @Override
        public boolean equals(Object obj) {
            if (obj instanceof FileContent) {
                FileContent that = (FileContent) obj;
                return Objects.equals(this.file, that.file) &&
                        Objects.deepEquals(this.content, that.content);
            }
            return false;
        }
    }
}
