package uk.ac.ic.wlgitbridge.application;

import org.apache.commons.io.FileUtils;
import org.apache.commons.io.IOUtils;
import static org.asynchttpclient.Dsl.*;
import org.asynchttpclient.*;
import org.eclipse.jgit.api.errors.GitAPIException;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import uk.ac.ic.wlgitbridge.bridge.swap.job.SwapJobConfig;
import uk.ac.ic.wlgitbridge.snapshot.servermock.server.MockSnapshotServer;
import uk.ac.ic.wlgitbridge.snapshot.servermock.state.SnapshotAPIState;
import uk.ac.ic.wlgitbridge.snapshot.servermock.state.SnapshotAPIStateBuilder;
import uk.ac.ic.wlgitbridge.snapshot.servermock.util.FileUtil;
import uk.ac.ic.wlgitbridge.util.Util;

import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutionException;

import static org.junit.Assert.*;

/**
 * Created by Winston on 11/01/15.
 */
public class WLGitBridgeIntegrationTest {

    private Runtime runtime = Runtime.getRuntime();

    private Map<String, Map<String, SnapshotAPIState>> states = new HashMap<String, Map<String, SnapshotAPIState>>() {{
        put("canCloneARepository", new HashMap<String, SnapshotAPIState>() {{
            put("state", new SnapshotAPIStateBuilder(getResourceAsStream("/canCloneARepository/state/state.json")).build());
        }});
        put("canCloneMultipleRepositories", new HashMap<String, SnapshotAPIState>() {{
            put("state", new SnapshotAPIStateBuilder(getResourceAsStream("/canCloneMultipleRepositories/state/state.json")).build());
        }});
        put("cannotCloneAProtectedProjectWithoutAuthentication", new HashMap<String, SnapshotAPIState>() {{
            put("state", new SnapshotAPIStateBuilder(getResourceAsStream("/cannotCloneAProtectedProjectWithoutAuthentication/state/state.json")).build());
        }});
        put("cannotCloneA4xxProject", new HashMap<String, SnapshotAPIState>() {{
            put("state", new SnapshotAPIStateBuilder(getResourceAsStream("/cannotCloneA4xxProject/state/state.json")).build());
        }});
        put("cannotCloneAMissingProject", new HashMap<String, SnapshotAPIState>() {{
            put("state", new SnapshotAPIStateBuilder(getResourceAsStream("/cannotCloneAMissingProject/state/state.json")).build());
        }});
        put("canPullAModifiedTexFile", new HashMap<String, SnapshotAPIState>() {{
            put("base", new SnapshotAPIStateBuilder(getResourceAsStream("/canPullAModifiedTexFile/base/state.json")).build());
            put("withModifiedTexFile", new SnapshotAPIStateBuilder(getResourceAsStream("/canPullAModifiedTexFile/withModifiedTexFile/state.json")).build());
        }});
        put("canPullADeletedTexFile", new HashMap<String, SnapshotAPIState>() {{
            put("base", new SnapshotAPIStateBuilder(getResourceAsStream("/canPullADeletedTexFile/base/state.json")).build());
            put("withDeletedTexFile", new SnapshotAPIStateBuilder(getResourceAsStream("/canPullADeletedTexFile/withDeletedTexFile/state.json")).build());
        }});
        put("canPullAModifiedBinaryFile", new HashMap<String, SnapshotAPIState>() {{
            put("base", new SnapshotAPIStateBuilder(getResourceAsStream("/canPullAModifiedBinaryFile/base/state.json")).build());
            put("withModifiedBinaryFile", new SnapshotAPIStateBuilder(getResourceAsStream("/canPullAModifiedBinaryFile/withModifiedBinaryFile/state.json")).build());
        }});
        put("canPullADeletedBinaryFile", new HashMap<String, SnapshotAPIState>() {{
            put("base", new SnapshotAPIStateBuilder(getResourceAsStream("/canPullADeletedBinaryFile/base/state.json")).build());
            put("withDeletedBinaryFile", new SnapshotAPIStateBuilder(getResourceAsStream("/canPullADeletedBinaryFile/withDeletedBinaryFile/state.json")).build());
        }});
        put("canPullADuplicateBinaryFile", new HashMap<String, SnapshotAPIState>() {{
            put("base", new SnapshotAPIStateBuilder(getResourceAsStream("/canPullADuplicateBinaryFile/base/state.json")).build());
            put("withDuplicateBinaryFile", new SnapshotAPIStateBuilder(getResourceAsStream("/canPullADuplicateBinaryFile/withDuplicateBinaryFile/state.json")).build());
        }});
        put("canCloneDuplicateBinaryFiles", new HashMap<String, SnapshotAPIState>() {{
            put("state", new SnapshotAPIStateBuilder(getResourceAsStream("/canCloneDuplicateBinaryFiles/state/state.json")).build());
        }});
        put("canPullUpdatedBinaryFiles", new HashMap<String, SnapshotAPIState>() {{
            put("base", new SnapshotAPIStateBuilder(getResourceAsStream("/canPullUpdatedBinaryFiles/base/state.json")).build());
            put("withUpdatedBinaryFiles", new SnapshotAPIStateBuilder(getResourceAsStream("/canPullUpdatedBinaryFiles/withUpdatedBinaryFiles/state.json")).build());
        }});
        put("canPullAModifiedNestedFile", new HashMap<String, SnapshotAPIState>() {{
            put("base", new SnapshotAPIStateBuilder(getResourceAsStream("/canPullAModifiedNestedFile/base/state.json")).build());
            put("withModifiedNestedFile", new SnapshotAPIStateBuilder(getResourceAsStream("/canPullAModifiedNestedFile/withModifiedNestedFile/state.json")).build());
        }});
        put("canPullDeletedNestedFiles", new HashMap<String, SnapshotAPIState>() {{
            put("base", new SnapshotAPIStateBuilder(getResourceAsStream("/canPullDeletedNestedFiles/base/state.json")).build());
            put("withDeletedNestedFiles", new SnapshotAPIStateBuilder(getResourceAsStream("/canPullDeletedNestedFiles/withDeletedNestedFiles/state.json")).build());
        }});
        put("canPushFilesSuccessfully", new HashMap<String, SnapshotAPIState>() {{
            put("state", new SnapshotAPIStateBuilder(getResourceAsStream("/canPushFilesSuccessfully/state/state.json")).build());
        }});
        put("pushFailsOnFirstStageOutOfDate", new HashMap<String, SnapshotAPIState>() {{
            put("state", new SnapshotAPIStateBuilder(getResourceAsStream("/pushFailsOnFirstStageOutOfDate/state/state.json")).build());
        }});
        put("pushFailsOnSecondStageOutOfDate", new HashMap<String, SnapshotAPIState>() {{
            put("state", new SnapshotAPIStateBuilder(getResourceAsStream("/pushFailsOnSecondStageOutOfDate/state/state.json")).build());
        }});
        put("pushFailsOnInvalidFiles", new HashMap<String, SnapshotAPIState>() {{
            put("state", new SnapshotAPIStateBuilder(getResourceAsStream("/pushFailsOnInvalidFiles/state/state.json")).build());
        }});
        put("pushFailsOnInvalidProject", new HashMap<String, SnapshotAPIState>() {{
            put("state", new SnapshotAPIStateBuilder(getResourceAsStream("/pushFailsOnInvalidProject/state/state.json")).build());
        }});
        put("pushFailsOnUnexpectedError", new HashMap<String, SnapshotAPIState>() {{
            put("state", new SnapshotAPIStateBuilder(getResourceAsStream("/pushFailsOnUnexpectedError/state/state.json")).build());
        }});
        put("pushSucceedsAfterRemovingInvalidFiles", new HashMap<String, SnapshotAPIState>() {{
            put("invalidState", new SnapshotAPIStateBuilder(getResourceAsStream("/pushSucceedsAfterRemovingInvalidFiles/invalidState/state.json")).build());
            put("validState", new SnapshotAPIStateBuilder(getResourceAsStream("/pushSucceedsAfterRemovingInvalidFiles/validState/state.json")).build());
        }});
        put("canServePushedFiles", new HashMap<String, SnapshotAPIState>() {{
            put("state", new SnapshotAPIStateBuilder(getResourceAsStream("/canServePushedFiles/state/state.json")).build());
        }});
        put("wlgbCanSwapProjects", new HashMap<String, SnapshotAPIState>() {{
            put("state", new SnapshotAPIStateBuilder(getResourceAsStream("/wlgbCanSwapProjects/state/state.json")).build());
        }});
        put("pushSubmoduleFailsWithInvalidGitRepo", new HashMap<String, SnapshotAPIState>() {{
            put("state", new SnapshotAPIStateBuilder(getResourceAsStream("/pushSubmoduleFailsWithInvalidGitRepo/state/state.json")).build());
        }});
    }};

