public class Config implements JSONSource {

    static Config asSanitised(Config config) {
        return new Config(
                config.port,
                config.rootGitDirectory,
                config.username,
                "<password>",
                config.apiBaseURL,
                config.postbackURL,
                config.serviceName,
                Oauth2.asSanitised(config.oauth2),
                config.repoStore,
                SwapStoreConfig.sanitisedCopy(config.swapStore),
                config.swapJob
        );
    }

    private int port;
    private String rootGitDirectory;
    private String username;
    private String password;
    private String apiBaseURL;
    private String postbackURL;
    private String serviceName;
    @Nullable
    private Oauth2 oauth2;
    @Nullable
    private RepoStoreConfig repoStore;
    @Nullable
    private SwapStoreConfig swapStore;
    @Nullable
    private SwapJobConfig swapJob;

    public Config(
            String configFilePath
    ) throws ConfigFileException,
             IOException {
        this(new FileReader(configFilePath));
    }

    Config(Reader reader) {
        fromJSON(new Gson().fromJson(reader, JsonElement.class));
    }

    public Config(
            int port,
            String rootGitDirectory,
            String username,
            String password,
            String apiBaseURL,
            String postbackURL,
            String serviceName,
            Oauth2 oauth2,
            RepoStoreConfig repoStore,
            SwapStoreConfig swapStore,
            SwapJobConfig swapJob
    ) {
        this.port = port;
        this.rootGitDirectory = rootGitDirectory;
        this.username = username;
        this.password = password;
        this.apiBaseURL = apiBaseURL;
        this.postbackURL = postbackURL;
        this.serviceName = serviceName;
        this.oauth2 = oauth2;
        this.repoStore = repoStore;
        this.swapStore = swapStore;
        this.swapJob = swapJob;
    }

    @Override
    public void fromJSON(JsonElement json) {
        JsonObject configObject = json.getAsJsonObject();
        port = getElement(configObject, "port").getAsInt();
        rootGitDirectory = getElement(
                configObject,
                "rootGitDirectory"
        ).getAsString();
        username = getOptionalString(configObject, "username");
        password = getOptionalString(configObject, "password");
        String apiBaseURL = getElement(
                configObject,
                "apiBaseUrl"
        ).getAsString();
        if (!apiBaseURL.endsWith("/")) {
            apiBaseURL += "/";
        }
        this.apiBaseURL = apiBaseURL;
        serviceName = getElement(configObject, "serviceName").getAsString();
        postbackURL = getElement(configObject, "postbackBaseUrl").getAsString();
        if (!postbackURL.endsWith("/")) {
            postbackURL += "/";
        }
        oauth2 = new Gson().fromJson(configObject.get("oauth2"), Oauth2.class);
        repoStore = new Gson().fromJson(
                configObject.get("repoStore"), RepoStoreConfig.class);
        swapStore = new Gson().fromJson(
                configObject.get("swapStore"),
                SwapStoreConfig.class
        );
        swapJob = new Gson().fromJson(
                configObject.get("swapJob"),
                SwapJobConfig.class
        );
    }

    public String getSanitisedString() {
        return Instance.prettyGson.toJson(Config.asSanitised(this));
    }

    public int getPort() {
        return port;
    }

    public String getRootGitDirectory() {
        return rootGitDirectory;
    }

    public String getUsername() {
        return username;
    }

    public String getPassword() {
        return password;
    }

    public String getAPIBaseURL() {
        return apiBaseURL;
    }

    public String getServiceName() {
        return serviceName;
    }

    public String getPostbackURL() {
        return postbackURL;
    }

    public boolean isUsingOauth2() {
        return oauth2 != null;
    }

    public Oauth2 getOauth2() {
        if (!isUsingOauth2()) {
            throw new AssertionError("Getting oauth2 when not using it");
        }
        return oauth2;
    }

    public Optional<RepoStoreConfig> getRepoStore() {
        return Optional.ofNullable(repoStore);
    }

    public Optional<SwapStoreConfig> getSwapStore() {
        return Optional.ofNullable(swapStore);
    }

    public Optional<SwapJobConfig> getSwapJob() {
        return Optional.ofNullable(swapJob);
    }

    private JsonElement getElement(JsonObject configObject, String name) {
        JsonElement element = configObject.get(name);
        if (element == null) {
            throw new RuntimeException(new ConfigFileException(name));
        }
        return element;
    }

