// Java 17+; credentials stay on your server. No Maven or Gradle dependencies.
// Run: java ./scormbox-upload.java /path/to/course.zip
import java.io.ByteArrayInputStream;
import java.io.EOFException;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.math.BigDecimal;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Base64;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;

class ScormboxUpload {
    private final String base_url;
    private final String authorization;
    private final String idempotency_key;
    private final HttpClient client;
    private static final Set<Integer> RETRYABLE = Set.of(409, 429, 500, 502, 503, 504);

    public static void main(String[] args) {
        try {
            if (args.length != 1) {
                var source = System.getProperty("jdk.launcher.sourcefile", "scormbox-upload.java");
                throw new IllegalArgumentException("Usage: java ./" + Path.of(source).getFileName() + " /path/to/course.zip");
            }
            new ScormboxUpload().uploadCourse(Path.of(args[0]));
        } catch (Exception error) {
            if (error instanceof InterruptedException) Thread.currentThread().interrupt();
            System.err.println("Error: " + error.getMessage());
            System.exit(1);
        }
    }

    ScormboxUpload() {
        var api_key = environment("SCORMBOX_API_KEY");
        var api_secret = environment("SCORMBOX_API_SECRET");
        idempotency_key = environment("SCORMBOX_IDEMPOTENCY_KEY");
        if (api_key.isEmpty() || api_secret.isEmpty() || idempotency_key.isEmpty())
            throw new IllegalArgumentException("Set SCORMBOX_API_KEY, SCORMBOX_API_SECRET and a stable SCORMBOX_IDEMPOTENCY_KEY for this content version.");
        var configured_url = environment("SCORMBOX_API_BASE_URL");
        if (configured_url.isEmpty()) configured_url = "https://scormbox.com/api/v1";
        var service = URI.create(configured_url);
        if (service.getHost() == null || !("https".equals(service.getScheme()) ||
            ("http".equals(service.getScheme()) && "localhost".equals(service.getHost()))))
            throw new IllegalArgumentException("Use HTTPS for server credentials, or HTTP localhost for development.");
        if (service.getUserInfo() != null || service.getQuery() != null || service.getFragment() != null)
            throw new IllegalArgumentException("Use an API base URL without credentials, a query or a fragment.");
        while (configured_url.endsWith("/")) configured_url = configured_url.substring(0, configured_url.length() - 1);
        base_url = configured_url;
        authorization = "Basic " + Base64.getEncoder().encodeToString((api_key + ":" + api_secret).getBytes(StandardCharsets.UTF_8));
        client = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NEVER)
            .connectTimeout(Duration.ofMinutes(2)).version(HttpClient.Version.HTTP_1_1).build();
    }

    private static String environment(String name) {
        var value = System.getenv(name);
        return value == null ? "" : value;
    }

    private static String encodePath(String value) {
        return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20");
    }

    private void uploadCourse(Path filename) throws Exception {
        long size = Files.size(filename);
        var properties = new LinkedHashMap<String, Object>();
        properties.put("filename", filename.getFileName().toString());
        properties.put("size", size);
        properties.put("shareable", "true".equals(environment("SCORMBOX_SHAREABLE")));
        var title = environment("SCORMBOX_TITLE");
        if (!title.isEmpty()) properties.put("title", title);
        var course = request("/courses", "POST", properties, idempotency_key, 0);
        var status = string(course, "status");
        if (status.equals("awaiting_upload") || status.equals("uploading")) {
            var upload = object(course.get("upload"));
            var upload_path = "/uploads/" + encodePath(string(upload, "upload_id"));
            int part_size = integer(upload, "part_size"), part_count = integer(upload, "part_count");
            if (part_size <= 0 || part_count <= 0) throw new IllegalArgumentException("Invalid upload part sizes.");
            var saved = new HashSet<Integer>();
            if (!(upload.get("parts") instanceof List<?> parts))
                throw new IllegalArgumentException("Invalid upload receipts.");
            for (var part : parts) saved.add(integer(object(part), "part_number"));
            var chunk = new byte[part_size]; // Reuse one part buffer, including for retries.
            try (var file = new RandomAccessFile(filename.toFile(), "r")) {
                for (int part_number = 1; part_number <= part_count; part_number++) {
                    if (saved.contains(part_number)) continue;
                    long offset = (long) (part_number - 1) * part_size;
                    int length = (int) Math.min(part_size, size - offset);
                    if (length <= 0) throw new IllegalArgumentException("Invalid upload part sizes.");
                    file.seek(offset);
                    try {
                        file.readFully(chunk, 0, length);
                    } catch (EOFException error) {
                        throw new IOException("The package changed while being read.", error);
                    }
                    request(upload_path + "/parts/" + part_number, "PUT", new Part(chunk, length), null, 0);
                    System.out.println("Uploaded part " + part_number + " of " + part_count);
                }
            }
            request(upload_path + "/complete", "POST", Map.of(), null, 0);
        }

        var course_id = string(course, "course_id");
        long deadline = System.nanoTime() + Duration.ofMinutes(20).toNanos();
        for (;;) {
            if (System.nanoTime() >= deadline)
                throw new IOException("Preparation is still running. Check course_id " + course_id + " before trying again.");
            course = request("/courses/" + encodePath(course_id), "GET", null, null, deadline);
            status = string(course, "status");
            if (status.equals("ready")) break;
            if (status.equals("failed")) {
                var error = course.get("error");
                throw new IOException(error == null ? "Package preparation failed." : error.toString());
            }
            var preparation = course.get("preparation") == null ? Map.<String, Object>of() : object(course.get("preparation"));
            System.out.println("Course " + status + ": " + preparation.getOrDefault("completed_files", 0)
                + " of " + preparation.getOrDefault("total_files", 0) + " files");
            pause(2000, deadline);
        }
        var result = new LinkedHashMap<String, Object>();
        for (var field : List.of("course_id", "format", "management_url", "learner_url"))
            result.put(field, course.get(field));
        System.out.println(Json.write(result));
    }

    private record Part(byte[] bytes, int length) { }

    private Map<String, Object> request(String path, String method, Object body, String idempotency, long deadline)
        throws IOException, InterruptedException {
        // Creation has an idempotency key; parts, completion and reads are safe to retry.
        for (int retry = 0; ; retry++) {
            long remaining = deadline == 0 ? Duration.ofMinutes(2).toNanos() : deadline - System.nanoTime();
            if (remaining <= 0) throw new IOException("Preparation timed out; check the course_id before trying again.");
            var timeout = Duration.ofNanos(Math.min(Duration.ofMinutes(2).toNanos(), remaining));
            HttpRequest.BodyPublisher publisher;
            if (body instanceof Part part) {
                // The known length makes HttpClient send exact Content-Length. The stream shares our buffer.
                publisher = HttpRequest.BodyPublishers.fromPublisher(HttpRequest.BodyPublishers.ofInputStream(
                    () -> new ByteArrayInputStream(part.bytes(), 0, part.length())), part.length());
            } else {
                publisher = body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(Json.write(body));
            }
            var operation = HttpRequest.newBuilder(URI.create(base_url + path)).timeout(timeout)
                .header("Authorization", authorization)
                .header("Content-Type", body instanceof Part ? "application/octet-stream" : "application/json")
                .method(method, publisher);
            if (idempotency != null) operation.header("Idempotency-Key", idempotency);
            HttpResponse<String> response;
            try {
                response = send(operation.build(), timeout);
            } catch (IOException error) {
                if (retry == 4) throw new IOException("Request failed after retries: " + error.getMessage(), error);
                pause(1000L << retry, deadline);
                continue;
            }
            int status = response.statusCode();
            if (status >= 300 && status < 400) throw new IOException("HTTP " + status + ": redirects are refused.");
            Map<String, Object> result;
            try {
                result = object(Json.parse(response.body()));
            } catch (IllegalArgumentException error) {
                if (status >= 200 && status < 300) throw new IOException("HTTP " + status + ": invalid JSON response.", error);
                result = Map.of();
            }
            if (status >= 200 && status < 300) return result;
            if (retry == 4 || !RETRYABLE.contains(status))
                throw new IOException("HTTP " + status + ": " + result.getOrDefault("error", "Request failed."));
            pause(1000L << retry, deadline); // Four retries: 1, 2, 4 and 8 seconds.
        }
    }

    private HttpResponse<String> send(HttpRequest operation, Duration timeout) throws IOException, InterruptedException {
        var pending = client.sendAsync(operation, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
        try {
            // Bound the complete response body, as well as connecting and receiving its headers.
            return pending.get(timeout.toNanos(), TimeUnit.NANOSECONDS);
        } catch (TimeoutException error) {
            pending.cancel(true);
            throw new IOException("Request timed out.", error);
        } catch (ExecutionException error) {
            if (error.getCause() instanceof IOException cause) throw cause;
            throw new IllegalStateException("Request failed.", error.getCause());
        } catch (InterruptedException error) {
            pending.cancel(true);
            throw error;
        }
    }

    private static void pause(long milliseconds, long deadline) throws InterruptedException {
        long nanos = TimeUnit.MILLISECONDS.toNanos(milliseconds);
        if (deadline != 0) nanos = Math.min(nanos, Math.max(0, deadline - System.nanoTime()));
        TimeUnit.NANOSECONDS.sleep(nanos);
    }

    @SuppressWarnings("unchecked")
    private static Map<String, Object> object(Object value) {
        if (!(value instanceof Map<?, ?>)) throw new IllegalArgumentException("Expected a JSON object.");
        return (Map<String, Object>) value;
    }

    private static String string(Map<String, Object> value, String field) {
        if (!(value.get(field) instanceof String result)) throw new IllegalArgumentException("Invalid response field: " + field);
        return result;
    }

    private static int integer(Map<String, Object> value, String field) {
        if (!(value.get(field) instanceof BigDecimal result)) throw new IllegalArgumentException("Invalid response field: " + field);
        return result.intValueExact();
    }

    // A small JSON reader/writer for API responses and requests; no external JSON library is needed.
    private static final class Json {
        private final String source;
        private int position;

        private Json(String source) { this.source = source; }

        static Object parse(String source) {
            var reader = new Json(source);
            var result = reader.value(0);
            reader.whitespace();
            if (reader.position != source.length()) throw reader.invalid();
            return result;
        }

        private Object value(int depth) {
            if (depth > 128) throw invalid();
            whitespace();
            if (position >= source.length()) throw invalid();
            char next = source.charAt(position);
            if (next == '"') return text();
            if (next == '{') {
                position++;
                var result = new LinkedHashMap<String, Object>();
                whitespace();
                if (consume('}')) return result;
                for (;;) {
                    whitespace();
                    var key = text();
                    whitespace();
                    require(':');
                    result.put(key, value(depth + 1));
                    whitespace();
                    if (consume('}')) return result;
                    require(',');
                }
            }
            if (next == '[') {
                position++;
                var result = new ArrayList<Object>();
                whitespace();
                if (consume(']')) return result;
                for (;;) {
                    result.add(value(depth + 1));
                    whitespace();
                    if (consume(']')) return result;
                    require(',');
                }
            }
            if (next == 't') return literal("true", true);
            if (next == 'f') return literal("false", false);
            if (next == 'n') return literal("null", null);
            return number();
        }

        private Object literal(String text, Object result) {
            if (!source.startsWith(text, position)) throw invalid();
            position += text.length();
            return result;
        }

        private BigDecimal number() {
            int start = position;
            consume('-');
            if (!consume('0')) {
                if (position >= source.length() || source.charAt(position) < '1' || source.charAt(position) > '9') throw invalid();
                digits();
            }
            if (consume('.')) digits();
            if (consume('e') || consume('E')) {
                if (!consume('+')) consume('-');
                digits();
            }
            try { return new BigDecimal(source.substring(start, position)); }
            catch (NumberFormatException error) { throw invalid(); }
        }

        private void digits() {
            int start = position;
            while (position < source.length() && source.charAt(position) >= '0' && source.charAt(position) <= '9') position++;
            if (start == position) throw invalid();
        }

        private String text() {
            require('"');
            var result = new StringBuilder();
            while (position < source.length()) {
                char character = source.charAt(position++);
                if (character == '"') return result.toString();
                if (character < 0x20) throw invalid();
                if (character != '\\') { result.append(character); continue; }
                if (position >= source.length()) throw invalid();
                char escape = source.charAt(position++);
                switch (escape) {
                    case '"', '\\', '/' -> result.append(escape);
                    case 'b' -> result.append('\b');
                    case 'f' -> result.append('\f');
                    case 'n' -> result.append('\n');
                    case 'r' -> result.append('\r');
                    case 't' -> result.append('\t');
                    case 'u' -> {
                        if (position + 4 > source.length()) throw invalid();
                        int code = 0;
                        for (int count = 0; count < 4; count++) {
                            char digit = source.charAt(position++);
                            int hex = digit >= '0' && digit <= '9' ? digit - '0'
                                : digit >= 'a' && digit <= 'f' ? digit - 'a' + 10
                                : digit >= 'A' && digit <= 'F' ? digit - 'A' + 10 : -1;
                            if (hex < 0) throw invalid();
                            code = code * 16 + hex;
                        }
                        result.append((char) code);
                    }
                    default -> throw invalid();
                }
            }
            throw invalid();
        }

        private void whitespace() {
            while (position < source.length() && " \t\r\n".indexOf(source.charAt(position)) >= 0) position++;
        }

        private boolean consume(char expected) {
            if (position < source.length() && source.charAt(position) == expected) { position++; return true; }
            return false;
        }

        private void require(char expected) { if (!consume(expected)) throw invalid(); }
        private IllegalArgumentException invalid() { return new IllegalArgumentException("Invalid JSON at character " + position); }

        static String write(Object value) {
            if (value == null) return "null";
            if (value instanceof Boolean || value instanceof Number) {
                var result = value.toString();
                if (value instanceof Number) new BigDecimal(result); // Reject NaN and infinity.
                return result;
            }
            if (value instanceof String text) {
                var result = new StringBuilder("\"");
                for (int index = 0; index < text.length(); index++) {
                    char character = text.charAt(index);
                    switch (character) {
                        case '"' -> result.append("\\\"");
                        case '\\' -> result.append("\\\\");
                        case '\b' -> result.append("\\b");
                        case '\f' -> result.append("\\f");
                        case '\n' -> result.append("\\n");
                        case '\r' -> result.append("\\r");
                        case '\t' -> result.append("\\t");
                        default -> {
                            if (character < 0x20 || Character.isSurrogate(character)) result.append(String.format("\\u%04x", (int) character));
                            else result.append(character);
                        }
                    }
                }
                return result.append('"').toString();
            }
            if (value instanceof Map<?, ?> map) {
                var result = new StringBuilder("{");
                for (var entry : map.entrySet()) {
                    if (!(entry.getKey() instanceof String)) throw new IllegalArgumentException("JSON object keys must be strings.");
                    if (result.length() > 1) result.append(',');
                    result.append(write(entry.getKey())).append(':').append(write(entry.getValue()));
                }
                return result.append('}').toString();
            }
            if (value instanceof List<?> list) {
                var result = new StringBuilder("[");
                for (var entry : list) {
                    if (result.length() > 1) result.append(',');
                    result.append(write(entry));
                }
                return result.append(']').toString();
            }
            throw new IllegalArgumentException("Unsupported JSON value.");
        }
    }
}