    @Rule
    public TemporaryFolder folder = new TemporaryFolder();

    private File dir;

    @Before
    public void setUp() throws Exception {
        dir = folder.newFolder();
    }

    private void gitConfig(File dir) throws IOException, InterruptedException {
        assertEquals(0, runtime.exec(
                "git config user.name TEST", null, dir
        ).waitFor());
        assertEquals(0, runtime.exec(
                "git config user.email test@test.com", null, dir
        ).waitFor());
        assertEquals(0, runtime.exec(
                "git config push.default matching", null, dir
        ).waitFor());
    }

    private File gitClone(String repositoryName, int port, File dir) throws IOException, InterruptedException {
        String repo = "git clone http://127.0.0.1:" + port + "/" + repositoryName + ".git";
        Process gitProcess = runtime.exec(repo, null, dir);
        int exitCode = gitProcess.waitFor();
        if (exitCode != 0) {
            System.err.println("git clone failed. Dumping stderr and stdout.");
            System.err.println(
                    IOUtils.toString(
                            gitProcess.getErrorStream(),
                            StandardCharsets.UTF_8
                    )
            );
            System.err.println(
                    IOUtils.toString(
                            gitProcess.getInputStream(),
                            StandardCharsets.UTF_8
                    )
            );
            fail("git clone failed");
        }
        File repositoryDir = new File(dir, repositoryName);
        gitConfig(repositoryDir);
        return repositoryDir;
    }

    private void gitInit(File dir) throws IOException, InterruptedException {
        assertEquals(0, runtime.exec(
                "git init", null, dir
        ).waitFor());
        gitConfig(dir);
    }

    private void gitAdd(File dir) throws IOException, InterruptedException {
        assertEquals(0, runtime.exec(
                "git add -A", null, dir
        ).waitFor());
    }

    private void gitCommit(
            File dir, String msg
    ) throws IOException, InterruptedException {
        assertEquals(0, runtime.exec(
                "git commit -m \"" + msg + "\"", null, dir
        ).waitFor());
    }

    private Process gitPush(File dir) throws IOException, InterruptedException {
        return gitPush(dir, 0);
    }

    private Process gitPush(
            File dir, int exit
    ) throws IOException, InterruptedException {
        Process ret = runtime.exec(
                "git push", null, dir
        );
        assertEquals(exit, ret.waitFor());
        return ret;
    }

    private void gitPull(File dir) throws IOException, InterruptedException {
        assertEquals(0, runtime.exec(
                "git pull", null, dir
        ).waitFor());
    }