    private String getOptionalString(JsonObject configObject, String name) {
        JsonElement element = configObject.get(name);
        if (element == null || !element.isJsonPrimitive()) {
            return "";
        }
        return element.getAsString();
    }

} public interface DBStore {

    int getNumProjects();

    List<String> getProjectNames();

    void setLatestVersionForProject(String project, int versionID);

    int getLatestVersionForProject(String project);

    void addURLIndexForProject(String projectName, String url, String path);

    void deleteFilesForProject(String project, String... files);

    String getPathForURLInProject(String projectName, String url);

    String getOldestUnswappedProject();

    int getNumUnswappedProjects();

    ProjectState getProjectState(String projectName);

    /**
     * Sets the last accessed time for the given project name.
     * @param projectName the project's name
     * @param time the time, or null if the project is to be swapped
     */
    void setLastAccessedTime(String projectName, Timestamp time);

} public enum ProjectState {

    NOT_PRESENT,
    PRESENT,
    SWAPPED

} public class SqliteDBStore implements DBStore {

    private final Connection connection;

    public SqliteDBStore(File dbFile) {
        try {
            connection = openConnectionTo(dbFile);
            createTables();
        } catch (Throwable t) {
            throw new DBInitException(t);
        }
    }

    @Override
    public int getNumProjects() {
        return query(new GetNumProjects());
    }

    @Override
    public List<String> getProjectNames() {
        return query(new GetProjectNamesSQLQuery());
    }

    @Override
    public void setLatestVersionForProject(
            String projectName,
            int versionID
    ) {
        update(new SetProjectSQLUpdate(projectName, versionID));
    }

    @Override
    public int getLatestVersionForProject(
            String projectName
    ) {
        return query(new GetLatestVersionForProjectSQLQuery(projectName));
    }

    @Override
    public void addURLIndexForProject(
            String projectName,
            String url,
            String path
    ) {
        update(new AddURLIndexSQLUpdate(projectName, url, path));
    }

    @Override
    public void deleteFilesForProject(
            String projectName,
            String... paths
    ) {
        update(new DeleteFilesForProjectSQLUpdate(projectName, paths));
    }

    @Override
    public String getPathForURLInProject(
            String projectName,
            String url
    ) {
        return query(new GetPathForURLInProjectSQLQuery(projectName, url));
    }

    @Override
    public String getOldestUnswappedProject() {
        return query(new GetOldestProjectName());
    }

    @Override
    public int getNumUnswappedProjects() {
        return query(new GetNumUnswappedProjects());
    }

    @Override
    public ProjectState getProjectState(String projectName) {
        return query(new GetProjectState(projectName));
    }

    @Override
    public void setLastAccessedTime(
            String projectName,
            Timestamp lastAccessed
    ) {
        update(new SetProjectLastAccessedTime(projectName, lastAccessed));
    }

    private Connection openConnectionTo(File dbFile) {
        File parentDir = dbFile.getParentFile();
        if (!parentDir.exists() && !parentDir.mkdirs()) {
            throw new DBInitException(
                    parentDir.getAbsolutePath() + " directory didn't exist, " +
                            "and unable to create. Check your permissions."
            );
        }
        try {
            Class.forName("org.sqlite.JDBC");
        } catch (ClassNotFoundException e) {
            throw new DBInitException(e);
        }
        try {
            return DriverManager.getConnection(
                    "jdbc:sqlite:" + dbFile.getAbsolutePath()
            );
        } catch (SQLException e) {
            throw new DBInitException("Unable to connect to DB", e);
        }
    }

    private void createTables() {
        try {
            doUpdate(new ProjectsAddLastAccessed());
        } catch (SQLException ignore) {
            /* We need to eat exceptions from here */
        }
        Stream.of(
                new CreateProjectsTableSQLUpdate(),
                new CreateProjectsIndexLastAccessed(),
                new CreateURLIndexStoreSQLUpdate(),
                new CreateIndexURLIndexStore()
        ).forEach(this::update);
        /* In the case of needing to change the schema, we need to check that
           ProjectsAddLastAccessed didn't just fail */
        Preconditions.checkState(query(new LastAccessedColumnExists()));
    }

    private void update(SQLUpdate update) {
        try {
            doUpdate(update);
        } catch (SQLException e) {
            throw new RuntimeException(e);
        }
    }

    private <T> T query(SQLQuery<T> query) {
        try {
            return doQuery(query);
        } catch (SQLException e) {
            throw new RuntimeException(e);
        }
    }

    private void doUpdate(SQLUpdate update) throws SQLException {
        PreparedStatement statement = null;
        try {
            statement = connection.prepareStatement(update.getSQL());
            update.addParametersToStatement(statement);
            statement.executeUpdate();
        } catch (SQLException e) {
            throw e;
        } finally {
            try {
                statement.close();
            } catch (Throwable t) {
                throw new SQLException(t);
            }
        }
    }

    private <T> T doQuery(SQLQuery<T> query) throws SQLException {
        PreparedStatement statement = null;
        ResultSet results = null;
        try {
            statement = connection.prepareStatement(query.getSQL());
            query.addParametersToStatement(statement);
            results = statement.executeQuery();
            return query.processResultSet(results);
        } catch (SQLException e) {
            throw e;
        } finally {
            if (statement != null) {
                statement.close();
            }
            if (results != null) {
                results.close();
            }
        }
    }

} public interface GcJob {

    void start();

    void stop();

    void onPreGc(Runnable preGc);

    void onPostGc(Runnable postGc);

    void queueForGc(String projectName);

    CompletableFuture<Void> waitForRun();
} public class UrlResourceCache implements ResourceCache {

    private final DBStore dbStore;

    private final NingHttpClientFacade http;

    UrlResourceCache(DBStore dbStore, NingHttpClientFacade http) {
        this.dbStore = dbStore;
        this.http = http;
    }

    public UrlResourceCache(DBStore dbStore) {
        this(dbStore, new NingHttpClient(new AsyncHttpClient()));
    }

    @Override
    public RawFile get(
            String projectName,
            String url,
            String newPath,
            Map<String, RawFile> fileTable,
            Map<String, byte[]> fetchedUrls,
            Optional<Long> maxFileSize
    ) throws IOException, SizeLimitExceededException {
        String path = dbStore.getPathForURLInProject(projectName, url);
        byte[] contents;
        if (path == null) {
            path = newPath;
            contents = fetch(projectName, url, path, maxFileSize);
            fetchedUrls.put(url, contents);
        } else {
            Log.info("Found (" + projectName + "): " + url);
            Log.info("At (" + projectName + "): " + path);
            contents = fetchedUrls.get(url);
            if (contents == null) {
                RawFile rawFile = fileTable.get(path);
                if (rawFile == null) {
                    Log.warn(
                            "File " + path
                                    + " was not in the current commit, "
                                    + "or the git tree, yet path was not null. "
                                    + "File url is: "
                                    + url
                    );
                    contents = fetch(projectName, url, path, maxFileSize);
                } else {
                    contents = rawFile.getContents();
                }
            }
        }
        return new RepositoryFile(newPath, contents);
    }

    private byte[] fetch(
            String projectName,
            final String url,
            String path,
            Optional<Long> maxFileSize
    ) throws FailedConnectionException, SizeLimitExceededException {
        byte[] contents;
        Log.info("GET -> " + url);
        try {
            contents = http.get(url, hs -> {
                List<String> contentLengths
                        = hs.getHeaders().get("Content-Length");
                if (!maxFileSize.isPresent()) {
                    return true;
                }
                if (contentLengths.isEmpty()) {
                    return true;
                }
                long contentLength = Long.parseLong(contentLengths.get(0));
                long maxFileSize_ = maxFileSize.get();
                if (contentLength <= maxFileSize_) {
                    return true;
                }
                throw new SizeLimitExceededException(
                        Optional.of(path), contentLength, maxFileSize_
                );
            });
        } catch (ExecutionException e) {
            Throwable cause = e.getCause();
            if (cause instanceof SizeLimitExceededException) {
                throw (SizeLimitExceededException) cause;
            }
            Log.warn(
                    "ExecutionException when fetching project: " +
                            projectName +
                            ", url: " +
                            url +
                            ", path: " +
                            path,
                    e
            );
            throw new FailedConnectionException();
        }
        if (maxFileSize.isPresent() && contents.length > maxFileSize.get()) {
            throw new SizeLimitExceededException(
                    Optional.of(path), contents.length, maxFileSize.get());
        }
        dbStore.addURLIndexForProject(projectName, url, path);
        return contents;
    }

} public class NetSnapshotApi implements SnapshotApi {

    @Override
    public CompletableFuture<GetDocResult> getDoc(
            Optional<Credential> oath2, String projectName) {
        return new GetDocRequest(opt(oath2), projectName).request();
    }

    @Override
    public CompletableFuture<GetForVersionResult> getForVersion(
            Optional<Credential> oauth2, String projectName, int versionId) {
        return new GetForVersionRequest(
                opt(oauth2), projectName, versionId).request();
    }

    @Override
    public CompletableFuture<GetSavedVersResult> getSavedVers(
            Optional<Credential> oauth2, String projectName) {
        return new GetSavedVersRequest(opt(oauth2), projectName).request();
    }

    @Override
    public CompletableFuture<PushResult> push(
            Optional<Credential> oauth2,
            CandidateSnapshot candidateSnapshot,
            String postbackKey
    ) {
        return new PushRequest(
                opt(oauth2), candidateSnapshot, postbackKey).request();
    }

    private static Credential opt(Optional<Credential> oath2) {
        return oath2.orElse(null);
    }

} public interface SnapshotApi {

    CompletableFuture<GetDocResult> getDoc(
            Optional<Credential> oath2, String projectName);

    CompletableFuture<GetForVersionResult> getForVersion(
            Optional<Credential> oauth2, String projectName, int versionId);

    CompletableFuture<GetSavedVersResult> getSavedVers(
            Optional<Credential> oauth2, String projectName);

    CompletableFuture<PushResult> push(
            Optional<Credential> oauth2,
            CandidateSnapshot candidateSnapshot,
            String postbackKey);

    static <T> T getResult(CompletableFuture<T> result)
            throws FailedConnectionException, ForbiddenException {
        try {
            return result.join();
        } catch (CompletionException e) {
            try {
                throw e.getCause();
            } catch (FailedConnectionException
                    | ForbiddenException
                    | RuntimeException r) {
                throw r;
            } catch (Throwable __) {
                throw e;
            }
        }
    }

} public class SnapshotApiFacade {

    private final SnapshotApi api;

    public SnapshotApiFacade(SnapshotApi api) {
        this.api = api;
    }

    public boolean projectExists(
            Optional<Credential> oauth2,
            String projectName
    ) throws FailedConnectionException, GitUserException {
        try {
            SnapshotApi
                    .getResult(api.getDoc(oauth2, projectName))
                    .getVersionID();
            return true;
        } catch (InvalidProjectException e) {
            return false;
        }
    }

    public Deque<Snapshot> getSnapshots(
            Optional<Credential> oauth2,
            String projectName,
            int afterVersionId
    ) throws GitUserException, FailedConnectionException {
        List<SnapshotInfo> snapshotInfos = getSnapshotInfosAfterVersion(
                oauth2,
                projectName,
                afterVersionId
        );
        List<SnapshotData> snapshotDatas = getMatchingSnapshotData(
                oauth2,
                projectName,
                snapshotInfos
        );
        return combine(snapshotInfos, snapshotDatas);
    }

    public PushResult push(
            Optional<Credential> oauth2,
            CandidateSnapshot candidateSnapshot,
            String postbackKey
    ) throws FailedConnectionException, ForbiddenException {
        return SnapshotApi.getResult(api.push(
                oauth2, candidateSnapshot, postbackKey));
    }

    private List<SnapshotInfo> getSnapshotInfosAfterVersion(
            Optional<Credential> oauth2,
            String projectName,
            int version
    ) throws FailedConnectionException, GitUserException {
        SortedSet<SnapshotInfo> versions = new TreeSet<>();
        CompletableFuture<GetDocResult> getDoc
                = api.getDoc(oauth2, projectName);
        CompletableFuture<GetSavedVersResult> savedVers
                = api.getSavedVers(oauth2, projectName);
        GetDocResult latestDoc = SnapshotApi.getResult(getDoc);
        int latest = latestDoc.getVersionID();
        if (latest > version) {
            for (
                    SnapshotInfo snapshotInfo :
                    SnapshotApi.getResult(savedVers).getSavedVers()
            ) {
                if (snapshotInfo.getVersionId() > version) {
                    versions.add(snapshotInfo);
                }
            }
            versions.add(new SnapshotInfo(
                    latest,
                    latestDoc.getCreatedAt(),
                    latestDoc.getName(),
                    latestDoc.getEmail()
            ));

        }
        return new ArrayList<>(versions);
    }

    private List<SnapshotData> getMatchingSnapshotData(
            Optional<Credential> oauth2,
            String projectName,
            List<SnapshotInfo> snapshotInfos
    ) throws FailedConnectionException, ForbiddenException {
        List<CompletableFuture<GetForVersionResult>> firedRequests
                = fireDataRequests(oauth2, projectName, snapshotInfos);
        List<SnapshotData> snapshotDataList = new ArrayList<>();
        for (CompletableFuture<GetForVersionResult> fired : firedRequests) {
            snapshotDataList.add(fired.join().getSnapshotData());
        }
        return snapshotDataList;
    }

    private List<CompletableFuture<GetForVersionResult>> fireDataRequests(
            Optional<Credential> oauth2,
            String projectName,
            List<SnapshotInfo> snapshotInfos
    ) {
        return snapshotInfos
                .stream()
                .map(snap -> api.getForVersion(
                        oauth2, projectName, snap.getVersionId()))
                .collect(Collectors.toList());
    }

    private static Deque<Snapshot> combine(
            List<SnapshotInfo> snapshotInfos,
            List<SnapshotData> snapshotDatas
    ) {
        Deque<Snapshot> snapshots = new LinkedList<>();
        Iterator<SnapshotInfo> infos = snapshotInfos.iterator();
        Iterator<SnapshotData> datas = snapshotDatas.iterator();
        while (infos.hasNext()) {
            snapshots.add(new Snapshot(infos.next(), datas.next()));
        }
        return snapshots;
    }

} public interface SwapJob {

    static SwapJob fromConfig(
            Optional<SwapJobConfig> cfg,
            ProjectLock lock,
            RepoStore repoStore,
            DBStore dbStore,
            SwapStore swapStore
    ) {
        if (cfg.isPresent()) {
            return new SwapJobImpl(
                    cfg.get(),
                    lock,
                    repoStore,
                    dbStore,
                    swapStore
            );
        }
        return new NoopSwapJob();
    }

    /**
     * Starts the swap job, which should schedule an attempted swap at the given
     * configured interval (config["swapJob"]["intervalMillis"]
     */
    void start();

    /**
     * Stops the stop job.
     */
    void stop();

    /**
     * Called by the swap job when a project should be evicted.
     *
     * Pre:
     * 1. projName must be in repoStore
     * 2. projName should not be in swapStore
     * 3. projName should be PRESENT in dbStore (last_accessed is not null)
     *
     * Acquires the project lock and performs an eviction of projName.
     *
     * Post:
     * 1. projName should not in repoStore
     * 2. projName must be in swapStore
     * 3. projName must be SWAPPED in dbStore (last_accessed is null)
     * @param projName
     * @throws IOException
     */
    void evict(String projName) throws IOException;

    /**
     * Called on a project when it must be restored.
     *
     * Pre:
     * 1. projName should not be in repoStore
     * 2. projName must be in swapStore
     * 3. projName must be SWAPPED in dbStore (last_accessed is null)
     *
     * Acquires the project lock and restores projName.
     *
     * Post:
     * 1. projName must be in repoStore
     * 2. projName should not in swapStore
     * 3. projName should be PRESENT in dbStore (last_accessed is not null)
     * @param projName
     * @throws IOException
     */
    void restore(String projName) throws IOException;

} public class SwapJobImpl implements SwapJob {

    private static final long GiB = (1l << 30);

    int minProjects;
    long lowWatermarkBytes;
    long highWatermarkBytes;
    Duration interval;

    private final ProjectLock lock;
    private final RepoStore repoStore;
    private final DBStore dbStore;
    private final SwapStore swapStore;

    private final Timer timer;

    final AtomicInteger swaps;

    public SwapJobImpl(
            SwapJobConfig cfg,
            ProjectLock lock,
            RepoStore repoStore,
            DBStore dbStore,
            SwapStore swapStore
    ) {
        this(
                cfg.getMinProjects(),
                GiB * cfg.getLowGiB(),
                GiB * cfg.getHighGiB(),
                Duration.ofMillis(cfg.getIntervalMillis()),
                lock,
                repoStore,
                dbStore,
                swapStore
        );
    }

    SwapJobImpl(
            int minProjects,
            long lowWatermarkBytes,
            long highWatermarkBytes,
            Duration interval,
            ProjectLock lock,
            RepoStore repoStore,
            DBStore dbStore,
            SwapStore swapStore
    ) {
        this.minProjects = minProjects;
        this.lowWatermarkBytes = lowWatermarkBytes;
        this.highWatermarkBytes = highWatermarkBytes;
        this.interval = interval;
        this.lock = lock;
        this.repoStore = repoStore;
        this.dbStore = dbStore;
        this.swapStore = swapStore;
        timer = new Timer();
        swaps = new AtomicInteger(0);
    }

    @Override
    public void start() {
        timer.schedule(
                TimerUtils.makeTimerTask(this::doSwap),
                0
        );
    }

    @Override
    public void stop() {
        timer.cancel();
    }

    private void doSwap() {
        try {
            doSwap_();
        } catch (Throwable t) {
            Log.warn("Exception thrown during swap job", t);
        }
        timer.schedule(
                TimerUtils.makeTimerTask(this::doSwap),
                interval.toMillis()
        );
    }

    private void doSwap_() {
        Log.info("Running swap number {}", swaps.get() + 1);
        long totalSize = repoStore.totalSize();
        Log.info("Size is {}/{} (high)", totalSize, highWatermarkBytes);
        if (totalSize < highWatermarkBytes) {
            Log.info("No need to swap.");
            swaps.incrementAndGet();
            return;
        }
        int numProjects = dbStore.getNumProjects();
        while (
                (totalSize = repoStore.totalSize()) > lowWatermarkBytes &&
                (numProjects = dbStore.getNumUnswappedProjects()) > minProjects
        ) {
            try {
                evict(dbStore.getOldestUnswappedProject());
            } catch (IOException e) {
                Log.warn("Exception while swapping, giving up", e);
            }
        }
        if (totalSize > lowWatermarkBytes) {
            Log.warn(
                    "Finished swapping, but total size is still too high."
            );
        }
        Log.info(
                "Size: {}/{} (low), " +
                        "{} (high), " +
                        "projects on disk: {}/{}, " +
                        "min projects on disk: {}",
                totalSize,
                lowWatermarkBytes,
                highWatermarkBytes,
                numProjects,
                dbStore.getNumProjects(),
                minProjects
        );
        swaps.incrementAndGet();
    }

    /**
     * @see SwapJob#evict(String) for high-level description.
     *
     * 1. Acquires the project lock.
     * 2. Gets a bz2 stream and size of a project from the repo store, or throws
     * 3. Uploads the bz2 stream and size to the projName in the swapStore.
     * 4. Sets the last accessed time in the dbStore to null, which makes our
     *    state SWAPPED
     * 5. Removes the project from the repo store.
     * @param projName
     * @throws IOException
     */
    @Override
    public void evict(String projName) throws IOException {
        Preconditions.checkNotNull(projName, "projName was null");
        Log.info("Evicting project: {}", projName);
        try (LockGuard __ = lock.lockGuard(projName)) {
            long[] sizePtr = new long[1];
            try (InputStream blob = repoStore.bzip2Project(projName, sizePtr)) {
                swapStore.upload(projName, blob, sizePtr[0]);
                dbStore.setLastAccessedTime(projName, null);
                repoStore.remove(projName);
            }
        }
        Log.info("Evicted project: {}", projName);
    }

    /**
     * @see SwapJob#restore(String) for high-level description.
     *
     * 1. Acquires the project lock.
     * 2. Gets a bz2 stream for the project from the swapStore.
     * 3. Fully downloads and places the bz2 stream back in the repo store.
     * 4. Sets the last accessed time in the dbStore to now, which makes our
     *    state PRESENT and the last project to be evicted.
     * @param projName
     * @throws IOException
     */
    @Override
    public void restore(String projName) throws IOException {
        try (LockGuard __ = lock.lockGuard(projName)) {
            try (InputStream zipped = swapStore.openDownloadStream(projName)) {
                repoStore.unbzip2Project(
                        projName,
                        zipped
                );
                swapStore.remove(projName);
                dbStore.setLastAccessedTime(
                        projName,
                        Timestamp.valueOf(LocalDateTime.now())
                );
            }
        }
    }

} public class S3SwapStore implements SwapStore {

    private final AmazonS3 s3;

    private final String bucketName;

    public S3SwapStore(SwapStoreConfig cfg) {
        this(
                cfg.getAwsAccessKey(),
                cfg.getAwsSecret(),
                cfg.getS3BucketName()
        );
    }

    S3SwapStore(
            String accessKey,
            String secret,
            String bucketName
    ) {
        s3 = new AmazonS3Client(new BasicAWSCredentials(accessKey, secret));
        this.bucketName = bucketName;
    }

    @Override
    public void upload(
            String projectName,
            InputStream uploadStream,
            long contentLength
    ) {
        ObjectMetadata metadata = new ObjectMetadata();
        metadata.setContentLength(contentLength);
        PutObjectRequest put = new PutObjectRequest(
                bucketName,
                projectName,
                uploadStream,
                metadata
        );
        PutObjectResult res = s3.putObject(put);
    }

    @Override
    public InputStream openDownloadStream(String projectName) {
        GetObjectRequest get = new GetObjectRequest(
                bucketName,
                projectName
        );
        S3Object res = s3.getObject(get);
        return res.getObjectContent();
    }

    @Override
    public void remove(String projectName) {
        DeleteObjectRequest del = new DeleteObjectRequest(
                bucketName,
                projectName
        );
        s3.deleteObject(del);
    }

} public class CandidateSnapshot implements AutoCloseable {

    private final String projectName;
    private final int currentVersion;
    private final List<ServletFile> files;
    private final List<String> deleted;
    private File attsDirectory;

    public CandidateSnapshot(
            String projectName,
            int currentVersion,
            RawDirectory directoryContents,
            RawDirectory oldDirectoryContents
    ) {
        this.projectName = projectName;
        this.currentVersion = currentVersion;
        files = diff(directoryContents, oldDirectoryContents);
        deleted = deleted(directoryContents, oldDirectoryContents);
    }

    private List<ServletFile> diff(
            RawDirectory directoryContents,
            RawDirectory oldDirectoryContents
    ) {
        List<ServletFile> files = new LinkedList<ServletFile>();
        Map<String, RawFile> fileTable = directoryContents.getFileTable();
        Map<String, RawFile> oldFileTable = oldDirectoryContents.getFileTable();
        for (Entry<String, RawFile> entry : fileTable.entrySet()) {
            RawFile file = entry.getValue();
            files.add(new ServletFile(file, oldFileTable.get(file.getPath())));
        }
        return files;
    }

    private List<String> deleted(
            RawDirectory directoryContents,
            RawDirectory oldDirectoryContents
    ) {
        List<String> deleted = new LinkedList<String>();
        Map<String, RawFile> fileTable = directoryContents.getFileTable();
        for (
                Entry<String, RawFile> entry :
                oldDirectoryContents.getFileTable().entrySet()
        ) {
            String path = entry.getKey();
            RawFile newFile = fileTable.get(path);
            if (newFile == null) {
                deleted.add(path);
            }
        }
        return deleted;
    }

    public void writeServletFiles(File rootGitDirectory) throws IOException {
        attsDirectory = new File(
                rootGitDirectory,
                ".wlgb/atts/" + projectName
        );
        for (ServletFile file : files) {
            if (file.isChanged()) {
                file.writeToDisk(attsDirectory);
            }
        }
    }

    public void deleteServletFiles() throws IOException {
        if (attsDirectory != null) {
            Util.deleteDirectory(attsDirectory);
        }
    }

    public JsonElement getJsonRepresentation(String postbackKey) {
        String projectURL = Util.getPostbackURL() + "api/" + projectName;
        JsonObject jsonObject = new JsonObject();
        jsonObject.addProperty("latestVerId", currentVersion);
        jsonObject.add("files", getFilesAsJson(projectURL, postbackKey));
        jsonObject.addProperty(
                "postbackUrl", projectURL + "/" + postbackKey + "/postback"
        );
        return jsonObject;
    }

    private JsonArray getFilesAsJson(String projectURL, String postbackKey) {
        JsonArray filesArray = new JsonArray();
        for (ServletFile file : files) {
            filesArray.add(getFileAsJson(file, projectURL, postbackKey));
        }
        return filesArray;
    }

    private JsonObject getFileAsJson(
            ServletFile file,
            String projectURL,
            String postbackKey
    ) {
        JsonObject jsonFile = new JsonObject();
        jsonFile.addProperty("name", file.getPath());
        if (file.isChanged()) {
            jsonFile.addProperty(
                    "url",
                    projectURL + "/" + file.getPath() + "?key=" + postbackKey
            );
        }
        return jsonFile;
    }

    public String getProjectName() {
        return projectName;
    }

    public List<String> getDeleted() {
        return deleted;
    }

    @Override
    public String toString() {
        StringBuilder sb = new StringBuilder();
        sb.append("VersionId: ");
        sb.append(currentVersion);
        sb.append(", files: ");
        sb.append(files);
        sb.append(", deleted: ");
        sb.append(deleted);
        return sb.toString();
    }

    @Override
    public void close() throws IOException {
        deleteServletFiles();
    }

} public class ProjectLockImpl implements ProjectLock {

    private final Map<String, Lock> projectLocks;
    private final ReentrantReadWriteLock rwlock;
    private final Lock rlock;
    private final ReentrantReadWriteLock.WriteLock wlock;
    private LockAllWaiter waiter;
    private boolean waiting;

    public ProjectLockImpl() {
        projectLocks = new HashMap<String, Lock>();
        rwlock = new ReentrantReadWriteLock();
        rlock = rwlock.readLock();
        wlock = rwlock.writeLock();
        waiting = false;
    }

    public ProjectLockImpl(LockAllWaiter waiter) {
        this();
        setWaiter(waiter);
    }

    @Override
    public void lockForProject(String projectName) {
        getLockForProjectName(projectName).lock();
        rlock.lock();
    }

    @Override
    public void unlockForProject(String projectName) {
        getLockForProjectName(projectName).unlock();
        rlock.unlock();
        if (waiting) {
            trySignal();
        }
    }

    private void trySignal() {
        int threads = rwlock.getReadLockCount();
        if (waiter != null && threads > 0) {
            waiter.threadsRemaining(threads);
        }
    }

    public void lockAll() {
        waiting = true;
        trySignal();
        wlock.lock();
    }

    private synchronized Lock getLockForProjectName(String projectName) {
        Lock lock = projectLocks.get(projectName);
        if (lock == null) {
            lock = new ReentrantLock();
            projectLocks.put(projectName, lock);
        }
        return lock;
    }

    public void setWaiter(LockAllWaiter waiter) {
        this.waiter = waiter;
    }

} public class GitDirectoryContents {

    private final List<RawFile> files;
    private final File gitDirectory;
    private final String userName;
    private final String userEmail;
    private final String commitMessage;
    private final Date when;

    public GitDirectoryContents(
            List<RawFile> files,
            File rootGitDirectory,
            String projectName,
            String userName,
            String userEmail,
            String commitMessage,
            Date when
    ) {
        this.files = files;
        this.gitDirectory = new File(rootGitDirectory, projectName);
        this.userName = userName;
        this.userEmail = userEmail;
        this.commitMessage = commitMessage;
        this.when = when;
    }

    public GitDirectoryContents(
            List<RawFile> files,
            File rootGitDirectory,
            String projectName,
            Snapshot snapshot
    ) {
        this(
                files,
                rootGitDirectory,
                projectName,
                snapshot.getUserName(),
                snapshot.getUserEmail(),
                snapshot.getComment(),
                snapshot.getCreatedAt()
        );
    }

    public void write() throws IOException {
        Util.deleteInDirectoryApartFrom(gitDirectory, ".git");
        for (RawFile fileNode : files) {
            fileNode.writeToDisk(gitDirectory);
        }
    }

    public File getDirectory() {
        return gitDirectory;
    }

    public String getUserName() {
        return userName;
    }

    public String getUserEmail() {
        return userEmail;
    }

    public String getCommitMessage() {
        return commitMessage;
    }

    public Date getWhen() {
        return when;
    }

} public abstract class RawFile {

    public abstract String getPath();

    public abstract byte[] getContents();

    public abstract long size();

    public final void writeToDisk(File directory) throws IOException {
        File file = new File(directory, getPath());
        file.getParentFile().mkdirs();
        file.createNewFile();
        OutputStream out = new FileOutputStream(file);
        out.write(getContents());
        out.close();
        Log.info("Wrote file: {}", file.getAbsolutePath());
    }

    @Override
    public boolean equals(Object obj) {
        if (!(obj instanceof RawFile)) {
            return false;
        }
        RawFile that = (RawFile) obj;
        return getPath().equals(that.getPath())
                && Arrays.equals(getContents(), that.getContents());
    }

} public class SizeLimitExceededException extends GitUserException {

    private final Optional<String> path;

    private final long actualSize;

    private final long maxSize;

    public SizeLimitExceededException(
            Optional<String> path, long actualSize, long maxSize) {
        this.path = path;
        this.actualSize = actualSize;
        this.maxSize = maxSize;
    }

    @Override
    public String getMessage() {
        return "file too big";
    }

    @Override
    public List<String> getDescriptionLines() {
        String filename =
                path.isPresent() ? "File '" + path + "' is" : "There's a file";
        return Arrays.asList(
                filename + " too large to push to "
                        + Util.getServiceName() + " via git",
                        "the recommended maximum file size is 50 MiB"
        );
    }

} public class WLReceivePackFactory
        implements ReceivePackFactory<HttpServletRequest> {

    private final RepoStore repoStore;

    private final Bridge bridge;

    public WLReceivePackFactory(RepoStore repoStore, Bridge bridge) {
        this.repoStore = repoStore;
        this.bridge = bridge;
    }

    /**
     * Puts a {@link WriteLatexPutHook} into the returned {@link ReceivePack}.
     *
     * The {@link WriteLatexPutHook} needs our hostname, which we get from the
     * original {@link HttpServletRequest}, used to provide a postback URL to
     * the {@link SnapshotApi}. We also give it the oauth2 that we injected in
     * the {@link Oauth2Filter}, and the {@link Bridge}.
     *
     * At this point, the repository will have been synced to the latest on
     * Overleaf, but it's possible that an update happens on Overleaf while our
     * put hook is running. In this case, we fail, and the user tries again,
     * triggering another sync, and so on.
     * @param httpServletRequest the original request
     * @param repository the JGit {@link Repository} provided by
     * {@link WLRepositoryResolver}
     * @return a correctly hooked {@link ReceivePack}
     */
    @Override
    public ReceivePack create(
            HttpServletRequest httpServletRequest,
            Repository repository
    ) {
        Log.info(
                "[{}] Creating receive-pack",
                repository.getWorkTree().getName()
        );
        Optional<Credential> oauth2 = Optional.ofNullable(
                (Credential) httpServletRequest.getAttribute(
                        Oauth2Filter.ATTRIBUTE_KEY));
        ReceivePack receivePack = new ReceivePack(repository);
        String hostname = Util.getPostbackURL();
        if (hostname == null) {
            hostname = httpServletRequest.getLocalName();
        }
        receivePack.setPreReceiveHook(
                new WriteLatexPutHook(repoStore, bridge, hostname, oauth2)
        );
        return receivePack;
    }

} public class WLRepositoryResolver
        implements RepositoryResolver<HttpServletRequest> {

    private final Bridge bridge;

    public WLRepositoryResolver(Bridge bridge) {
        this.bridge = bridge;
    }

    /**
     * Calls into the Bridge to resolve a project name to a JGit
     * {@link Repository}, or throw an exception.
     *
     * On success, the repository will have been brought onto disk and updated
     * to the latest (synced).
     *
     * In the case of clones and fetches, upload packs are created from the
     * returned JGit {@link Repository} by the {@link WLUploadPackFactory}.
     *
     * The project lock is acquired for this process so it can't be swapped out.
     *
     * However, it can still be swapped out between this and a Git push. The
     * push would fail due to the project changed on Overleaf between the sync
     * and the actual push to Overleaf (performed by the
     * {@link WLReceivePackFactory} and {@link WriteLatexPutHook}. In this case,
     * the user will have to try again (which prompts another update, etc. until
     * this no longer happens).
     * @param httpServletRequest The HttpServletRequest as required by the
     * interface. We injected the oauth2 creds into it with
     * {@link Oauth2Filter}, which was set up by the {@link GitBridgeServer}.
     * @param name The name of the project
     * @return the JGit {@link Repository}.
     * @throws RepositoryNotFoundException If the project does not exist
     * @throws ServiceNotAuthorizedException If the user did not auth when
     * required to
     * @throws ServiceMayNotContinueException If any other general user
     * exception occurs that must be propogated back to the user, e.g.
     * internal errors (IOException, etc), too large file, and so on.
     */
    @Override
    public Repository open(
            HttpServletRequest httpServletRequest,
            String name
    ) throws RepositoryNotFoundException,
             ServiceNotAuthorizedException,
             ServiceMayNotContinueException {
        Log.info("[{}] Request to open git repo", name);
        Optional<Credential> oauth2 = Optional.ofNullable(
                (Credential) httpServletRequest.getAttribute(
                        Oauth2Filter.ATTRIBUTE_KEY));
        String projName = Util.removeAllSuffixes(name, "/", ".git");
        try {
            return bridge.getUpdatedRepo(oauth2, projName).getJGitRepository();
        } catch (RepositoryNotFoundException e) {
            Log.info("Repository not found: " + name);
            throw e;
            /*
        } catch (ServiceNotAuthorizedException e) {
            cannot occur
        } catch (ServiceNotEnabledException e) {
            cannot occur
            */
        } catch (ServiceMayNotContinueException e) {
            /* Such as FailedConnectionException */
            throw e;
        } catch (RuntimeException e) {
            Log.warn(
                    "Runtime exception when trying to open repo: " + projName,
                    e
            );
            throw new ServiceMayNotContinueException(e);
        } catch (ForbiddenException e) {
            throw new ServiceNotAuthorizedException();
        } catch (GitUserException e) {
            throw new ServiceMayNotContinueException(e.getMessage(), e);
        } catch (IOException e) {
            Log.warn(
                    "IOException when trying to open repo: " + projName,
                    e
            );
            throw new ServiceMayNotContinueException("Internal server error.");
        }
    }

} public class WLUploadPackFactory
        implements UploadPackFactory<HttpServletRequest> {

    /**
     * This does nothing special. Synchronising the project with Overleaf will
     * have been performed by {@link WLRepositoryResolver}.
     * @param __ Not used, required by the {@link UploadPackFactory} interface
     * @param repository The JGit repository provided by the
     * {@link WLRepositoryResolver}
     * @return the {@link UploadPack}, used by JGit to serve the request
     */
    @Override
    public UploadPack create(
            HttpServletRequest __,
            Repository repository
    ) {
        Log.info(
                "[{}] Creating upload-pack",
                repository.getWorkTree().getName()
        );
       return new UploadPack(repository);
    }

} public class WriteLatexPutHook implements PreReceiveHook {

    private final RepoStore repoStore;

    private final Bridge bridge;

    private final String hostname;

    private final Optional<Credential> oauth2;

    /**
     * The constructor to use, which provides the hook with the {@link Bridge},
     * the hostname (used to construct a URL to give to Overleaf to postback),
     * and the oauth2 (used to authenticate with the Snapshot API).
     * @param repoStore
     * @param bridge the {@link Bridge}
     * @param hostname the hostname used for postback from the Snapshot API
     * @param oauth2 used to authenticate with the snapshot API, or null
     */
    public WriteLatexPutHook(
            RepoStore repoStore,
            Bridge bridge,
            String hostname,
            Optional<Credential> oauth2
    ) {
        this.repoStore = repoStore;
        this.bridge = bridge;
        this.hostname = hostname;
        this.oauth2 = oauth2;
    }

    @Override
    public void onPreReceive(
            ReceivePack receivePack,
            Collection<ReceiveCommand> receiveCommands
    ) {
        for (ReceiveCommand receiveCommand : receiveCommands) {
            try {
                handleReceiveCommand(
                        oauth2,
                        receivePack.getRepository(),
                        receiveCommand
                );
            } catch (IOException e) {
                receivePack.sendError(e.getMessage());
                receiveCommand.setResult(
                        Result.REJECTED_OTHER_REASON,
                        e.getMessage()
                );
            } catch (OutOfDateException e) {
                receiveCommand.setResult(Result.REJECTED_NONFASTFORWARD);
            } catch (GitUserException e) {
                handleSnapshotPostException(receivePack, receiveCommand, e);
            } catch (Throwable t) {
                Log.warn("Throwable on pre receive: ", t);
                handleSnapshotPostException(
                        receivePack,
                        receiveCommand,
                        new InternalErrorException()
                );
            }
        }
    }

    private void handleSnapshotPostException(
            ReceivePack receivePack,
            ReceiveCommand receiveCommand,
            GitUserException e
    ) {
        String message = e.getMessage();
        receivePack.sendError(message);
        StringBuilder msg = new StringBuilder();
        for (
                Iterator<String> it = e.getDescriptionLines().iterator();
                it.hasNext();
        ) {
            String line = it.next();
            msg.append("hint: ");
            msg.append(line);
            if (it.hasNext()) {
                msg.append('\n');
            }
        }
        receivePack.sendMessage("");
        receivePack.sendMessage(msg.toString());
        receiveCommand.setResult(Result.REJECTED_OTHER_REASON, message);
    }

    private void handleReceiveCommand(
            Optional<Credential> oauth2,
            Repository repository,
            ReceiveCommand receiveCommand
    ) throws IOException, GitUserException {
        checkBranch(receiveCommand);
        checkForcedPush(receiveCommand);
        bridge.push(
                oauth2,
                repository.getWorkTree().getName(),
                getPushedDirectoryContents(repository, receiveCommand),
                getOldDirectoryContents(repository),
                hostname
        );
    }

    private void checkBranch(
            ReceiveCommand receiveCommand
    ) throws WrongBranchException {
        if (!receiveCommand.getRefName().equals("refs/heads/master")) {
            throw new WrongBranchException();
        }
    }

    private void checkForcedPush(
            ReceiveCommand receiveCommand
    ) throws ForcedPushException {
        if (
                receiveCommand.getType()
                        == ReceiveCommand.Type.UPDATE_NONFASTFORWARD
        ) {
            throw new ForcedPushException();
        }
    }

    private RawDirectory getPushedDirectoryContents(
            Repository repository,
            ReceiveCommand receiveCommand
    ) throws IOException, GitUserException {
        return repoStore
                .useJGitRepo(repository, receiveCommand.getNewId())
                .getDirectory();
    }

    private RawDirectory getOldDirectoryContents(
            Repository repository
    ) throws IOException, GitUserException {
        return repoStore
                .useJGitRepo(repository, repository.resolve("HEAD"))
                .getDirectory();
    }

} public class FileHandler extends ResourceHandler {
    private static final Logger LOG
            = LoggerFactory.getLogger(FileHandler.class);

    private final Bridge bridge;
    private final Pattern DOC_KEY_PATTERN = Pattern.compile("^/(\\w+)/.+$");

    public FileHandler(Bridge bridge) {
        this.bridge = bridge;
    }

    @Override
    public void handle(
            String target,
            Request baseRequest,
            HttpServletRequest request,
            HttpServletResponse response
    ) throws IOException, ServletException {
        if (!"GET".equals(baseRequest.getMethod())) return;
        LOG.info("GET <- {}", baseRequest.getRequestURI());

        Matcher docKeyMatcher = DOC_KEY_PATTERN.matcher(target);
        if (!docKeyMatcher.matches()) return;
        String docKey = docKeyMatcher.group(1);

        String apiKey = request.getParameter("key");
        if (apiKey == null) return;

        try {
            bridge.checkPostbackKey(docKey, apiKey);
        } catch (InvalidPostbackKeyException e) {
            LOG.warn(
                    "INVALID POST BACK KEY: docKey={} apiKey={}",
                    docKey,
                    apiKey
            );
            return;
        }

        super.handle(target, baseRequest, request, response);
    }

} public class ForbiddenException extends SnapshotAPIException {

    @Override
    public void fromJSON(JsonElement json) {}

    @Override
    public String getMessage() {
        return "forbidden";
    }

    @Override
    public List<String> getDescriptionLines() {
        return Arrays.asList(getMessage());
    }

} public class SnapshotAttachment implements JSONSource {

    private String url;
    private String path;

    public SnapshotAttachment(JsonElement json) {
        fromJSON(json);
    }

    @Override
    public String toString() {
        return "SnapshotAttachment(url: " + url + ", path: " + path + ")";
    }

    @Override
    public void fromJSON(JsonElement json) {
        JsonArray jsonArray = json.getAsJsonArray();
        url = jsonArray.get(0).getAsString();
        path = jsonArray.get(1).getAsString();
    }

    public String getUrl() {
        return url;
    }

    public String getPath() {
        return path;
    }

    /* For the Mock Snapshot server */

    public SnapshotAttachment(String url, String path) {
        this.url = url;
        this.path = path;
    }

    public JsonElement toJson() {
        JsonArray jsonThis = new JsonArray();
        jsonThis.add(new JsonPrimitive(url));
        jsonThis.add(new JsonPrimitive(getPath()));
        return jsonThis;
    }

} public class PostbackPromise {

    private final String postbackKey;
    private final ReentrantLock lock;
    private final Condition cond;

    private boolean received;
    private int versionID;
    private SnapshotPostException exception;

    public PostbackPromise(String postbackKey) {
        this.postbackKey = postbackKey;
        lock = new ReentrantLock();
        cond = lock.newCondition();
        received = false;
        exception = null;
    }

    public int waitForPostback() throws SnapshotPostException {
        lock.lock();
        try {
            while (!received) {
                try {
                    if (!cond.await(60, TimeUnit.SECONDS)) {
                        throw new PostbackTimeoutException();
                    }
                } catch (InterruptedException e) {
                    throw new InternalErrorException();
                }
            }
            if (exception != null) {
                throw exception;
            }
            return versionID;
        } finally {
            lock.unlock();
        }
    }

    public void receivedVersionID(int versionID, String postbackKey) {
        lock.lock();
        try {
            if (postbackKey.equals(this.postbackKey)) {
                this.versionID = versionID;
                received = true;
                cond.signalAll();
            }
        } finally {
            lock.unlock();
        }
    }

    public void receivedException(
            SnapshotPostException exception,
            String postbackKey
    ) {
        lock.lock();
        try {
            if (postbackKey.equals(this.postbackKey)) {
                this.exception = exception;
                received = true;
                cond.signalAll();
            }
        } finally {
            lock.unlock();
        }
    }

    public void checkPostbackKey(
            String postbackKey
    ) throws InvalidPostbackKeyException {
        if (!postbackKey.equals(this.postbackKey)) {
            throw new InvalidPostbackKeyException();
        }
    }

} public class Log {

    private static Logger logger = LoggerFactory.getLogger(GitBridgeApp.class);

    public static void trace(String msg) {
        logger.trace(msg);
    }

    public static void trace(String msg, Throwable t) {
        logger.trace(msg, t);
    }

    public static void debug(String msg) {
        logger.debug(msg);
    }

    public static void debug(String msg, Throwable t) {
        logger.debug(msg, t);
    }

    public static void info(String msg) {
        logger.info(msg);
    }

    public static void info(String format, Object arg) {
        logger.info(format, arg);
    }

    public static void info(String format, Object arg1, Object arg2) {
        logger.info(format, arg1, arg2);
    }

    public static void info(String format, Object... args) {
        logger.info(format, args);
    }

    public static void info(String msg, Throwable t) {
        logger.info(msg, t);
    }

    public static void warn(String msg) {
        logger.warn(msg);
    }

    public static void warn(String msg, Object arg) {
        logger.warn(msg, arg);
    }

    public static void warn(String msg, Object arg1, Object arg2) {
        logger.warn(msg, arg1, arg2);
    }

    public static void warn(String msg, Object... args) {
        logger.warn(msg, args);
    }

    public static void warn(String msg, Throwable t) {
        logger.warn(msg, t);
    }

    public static void error(String msg) {
        logger.error(msg);
    }

    public static void error(String msg, Object... args) {
        logger.error(msg, args);
    }

    public static void error(String msg, Throwable t) {
        logger.error(msg, t);
    }

}
import com.google.api.client.auth.oauth2.Credential;
import org.eclipse.jgit.errors.RepositoryNotFoundException;
import uk.ac.ic.wlgitbridge.application.config.Config;
import uk.ac.ic.wlgitbridge.bridge.db.DBStore;
import uk.ac.ic.wlgitbridge.bridge.db.ProjectState;
import uk.ac.ic.wlgitbridge.bridge.db.sqlite.SqliteDBStore;
import uk.ac.ic.wlgitbridge.bridge.gc.GcJob;
import uk.ac.ic.wlgitbridge.bridge.gc.GcJobImpl;
import uk.ac.ic.wlgitbridge.bridge.lock.LockGuard;
import uk.ac.ic.wlgitbridge.bridge.lock.ProjectLock;
import uk.ac.ic.wlgitbridge.bridge.repo.*;
import uk.ac.ic.wlgitbridge.bridge.resource.ResourceCache;
import uk.ac.ic.wlgitbridge.bridge.resource.UrlResourceCache;
import uk.ac.ic.wlgitbridge.bridge.snapshot.NetSnapshotApi;
import uk.ac.ic.wlgitbridge.bridge.snapshot.SnapshotApi;
import uk.ac.ic.wlgitbridge.bridge.snapshot.SnapshotApiFacade;
import uk.ac.ic.wlgitbridge.bridge.swap.job.SwapJob;
import uk.ac.ic.wlgitbridge.bridge.swap.job.SwapJobImpl;
import uk.ac.ic.wlgitbridge.bridge.swap.store.S3SwapStore;
import uk.ac.ic.wlgitbridge.bridge.swap.store.SwapStore;
import uk.ac.ic.wlgitbridge.data.CandidateSnapshot;
import uk.ac.ic.wlgitbridge.data.ProjectLockImpl;
import uk.ac.ic.wlgitbridge.data.filestore.GitDirectoryContents;
import uk.ac.ic.wlgitbridge.data.filestore.RawDirectory;
import uk.ac.ic.wlgitbridge.data.filestore.RawFile;
import uk.ac.ic.wlgitbridge.data.model.Snapshot;
import uk.ac.ic.wlgitbridge.git.exception.GitUserException;
import uk.ac.ic.wlgitbridge.git.exception.SizeLimitExceededException;
import uk.ac.ic.wlgitbridge.git.handler.WLReceivePackFactory;
import uk.ac.ic.wlgitbridge.git.handler.WLRepositoryResolver;
import uk.ac.ic.wlgitbridge.git.handler.WLUploadPackFactory;
import uk.ac.ic.wlgitbridge.git.handler.hook.WriteLatexPutHook;
import uk.ac.ic.wlgitbridge.server.FileHandler;
import uk.ac.ic.wlgitbridge.server.PostbackContents;
import uk.ac.ic.wlgitbridge.server.PostbackHandler;
import uk.ac.ic.wlgitbridge.snapshot.base.ForbiddenException;
import uk.ac.ic.wlgitbridge.snapshot.getforversion.SnapshotAttachment;
import uk.ac.ic.wlgitbridge.snapshot.push.PostbackManager;
import uk.ac.ic.wlgitbridge.snapshot.push.PostbackPromise;
import uk.ac.ic.wlgitbridge.snapshot.push.PushResult;
import uk.ac.ic.wlgitbridge.snapshot.push.exception.*;
import uk.ac.ic.wlgitbridge.util.Log;
import java.io.File;
import java.io.IOException;
import java.sql.Timestamp;
import java.time.LocalDateTime;
import java.util.*;
package uk.ac.ic.wlgitbridge.bridge;



