#!/usr/bin/env -S java --source 25

import module java.base;
import module java.net.http;

String name = MethodHandles.lookup().lookupClass().getName();
String version = "2026-06-21.2";

/// Git-free counterpart to `git clone`: fetches the airails.dev repository into
/// `./airails` so the interactive `./installSkills` can take over from there.
/// Mirrors the one-liner ergonomics of zsmith's `zsinstall`, but because airails
/// ships skill *directories* (not a single released JAR) it unpacks the whole
/// repository archive instead of a lone artifact.
///
/// ```
/// curl -fsSL https://raw.githubusercontent.com/AdamBien/airails/main/downloadSkills | java --source 25 /dev/stdin
/// cd airails
/// ./installSkills
/// ```
///
/// Install logic lives in `installSkills` alone — this script only obtains the
/// sources, leaving the prompts, listing, and deletion to the single installer.
String archiveUrl = "https://codeload.github.com/AdamBien/airails/zip/refs/heads/main";
Path output = Path.of("airails");

void main(String... args) throws Exception {
    IO.println("%s %s".formatted(name, version));
    if (Files.exists(output)) {
        System.err.println("%s: %s already exists — remove it and retry".formatted(name, output.toAbsolutePath()));
        System.exit(1);
    }
    var work = Files.createTempDirectory(name + "-");
    try {
        var zip = download(archiveUrl, work.resolve("airails.zip"));
        unzipInto(zip, output);
        restoreExecutableBits(output);
        IO.println("%s: extracted to %s".formatted(name, output.toAbsolutePath()));
        IO.println("%s: next — cd %s && ./installSkills".formatted(name, output));
    } finally {
        deleteRecursively(work);
    }
}

/// Atomic-staged download: the destination either keeps its prior content or
/// becomes the complete archive, never a truncated stream. Exit-on-failure on
/// non-200 — a single-file script has no caller to unwind to.
Path download(String url, Path destination) throws Exception {
    IO.println("%s: fetching %s".formatted(name, url));
    var staging = Files.createTempFile(destination.getParent(), destination.getFileName() + "-", ".part");
    var client = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.ALWAYS).build();
    var request = HttpRequest.newBuilder(URI.create(url)).build();
    var response = client.send(request, HttpResponse.BodyHandlers.ofFile(staging));
    if (response.statusCode() != HttpURLConnection.HTTP_OK) {
        Files.deleteIfExists(staging);
        System.err.println("%s: download failed (HTTP %d)".formatted(name, response.statusCode()));
        System.exit(1);
    }
    Files.move(staging, destination, StandardCopyOption.REPLACE_EXISTING);
    return destination;
}

/// Unpacks the GitHub archive into `target`, stripping the single leading
/// `airails-main/` component GitHub wraps every entry in, so `target` mirrors
/// the repository root rather than nesting it. Guards against Zip-Slip by
/// rejecting any entry that would resolve outside `target`.
void unzipInto(Path zip, Path target) throws IOException {
    var base = target.toAbsolutePath().normalize();
    try (var in = new ZipInputStream(Files.newInputStream(zip))) {
        for (var entry = in.getNextEntry(); entry != null; entry = in.getNextEntry()) {
            var stripped = strip(entry.getName());
            if (stripped.isEmpty()) {
                continue;
            }
            var resolved = base.resolve(stripped).normalize();
            if (!resolved.startsWith(base)) {
                throw new IOException("blocked path traversal: " + entry.getName());
            }
            if (entry.isDirectory()) {
                Files.createDirectories(resolved);
            } else {
                Files.createDirectories(resolved.getParent());
                Files.copy(in, resolved, StandardCopyOption.REPLACE_EXISTING);
            }
        }
    }
}

/// Drops the first path segment (`airails-main/`) GitHub prepends to archive
/// entries; the wrapper directory itself maps to the empty string.
String strip(String entryName) {
    var slash = entryName.indexOf('/');
    return slash < 0 ? "" : entryName.substring(slash + 1);
}

/// `ZipInputStream` does not carry the Unix mode bits, so the extracted
/// `installSkills` arrives non-executable and `./installSkills` would fail with
/// "permission denied". Re-grants `+x` to every shebang script (`#!` first two
/// bytes), keeping the post-download `./installSkills` step working without
/// hard-coding script names.
void restoreExecutableBits(Path root) throws IOException {
    try (var paths = Files.walk(root)) {
        for (var file : (Iterable<Path>) paths.filter(Files::isRegularFile)::iterator) {
            if (startsWithShebang(file)) {
                file.toFile().setExecutable(true, false);
            }
        }
    }
}

boolean startsWithShebang(Path file) throws IOException {
    try (var in = Files.newInputStream(file)) {
        return in.read() == '#' && in.read() == '!';
    }
}

void deleteRecursively(Path dir) throws IOException {
    if (!Files.exists(dir)) {
        return;
    }
    try (var paths = Files.walk(dir)) {
        paths.sorted(Comparator.reverseOrder()).forEach(p -> {
            try {
                Files.delete(p);
            } catch (IOException e) {
                throw new UncheckedIOException(e);
            }
        });
    }
}