    @Test
    public void canCloneARepository() throws IOException, GitAPIException, InterruptedException {
        MockSnapshotServer server = new MockSnapshotServer(3857, getResource("/canCloneARepository").toFile());
        server.start();
        server.setState(states.get("canCloneARepository").get("state"));
        GitBridgeApp wlgb = new GitBridgeApp(new String[] {
            makeConfigFile(33857, 3857)
        });
        wlgb.run();
        File testprojDir = gitClone("testproj", 33857, dir);
        wlgb.stop();
        assertTrue(FileUtil.gitDirectoriesAreEqual(getResource("/canCloneARepository/state/testproj"), testprojDir.toPath()));
    }

    @Test
    public void canCloneMultipleRepositories() throws IOException, GitAPIException, InterruptedException {
        MockSnapshotServer server = new MockSnapshotServer(3858, getResource("/canCloneMultipleRepositories").toFile());
        server.start();
        server.setState(states.get("canCloneMultipleRepositories").get("state"));
        GitBridgeApp wlgb = new GitBridgeApp(new String[] {
            makeConfigFile(33858, 3858)
        });
        wlgb.run();
        File testproj1Dir = gitClone("testproj1", 33858, dir);
        File testproj2Dir = gitClone("testproj2", 33858, dir);
        wlgb.stop();
        assertTrue(FileUtil.gitDirectoriesAreEqual(getResource("/canCloneMultipleRepositories/state/testproj1"), testproj1Dir.toPath()));
        assertTrue(FileUtil.gitDirectoriesAreEqual(getResource("/canCloneMultipleRepositories/state/testproj2"), testproj2Dir.toPath()));
    }

    @Test
    public void canPullAModifiedTexFile() throws IOException, GitAPIException, InterruptedException {
        MockSnapshotServer server = new MockSnapshotServer(3859, getResource("/canPullAModifiedTexFile").toFile());
        server.start();
        server.setState(states.get("canPullAModifiedTexFile").get("base"));
        GitBridgeApp wlgb = new GitBridgeApp(new String[] {
            makeConfigFile(33859, 3859)
        });
        wlgb.run();
        File testprojDir = gitClone("testproj", 33859, dir);
        assertTrue(FileUtil.gitDirectoriesAreEqual(getResource("/canPullAModifiedTexFile/base/testproj"), testprojDir.toPath()));
        server.setState(states.get("canPullAModifiedTexFile").get("withModifiedTexFile"));
        gitPull(testprojDir);
        wlgb.stop();
        assertTrue(FileUtil.gitDirectoriesAreEqual(getResource("/canPullAModifiedTexFile/withModifiedTexFile/testproj"), testprojDir.toPath()));
    }

    @Test
    public void canPullADeletedTexFile() throws IOException, GitAPIException, InterruptedException {
        MockSnapshotServer server = new MockSnapshotServer(3860, getResource("/canPullADeletedTexFile").toFile());
        server.start();
        server.setState(states.get("canPullADeletedTexFile").get("base"));
        GitBridgeApp wlgb = new GitBridgeApp(new String[] {
            makeConfigFile(33860, 3860)
        });
        wlgb.run();
        File testprojDir = gitClone("testproj", 33860, dir);
        assertTrue(FileUtil.gitDirectoriesAreEqual(getResource("/canPullADeletedTexFile/base/testproj"), testprojDir.toPath()));
        server.setState(states.get("canPullADeletedTexFile").get("withDeletedTexFile"));
        gitPull(testprojDir);
        wlgb.stop();
        assertTrue(FileUtil.gitDirectoriesAreEqual(getResource("/canPullADeletedTexFile/withDeletedTexFile/testproj"), testprojDir.toPath()));
    }

    @Test
    public void canPullAModifiedBinaryFile() throws IOException, GitAPIException, InterruptedException {
        MockSnapshotServer server = new MockSnapshotServer(3862, getResource("/canPullAModifiedBinaryFile").toFile());
        server.start();
        server.setState(states.get("canPullAModifiedBinaryFile").get("base"));
        GitBridgeApp wlgb = new GitBridgeApp(new String[] {
            makeConfigFile(33862, 3862)
        });
        wlgb.run();
        File testprojDir = gitClone("testproj", 33862, dir);
        assertTrue(FileUtil.gitDirectoriesAreEqual(getResource("/canPullAModifiedBinaryFile/base/testproj"), testprojDir.toPath()));
        server.setState(states.get("canPullAModifiedBinaryFile").get("withModifiedBinaryFile"));
        gitPull(testprojDir);
        wlgb.stop();
        assertTrue(FileUtil.gitDirectoriesAreEqual(getResource("/canPullAModifiedBinaryFile/withModifiedBinaryFile/testproj"), testprojDir.toPath()));
    }

    @Test
    public void canPullADeletedBinaryFile() throws IOException, GitAPIException, InterruptedException {
        MockSnapshotServer server = new MockSnapshotServer(3863, getResource("/canPullADeletedBinaryFile").toFile());
        server.start();
        server.setState(states.get("canPullADeletedBinaryFile").get("base"));
        GitBridgeApp wlgb = new GitBridgeApp(new String[] {
            makeConfigFile(33863, 3863)
        });
        wlgb.run();
        File testprojDir = gitClone("testproj", 33863, dir);
        assertTrue(FileUtil.gitDirectoriesAreEqual(getResource("/canPullADeletedBinaryFile/base/testproj"), testprojDir.toPath()));
        server.setState(states.get("canPullADeletedBinaryFile").get("withDeletedBinaryFile"));
        gitPull(testprojDir);
        wlgb.stop();
        assertTrue(FileUtil.gitDirectoriesAreEqual(getResource("/canPullADeletedBinaryFile/withDeletedBinaryFile/testproj"), testprojDir.toPath()));
    }