/**
 * This is the heart of the Git Bridge. You plug in all the parts (project
 * lock, repo store, db store, swap store, snapshot api, resource cache and
 * postback manager) is called by Git user requests and Overleaf postback
 * requests.
 *
 * Follow these links to go "outward" (to input from Git users and Overleaf):
 *
 * 1. JGit hooks, which handle user Git requests:
 *
 *    @see WLRepositoryResolver - used on all requests associate a repo with a
 *                                project name, or fail
 *
 *    @see WLUploadPackFactory - used to handle clones and fetches
 *
 *    @see WLReceivePackFactory - used to handle pushes by setting a hook
 *    @see WriteLatexPutHook - the hook used to handle pushes
 *
 * 2. The Postback Servlet, which handles postbacks from the Overleaf app
 *    to confirm that a project is pushed. If a postback is lost, it's fine, we
 *    just update ourselves on the next access.
 *
 *    @see PostbackHandler - the entry point for postbacks
 *
 * Follow these links to go "inward" (to the Git Bridge components):
 *
 * 1. The Project Lock, used to synchronise accesses to projects and shutdown
 *    the Git Bridge gracefully by preventing further lock acquiring.
 *
 *    @see ProjectLock - the interface used for the Project Lock
 *    @see ProjectLockImpl - the default concrete implementation
 *
 * 2. The Repo Store, used to provide repository objects.
 *
 *    The default implementation uses Git on the file system.
 *
 *    @see RepoStore - the interface for the Repo Store
 *    @see FSGitRepoStore - the default concrete implementation
 *    @see ProjectRepo - an interface for an actual repo instance
 *    @see GitProjectRepo - the default concrete implementation
 *
 * 3. The DB Store, used to store persistent data such as the latest version
 *    of each project that we have (used for querying the Snapshot API), along
 *    with caching remote blobs.
 *
 *    The default implementation is SQLite based.
 *
 *    @see DBStore - the interface for the DB store
 *    @see SqliteDBStore - the default concrete implementation
 *
 * 4. The Swap Store, used to swap projects to when the disk goes over a
 *    certain data usage.
 *
 *    The default implementation tarbzips projects to/from Amazon S3.
 *
 *    @see SwapStore - the interface for the Swap Store
 *    @see S3SwapStore - the default concrete implementation
 *
 * 5. The Swap Job, which performs the actual swapping on the swap store based
 *    on various configuration options.
 *
 *    @see SwapJob - the interface for the Swap Job
 *    @see SwapJobImpl - the default concrete implementation
 *
 * 6. The Snapshot API, which provides data from the Overleaf app.
 *
 *    @see SnapshotApiFacade - wraps a concrete instance of the Snapshot API.
 *    @see SnapshotApi - the interface for the Snapshot API.
 *    @see NetSnapshotApi - the default concrete implementation
 *
 * 7. The Resource Cache, which provides the data for attachment resources from
 *    URLs. It will generally fetch from the source on a cache miss.
 *
 *    The default implementation uses the DB Store to maintain a mapping from
 *    URLs to files in an actual repo.
 *
 *    @see ResourceCache - the interface for the Resource Cache
 *    @see UrlResourceCache - the default concrete implementation
 *
 * 8. The Postback Manager, which keeps track of pending postbacks. It stores a
 *    mapping from project names to postback promises.
 *
 *    @see PostbackManager - the class
 *    @see PostbackPromise - the object waited on for a postback.
 *
 */
public class Bridge {

    private final Config config;

    private final ProjectLock lock;

    private final RepoStore repoStore;
    private final DBStore dbStore;
    private final SwapStore swapStore;
    private final SwapJob swapJob;
    private final GcJob gcJob;

    private final SnapshotApiFacade snapshotAPI;
    private final ResourceCache resourceCache;

    private final PostbackManager postbackManager;

    /**
     * Creates a Bridge from its configurable parts, which are the repo, db and
     * swap store, and the swap job config.
     *
     * This should be the method used to create a Bridge.
     * @param config The config to use
     * @param repoStore The repo store to use
     * @param dbStore The db store to use
     * @param swapStore The swap store to use
     * @param snapshotApi The snapshot api to use
     * @return The constructed Bridge.
     */
    public static Bridge make(
            Config config,
            RepoStore repoStore,
            DBStore dbStore,
            SwapStore swapStore,
            SnapshotApi snapshotApi
    ) {
        ProjectLock lock = new ProjectLockImpl((int threads) ->
                Log.info("Waiting for " + threads + " projects...")