    @Test
    public void canPullADuplicateBinaryFile() throws IOException, GitAPIException, InterruptedException {
        MockSnapshotServer server = new MockSnapshotServer(4001, getResource("/canPullADuplicateBinaryFile").toFile());
        server.start();
        server.setState(states.get("canPullADuplicateBinaryFile").get("base"));
        GitBridgeApp wlgb = new GitBridgeApp(new String[] {
            makeConfigFile(44001, 4001)
        });
        wlgb.run();
        File testprojDir = gitClone("testproj", 44001, dir);
        assertTrue(FileUtil.gitDirectoriesAreEqual(getResource("/canPullADuplicateBinaryFile/base/testproj"), testprojDir.toPath()));
        server.setState(states.get("canPullADuplicateBinaryFile").get("withDuplicateBinaryFile"));
        gitPull(testprojDir);
        wlgb.stop();
        assertTrue(FileUtil.gitDirectoriesAreEqual(getResource("/canPullADuplicateBinaryFile/withDuplicateBinaryFile/testproj"), testprojDir.toPath()));
    }

    @Test
    public void canCloneDuplicateBinaryFiles() throws IOException, GitAPIException, InterruptedException {
        MockSnapshotServer server = new MockSnapshotServer(4002, getResource("/canCloneDuplicateBinaryFiles").toFile());
        server.start();
        server.setState(states.get("canCloneDuplicateBinaryFiles").get("state"));
        GitBridgeApp wlgb = new GitBridgeApp(new String[] {
            makeConfigFile(44002, 4002)
        });
        wlgb.run();
        File testprojDir = gitClone("testproj", 44002, dir);
        wlgb.stop();
        assertTrue(FileUtil.gitDirectoriesAreEqual(getResource("/canCloneDuplicateBinaryFiles/state/testproj"), testprojDir.toPath()));
    }

    @Test
    public void canPullUpdatedBinaryFiles() throws IOException, GitAPIException, InterruptedException {
        MockSnapshotServer server = new MockSnapshotServer(4003, getResource("/canPullUpdatedBinaryFiles").toFile());
        server.start();
        server.setState(states.get("canPullUpdatedBinaryFiles").get("base"));
        GitBridgeApp wlgb = new GitBridgeApp(new String[] {
            makeConfigFile(44003, 4003)
        });
        wlgb.run();
        File testprojDir = gitClone("testproj", 44003, dir);
        assertTrue(FileUtil.gitDirectoriesAreEqual(getResource("/canPullUpdatedBinaryFiles/base/testproj"), testprojDir.toPath()));
        server.setState(states.get("canPullUpdatedBinaryFiles").get("withUpdatedBinaryFiles"));
        gitPull(testprojDir);
        wlgb.stop();
        assertTrue(FileUtil.gitDirectoriesAreEqual(getResource("/canPullUpdatedBinaryFiles/withUpdatedBinaryFiles/testproj"), testprojDir.toPath()));
    }

    @Test
    public void canPullAModifiedNestedFile() throws IOException, GitAPIException, InterruptedException {
        MockSnapshotServer server = new MockSnapshotServer(3864, getResource("/canPullAModifiedNestedFile").toFile());
        server.start();
        server.setState(states.get("canPullAModifiedNestedFile").get("base"));
        GitBridgeApp wlgb = new GitBridgeApp(new String[] {
            makeConfigFile(33864, 3864)
        });
        wlgb.run();
        File testprojDir = gitClone("testproj", 33864, dir);
        assertTrue(FileUtil.gitDirectoriesAreEqual(getResource("/canPullAModifiedNestedFile/base/testproj"), testprojDir.toPath()));
        server.setState(states.get("canPullAModifiedNestedFile").get("withModifiedNestedFile"));
        gitPull(testprojDir);
        wlgb.stop();
        assertTrue(FileUtil.gitDirectoriesAreEqual(getResource("/canPullAModifiedNestedFile/withModifiedNestedFile/testproj"), testprojDir.toPath()));
    }

    @Test
    public void canPullDeletedNestedFiles() throws IOException, GitAPIException, InterruptedException {
        MockSnapshotServer server = new MockSnapshotServer(3865, getResource("/canPullDeletedNestedFiles").toFile());
        server.start();
        server.setState(states.get("canPullDeletedNestedFiles").get("base"));
        GitBridgeApp wlgb = new GitBridgeApp(new String[] {
            makeConfigFile(33865, 3865)
        });
        wlgb.run();
        File testprojDir = gitClone("testproj", 33865, dir);
        assertTrue(FileUtil.gitDirectoriesAreEqual(getResource("/canPullDeletedNestedFiles/base/testproj"), testprojDir.toPath()));
        server.setState(states.get("canPullDeletedNestedFiles").get("withDeletedNestedFiles"));
        gitPull(testprojDir);
        wlgb.stop();
        assertTrue(FileUtil.gitDirectoriesAreEqual(getResource("/canPullDeletedNestedFiles/withDeletedNestedFiles/testproj"), testprojDir.toPath()));
    }

    @Test
    public void canPushFilesSuccessfully() throws IOException, GitAPIException, InterruptedException {
        MockSnapshotServer server = new MockSnapshotServer(3866, getResource("/canPushFilesSuccessfully").toFile());
        server.start();
        GitBridgeApp wlgb = new GitBridgeApp(new String[] {
            makeConfigFile(33866, 3866)
        });
        wlgb.run();
        server.setState(states.get("canPushFilesSuccessfully").get("state"));
        File testprojDir = gitClone("testproj", 33866, dir);
        assertTrue(FileUtil.gitDirectoriesAreEqual(getResource("/canPushFilesSuccessfully/state/testproj"), testprojDir.toPath()));
        assertEquals(0, runtime.exec("touch push.tex", null, testprojDir).waitFor());
        gitAdd(testprojDir);
        gitCommit(testprojDir, "push");
        gitPush(testprojDir);
        wlgb.stop();
    }

    private static final String EXPECTED_OUT_PUSH_OUT_OF_DATE_FIRST =
      "error: failed to push some refs to 'http://127.0.0.1:33867/testproj.git'\n" +
      "hint: Updates were rejected because the tip of your current branch is behind\n" +
      "hint: its remote counterpart. Integrate the remote changes (e.g.\n" +
      "hint: 'git pull ...') before pushing again.\n" +
      "hint: See the 'Note about fast-forwards' in 'git push --help' for details.\n";

    @Test
    public void pushFailsOnFirstStageOutOfDate() throws IOException, GitAPIException, InterruptedException {
        MockSnapshotServer server = new MockSnapshotServer(3867, getResource("/pushFailsOnFirstStageOutOfDate").toFile());
        server.start();
        server.setState(states.get("pushFailsOnFirstStageOutOfDate").get("state"));
        GitBridgeApp wlgb = new GitBridgeApp(new String[] {
            makeConfigFile(33867, 3867)
        });
        wlgb.run();
        File testprojDir = gitClone("testproj", 33867, dir);
        assertTrue(FileUtil.gitDirectoriesAreEqual(getResource("/pushFailsOnFirstStageOutOfDate/state/testproj"), testprojDir.toPath()));
        runtime.exec("touch push.tex", null, testprojDir).waitFor();
        gitAdd(testprojDir);
        gitCommit(testprojDir, "push");
        Process push = gitPush(testprojDir, 1);
        wlgb.stop();
        assertEquals(EXPECTED_OUT_PUSH_OUT_OF_DATE_FIRST, Util.fromStream(push.getErrorStream(), 2));
    }

    private static final String EXPECTED_OUT_PUSH_OUT_OF_DATE_SECOND =
      "error: failed to push some refs to 'http://127.0.0.1:33868/testproj.git'\n" +
      "hint: Updates were rejected because the tip of your current branch is behind\n" +
      "hint: its remote counterpart. Integrate the remote changes (e.g.\n" +
      "hint: 'git pull ...') before pushing again.\n" +
      "hint: See the 'Note about fast-forwards' in 'git push --help' for details.\n";

    @Test
    public void pushFailsOnSecondStageOutOfDate() throws IOException, GitAPIException, InterruptedException {
        MockSnapshotServer server = new MockSnapshotServer(3868, getResource("/pushFailsOnSecondStageOutOfDate").toFile());
        server.start();
        server.setState(states.get("pushFailsOnSecondStageOutOfDate").get("state"));
        GitBridgeApp wlgb = new GitBridgeApp(new String[] {
            makeConfigFile(33868, 3868)
        });
        wlgb.run();
        File testprojDir = gitClone("testproj", 33868, dir);
        assertTrue(FileUtil.gitDirectoriesAreEqual(getResource("/pushFailsOnSecondStageOutOfDate/state/testproj"), testprojDir.toPath()));
        runtime.exec("touch push.tex", null, testprojDir).waitFor();
        gitAdd(testprojDir);
        gitCommit(testprojDir, "push");
        Process push = gitPush(testprojDir, 1);
        wlgb.stop();
        assertEquals(EXPECTED_OUT_PUSH_OUT_OF_DATE_SECOND, Util.fromStream(push.getErrorStream(), 2));
    }

    private static final List<String> EXPECTED_OUT_PUSH_INVALID_FILES = Arrays.asList(
        "remote: hint: You have 4 invalid files in your Overleaf project:",
        "remote: hint: file1.invalid (error)",
        "remote: hint: file2.exe (invalid file extension)",
        "remote: hint: hello world.png (rename to: hello_world.png)",
        "remote: hint: an image.jpg (rename to: an_image.jpg)",
        "To http://127.0.0.1:33869/testproj.git",
        "! [remote rejected] master -> master (invalid files)",
        "error: failed to push some refs to 'http://127.0.0.1:33869/testproj.git'"
    );

    @Test
    public void pushFailsOnInvalidFiles() throws IOException, GitAPIException, InterruptedException {
        MockSnapshotServer server = new MockSnapshotServer(3869, getResource("/pushFailsOnInvalidFiles").toFile());
        server.start();
        server.setState(states.get("pushFailsOnInvalidFiles").get("state"));
        GitBridgeApp wlgb = new GitBridgeApp(new String[] {
            makeConfigFile(33869, 3869)
        });
        wlgb.run();
        File testprojDir = gitClone("testproj", 33869, dir);
        assertTrue(FileUtil.gitDirectoriesAreEqual(getResource("/pushFailsOnInvalidFiles/state/testproj"), testprojDir.toPath()));
        runtime.exec("touch push.tex", null, testprojDir).waitFor();
        gitAdd(testprojDir);
        gitCommit(testprojDir, "push");
        Process push = gitPush(testprojDir, 1);
        wlgb.stop();
        List<String> actual = Util.linesFromStream(push.getErrorStream(), 2, "[K");
        assertEquals(EXPECTED_OUT_PUSH_INVALID_FILES, actual);
    }

    private static final List<String> EXPECTED_OUT_PUSH_INVALID_PROJECT = Arrays.asList(
        "remote: hint: project: no main file",
        "remote: hint: The project would have no (editable) main .tex file.",
        "To http://127.0.0.1:33870/testproj.git",
        "! [remote rejected] master -> master (invalid project)",
        "error: failed to push some refs to 'http://127.0.0.1:33870/testproj.git'"
    );

    @Test
    public void pushFailsOnInvalidProject() throws IOException, GitAPIException, InterruptedException {
        MockSnapshotServer server = new MockSnapshotServer(3870, getResource("/pushFailsOnInvalidProject").toFile());
        server.start();
        server.setState(states.get("pushFailsOnInvalidProject").get("state"));
        GitBridgeApp wlgb = new GitBridgeApp(new String[] {
            makeConfigFile(33870, 3870)
        });
        wlgb.run();
        File testprojDir = gitClone("testproj", 33870, dir);
        assertTrue(FileUtil.gitDirectoriesAreEqual(getResource("/pushFailsOnInvalidProject/state/testproj"), testprojDir.toPath()));
        runtime.exec("touch push.tex", null, testprojDir).waitFor();
        gitAdd(testprojDir);
        gitCommit(testprojDir, "push");
        Process push = gitPush(testprojDir, 1);
        wlgb.stop();
        List<String> actual = Util.linesFromStream(push.getErrorStream(), 2, "[K");
        assertEquals(EXPECTED_OUT_PUSH_INVALID_PROJECT, actual);
    }

    private static final List<String> EXPECTED_OUT_PUSH_UNEXPECTED_ERROR = Arrays.asList(
        "remote: hint: There was an internal error with the Overleaf server.",
        "remote: hint: Please contact Overleaf.",
        "To http://127.0.0.1:33871/testproj.git",
        "! [remote rejected] master -> master (Overleaf error)",
        "error: failed to push some refs to 'http://127.0.0.1:33871/testproj.git'"
    );

    /* this one prints a stack trace */
    @Test
    public void pushFailsOnUnexpectedError() throws IOException, GitAPIException, InterruptedException {
        MockSnapshotServer server = new MockSnapshotServer(3871, getResource("/pushFailsOnUnexpectedError").toFile());
        server.start();
        server.setState(states.get("pushFailsOnUnexpectedError").get("state"));
        GitBridgeApp wlgb = new GitBridgeApp(new String[] {
            makeConfigFile(33871, 3871)
        });
        wlgb.run();
        File testprojDir = gitClone("testproj", 33871, dir);
        assertTrue(FileUtil.gitDirectoriesAreEqual(getResource("/pushFailsOnUnexpectedError/state/testproj"), testprojDir.toPath()));
        runtime.exec("touch push.tex", null, testprojDir).waitFor();
        gitAdd(testprojDir);
        gitCommit(testprojDir, "push");
        Process push = gitPush(testprojDir, 1);
        wlgb.stop();
        List<String> actual = Util.linesFromStream(push.getErrorStream(), 2, "[K");
        assertEquals(EXPECTED_OUT_PUSH_UNEXPECTED_ERROR, actual);
    }

    private static final List<String> EXPECTED_OUT_PUSH_INVALID_EXE_FILE = Arrays.asList(
        "remote: error: invalid files",
        "remote:",
        "remote: hint: You have 1 invalid files in your Overleaf project:",
        "remote: hint: file1.exe (invalid file extension)",
        "To http://127.0.0.1:33872/testproj.git",
        "! [remote rejected] master -> master (invalid files)",
        "error: failed to push some refs to 'http://127.0.0.1:33872/testproj.git'"
    );

    @Test
    public void pushSucceedsAfterRemovingInvalidFiles() throws IOException, GitAPIException, InterruptedException {
        MockSnapshotServer server = new MockSnapshotServer(3872, getResource("/pushSucceedsAfterRemovingInvalidFiles").toFile());
        server.start();
        server.setState(states.get("pushSucceedsAfterRemovingInvalidFiles").get("invalidState"));
        GitBridgeApp wlgb = new GitBridgeApp(new String[] {
                makeConfigFile(33872, 3872)
        });
        wlgb.run();
        File testprojDir = gitClone("testproj", 33872, dir);

        // try to push invalid file; it should fail
        assertTrue(FileUtil.gitDirectoriesAreEqual(getResource("/pushSucceedsAfterRemovingInvalidFiles/invalidState/testproj"), testprojDir.toPath()));
        assertEquals(0, runtime.exec("touch file1.exe", null, testprojDir).waitFor());
        gitAdd(testprojDir);
        gitCommit(testprojDir, "push");
        Process push = gitPush(testprojDir, 1);
        List<String> actual = Util.linesFromStream(push.getErrorStream(), 0, "[K");
        assertEquals(EXPECTED_OUT_PUSH_INVALID_EXE_FILE, actual);

        // remove invalid file and push again; it should succeed this time
        assertEquals(0, runtime.exec("git rm file1.exe", null, testprojDir).waitFor());
        gitCommit(testprojDir, "remove_invalid_file");
        server.setState(states.get("pushSucceedsAfterRemovingInvalidFiles").get("validState"));
        gitPush(testprojDir);
        wlgb.stop();
        assertTrue(FileUtil.gitDirectoriesAreEqual(getResource("/pushSucceedsAfterRemovingInvalidFiles/validState/testproj"), testprojDir.toPath()));
    }

    @Test
    public void canServePushedFiles() throws IOException, ExecutionException, InterruptedException {
        //
        // I don't think we can test this completely without some changes to the mock server, because we have no way
        // of pausing the test while the push is in progress. Once the push is over, the file isn't actually there for
        // us to fetch any more. We can however test the access and error conditions, which comprise most of the logic.
        //
        int gitBridgePort = 33873;
        int mockServerPort = 3873;

        MockSnapshotServer server = new MockSnapshotServer(
                mockServerPort, getResource("/canServePushedFiles").toFile());
        server.start();
        server.setState(states.get("canServePushedFiles").get("state"));

        GitBridgeApp wlgb = new GitBridgeApp(new String[] {
                makeConfigFile(gitBridgePort, mockServerPort)
        });
        wlgb.run();

        File testprojDir = gitClone("testproj", gitBridgePort, dir);
        assertTrue(FileUtil.gitDirectoriesAreEqual(getResource("/canServePushedFiles/state/testproj"), testprojDir.toPath()));
        runtime.exec("touch push.tex", null, testprojDir).waitFor();
        gitAdd(testprojDir);
        gitCommit(testprojDir, "push");
        gitPush(testprojDir);

        // With no key, we should get a 404.
        String url = "http://127.0.0.1:" + gitBridgePort + "/api/testproj/push.tex";
        Response response = asyncHttpClient().prepareGet(url).execute().get();
        assertEquals(404, response.getStatusCode());

        // With an invalid project and no key, we should get a 404.
        url = "http://127.0.0.1:" + gitBridgePort + "/api/notavalidproject/push.tex";
        response = asyncHttpClient().prepareGet(url).execute().get();
        assertEquals(404, response.getStatusCode());

        // With a bad key for a valid project, we should get a 404.
        url = "http://127.0.0.1:" + gitBridgePort + "/api/testproj/push.tex?key=notavalidkey";
        response = asyncHttpClient().prepareGet(url).execute().get();
        assertEquals(404, response.getStatusCode());

        // With a bad key for an invalid project, we should get a 404.
        url = "http://127.0.0.1:" + gitBridgePort + "/api/notavalidproject/push.tex?key=notavalidkey";
        response = asyncHttpClient().prepareGet(url).execute().get();
        assertEquals(404, response.getStatusCode());

        wlgb.stop();
    }

    @Test
    public void wlgbCanSwapProjects(
    ) throws IOException, GitAPIException, InterruptedException {
        MockSnapshotServer server = new MockSnapshotServer(
                3874,
                getResource("/wlgbCanSwapProjects").toFile()
        );
        server.start();
        server.setState(states.get("wlgbCanSwapProjects").get("state"));
        GitBridgeApp wlgb = new GitBridgeApp(new String[] {
                makeConfigFile(33874, 3874, new SwapJobConfig(1, 0, 0, 250))
        });
        wlgb.run();
        File rootGitDir = new File(wlgb.config.getRootGitDirectory());
        File testProj1ServerDir = new File(rootGitDir, "testproj1");
        File testProj2ServerDir = new File(rootGitDir, "testproj2");
        File testProj1Dir = gitClone("testproj1", 33874, dir);
        assertTrue(testProj1ServerDir.exists());
        assertFalse(testProj2ServerDir.exists());
        gitClone("testproj2", 33874, dir);
        while (testProj1ServerDir.exists());
        assertFalse(testProj1ServerDir.exists());
        assertTrue(testProj2ServerDir.exists());
        FileUtils.deleteDirectory(testProj1Dir);
        gitClone("testproj1", 33874, dir);
        while (testProj2ServerDir.exists());
        assertTrue(testProj1ServerDir.exists());
        assertFalse(testProj2ServerDir.exists());
        wlgb.stop();
    }

    private static final List<String> EXPECTED_OUT_PUSH_SUBMODULE = Arrays.asList(
            "remote: hint: Your Git repository contains a reference we cannot resolve.",
            "remote: hint: If your project contains a Git submodule,",
            "remote: hint: please remove it and try again.",
            "To http://127.0.0.1:33875/testproj.git",
            "! [remote rejected] master -> master (invalid git repo)",
            "error: failed to push some refs to 'http://127.0.0.1:33875/testproj.git'"
    );

    @Test
    public void pushSubmoduleFailsWithInvalidGitRepo() throws IOException, GitAPIException, InterruptedException {
        MockSnapshotServer server = new MockSnapshotServer(3875, getResource("/pushSubmoduleFailsWithInvalidGitRepo").toFile());
        server.start();
        server.setState(states.get("pushSubmoduleFailsWithInvalidGitRepo").get("state"));
        GitBridgeApp wlgb = new GitBridgeApp(new String[] {
                makeConfigFile(33875, 3875)
        });
        wlgb.run();
        File testprojDir = gitClone("testproj", 33875, dir);
        runtime.exec("mkdir sub", null, testprojDir).waitFor();
        File sub = new File(testprojDir, "sub");
        runtime.exec("touch sub.txt", null, sub).waitFor();
        gitInit(sub);
        gitAdd(sub);
        gitCommit(sub, "sub");
        gitAdd(testprojDir);
        gitCommit(testprojDir, "push");
        Process push = gitPush(testprojDir, 1);
        wlgb.stop();
        List<String> actual = Util.linesFromStream(push.getErrorStream(), 2, "[K");
        assertEquals(EXPECTED_OUT_PUSH_SUBMODULE, actual);
        wlgb.stop();
    }

    @Test
    public void usesCustomErrorHandler(
    ) throws IOException, ExecutionException, InterruptedException {

        int gitBridgePort = 33873;
        int mockServerPort = 3873;

        MockSnapshotServer server = new MockSnapshotServer(
                mockServerPort, getResource("/canServePushedFiles").toFile());
        server.start();
        server.setState(states.get("canServePushedFiles").get("state"));

        GitBridgeApp wlgb = new GitBridgeApp(new String[] {
                makeConfigFile(gitBridgePort, mockServerPort)
        });
        wlgb.run();

        // With an invalid project and no key, we should get a 404,
        // which is rendered by our custom error handler.
        String url = "http://127.0.0.1:" + gitBridgePort + "/api/notavalidproject/main.tex";
        Response response = asyncHttpClient().prepareGet(url).execute().get();
        assertEquals(404, response.getStatusCode());
        assertEquals("{\"message\":\"HTTP error 404\"}", response.getResponseBody());

        // With an unsupported URL outside the api, we should get a 500,
        // which is rendered by our custom error handler.
        url = "http://127.0.0.1:" + gitBridgePort + "/foo";
        response = asyncHttpClient().prepareGet(url).execute().get();
        assertEquals(500, response.getStatusCode());
        assertEquals("{\"message\":\"HTTP error 500\"}", response.getResponseBody());

        wlgb.stop();
    }

    @Test
    public void cannotCloneAProtectedProjectWithoutAuthentication() throws IOException, GitAPIException, InterruptedException {
        int gitBridgePort = 33883;
        int mockServerPort = 3883;

        MockSnapshotServer server = new MockSnapshotServer(mockServerPort, getResource("/cannotCloneAProtectedProjectWithoutAuthentication").toFile());
        server.start();
        server.setState(states.get("cannotCloneAProtectedProjectWithoutAuthentication").get("state"));
        GitBridgeApp wlgb = new GitBridgeApp(new String[] {
            makeConfigFile(gitBridgePort, mockServerPort)
        });

        wlgb.run();
        Process gitProcess = runtime.exec("git clone http://127.0.0.1:" + gitBridgePort + "/testproj.git", null, dir);
        wlgb.stop();
        assertNotEquals(0, gitProcess.waitFor());
    }

    @Test
    public void cannotCloneA4xxProject() throws IOException, GitAPIException, InterruptedException {
        int gitBridgePort = 33879;
        int mockServerPort = 3879;

        MockSnapshotServer server = new MockSnapshotServer(mockServerPort, getResource("/cannotCloneA4xxProject").toFile());
        server.start();
        server.setState(states.get("cannotCloneA4xxProject").get("state"));
        GitBridgeApp wlgb = new GitBridgeApp(new String[] {
            makeConfigFile(gitBridgePort, mockServerPort)
        });

        wlgb.run();
        Process gitProcess = runtime.exec("git clone http://127.0.0.1:" + gitBridgePort + "/testproj.git", null, dir);
        wlgb.stop();
        assertNotEquals(0, gitProcess.waitFor());
    }

    @Test
    public void cannotCloneAMissingProject() throws IOException, GitAPIException, InterruptedException {
        int gitBridgePort = 33880;
        int mockServerPort = 3880;

        MockSnapshotServer server = new MockSnapshotServer(mockServerPort, getResource("/cannotCloneAMissingProject").toFile());
        server.start();
        server.setState(states.get("cannotCloneAMissingProject").get("state"));
        GitBridgeApp wlgb = new GitBridgeApp(new String[] {
            makeConfigFile(gitBridgePort, mockServerPort)
        });

        wlgb.run();
        Process gitProcess = runtime.exec("git clone http://127.0.0.1:" + gitBridgePort + "/testproj.git", null, dir);
        wlgb.stop();
        assertNotEquals(0, gitProcess.waitFor());
    }

    private String makeConfigFile(
            int port,
            int apiPort
    ) throws IOException {
        return makeConfigFile(port, apiPort, null);
    }

    private String makeConfigFile(
            int port,
            int apiPort,
            SwapJobConfig swapCfg
    ) throws IOException {
        File wlgb = folder.newFolder();
        File config = folder.newFile();
        PrintWriter writer = new PrintWriter(config);
        String cfgStr =
                "{\n" +
                "    \"port\": " + port + ",\n" +
                "    \"rootGitDirectory\": \"" +
                        wlgb.getAbsolutePath() +
                        "\",\n" +
                "    \"apiBaseUrl\": \"http://127.0.0.1:" +
                        apiPort +
                        "/api/v0\",\n" +
                "    \"username\": \"\",\n" +
                "    \"password\": \"\",\n" +
                "    \"postbackBaseUrl\": \"http://127.0.0.1:" +
                        port +
                        "\",\n" +
                "    \"serviceName\": \"Overleaf\",\n" +
                "    \"oauth2\": {\n" +
                "        \"oauth2ClientID\": \"clientID\",\n" +
                "        \"oauth2ClientSecret\": \"oauth2 client secret\",\n" +
                "        \"oauth2Server\": \"https://www.overleaf.com\"\n" +
                "    }";
        if (swapCfg != null) {
            cfgStr += ",\n" +
                    "    \"swapStore\": {\n" +
                    "        \"type\": \"memory\",\n" +
                    "        \"awsAccessKey\": null,\n" +
                    "        \"awsSecret\": null,\n" +
                    "        \"s3BucketName\": \"com.overleaf.testbucket\"\n" +
                    "    },\n" +
                    "    \"swapJob\": {\n" +
                    "        \"minProjects\": " +
                    swapCfg.getMinProjects() +
                    ",\n" +
                    "        \"lowGiB\": " +
                    swapCfg.getLowGiB() +
                    ",\n" +
                    "        \"highGiB\": " +
                    swapCfg.getHighGiB() +
                    ",\n" +
                    "        \"intervalMillis\": " +
                    swapCfg.getIntervalMillis() +
                    "\n" +
                    "    }\n";
        }
        cfgStr += "}\n";
        writer.print(cfgStr);
        writer.close();
        return config.getAbsolutePath();
    }

    private Path getResource(String path) {
        return Paths.get(
                "src/test/resources/" +
                "uk/ac/ic/wlgitbridge/WLGitBridgeIntegrationTest" + path
        );
    }

    private InputStream getResourceAsStream(String path) {
        try {
            return new FileInputStream(getResource(path).toFile());
        } catch (FileNotFoundException e) {
            throw new RuntimeException(e);
        }
    }

    private static String withoutWhitespace(String s) {
        return s.replaceAll("\\s","");
    }

}
