// =====================================================================
// distd.sx — the distribution server over the local store (subplan 04,
// Slices 1-4), run as `dist server run`.
//
// Serves the state the CLI publishes — db.json metadata and the
// content-addressed objects — over HTTP (src/server/http.sx). Reads are
// public:
//
//   GET /                    HTML index: apps, channels, releases, links
//   GET /healthz             {"status":"ok"} — no store access
//   GET /api/apps            {"apps":[...]} every app in the store
//   GET /api/apps/<slug>     {"app":..,"releases":[..],"channels":[..]}
//   GET /download/<sha256>   the object's bytes (application/octet-stream,
//                            X-Checksum-SHA256 header)
//   GET /install/<slug>/<channel>                 install page (UA-aware,
//                            iOS actions honest per the app's IosMode)
//   GET /install/<slug>/<channel>/manifest.plist  enterprise OTA manifest
//                            (404 unless ios_mode is enterprise)
//
// Writes require `Authorization: Bearer <token>` with the `publish` scope
// (src/server/auth.sx; tokens are minted with `dist token create`):
//
//   POST /api/upload                                  raw bytes -> content-
//        addressed object; responds {"status":"stored","sha256":..}
//   POST /api/apps/<slug>/releases                    JSON body {version,
//        channel, artifacts:[{platform, sha256, ...}]} over ALREADY-
//        uploaded objects -> the same commit pipeline as `dist ci publish`
//   POST /api/apps/<slug>/channels/<name>/promote     {"release_id":..}
//   POST /api/apps/<slug>/channels/<name>/rollback    (empty body)
//
// The channel operations delegate to the P3.5 CLI pipelines and the
// release POST commits through publish.sx's `commit_publish`, so HTTP and
// CLI semantics cannot drift.
//
// Anything else is a JSON error in the CLI's error shape
// (`{"status":"error","error":{code,message}}`) with the matching HTTP
// status — the API and the CLI report failures identically.
//
// FRESHNESS: db.json is RELOADED on every /api request, so a `dist ci
// publish` / `release promote` between requests is visible immediately —
// the store on disk stays the single source of truth (no cache to
// invalidate, LAN-scale traffic).
//
// RESPONSE BUFFERS are heap slices from the per-request arena, never big
// stack arrays: a stack array of 64K+ in one frame crashes the sx LLVM
// backend (DAGCombiner segfault). Small fixed buffers (4K) are fine.
// =====================================================================

#import "modules/std.sx";
#import "modules/std/json.sx";
#import "modules/std/fs.sx";
#import "../domain/platform.sx";
#import "../domain/app.sx";
#import "../domain/release.sx";
#import "../domain/artifact.sx";
#import "../domain/channel.sx";
#import "../domain/token.sx";
#import "../domain/audit.sx";
#import "../domain/validate.sx";
#import "../repo/repo.sx";
#import "../store/store.sx";
#import "../validation/artifact_file.sx";
#import "../publish/publish.sx";
sock :: #import "modules/std/socket.sx";
http :: #import "http.sx";
au   :: #import "auth.sx";
db   :: #import "../repo/db.sx";
jout :: #import "../json_out.sx";
// Also reached through an alias so publish helpers read as `pl.…` at the
// call sites that mirror dist.sx's.
pl   :: #import "../publish/publish.sx";
ops  :: #import "../release/ops.sx";
// Aliased so the json reader is called as `jsrv.parse` — a bare `parse`
// would bind to `std.cli`'s once both modules share the `dist` program.
jsrv :: #import "modules/std/json.sx";

// Response-body capacity for the /api JSON renders (heap, per-request).
RENDER_CAP :: 262144;

ServeErr :: error {
    Bind,
}

// Server-side stderr log line (fd 2 via the socket module's write; stdout
// stays clean for whoever launched the process).
slog :: (s: string) {
    if s.len > 0 { sock.write(2, s.ptr, xx s.len); }
}

// True iff `s` is exactly 64 lowercase-hex chars (a storage key).
is_hex64 :: (s: string) -> bool {
    if s.len != 64 { return false; }
    i := 0;
    while i < s.len {
        c := s[i];
        digit := c >= 48 and c <= 57;    // '0'..'9'
        lower := c >= 97 and c <= 102;   // 'a'..'f'
        if !digit and !lower { return false; }
        i += 1;
    }
    return true;
}

starts_with :: (s: string, prefix: string) -> bool {
    if prefix.len > s.len { return false; }
    i := 0;
    while i < prefix.len {
        if s[i] != prefix[i] { return false; }
        i += 1;
    }
    return true;
}

// The path remainder after `prefix` (caller has checked starts_with).
tail_after :: (s: string, prefix: string) -> string {
    return string.{ ptr = @s[prefix.len], len = s.len - prefix.len };
}

// A `RENDER_CAP` heap slice from the per-request context allocator.
render_buf :: () -> string {
    raw : [*]u8 = xx context.allocator.alloc_bytes(RENDER_CAP);
    return string.{ ptr = raw, len = RENDER_CAP };
}

// ── responses ─────────────────────────────────────────────────────────

// JSON error body in the CLI's error shape, sent with the HTTP status.
respond_error :: (client: i32, code: i64, fail_code: string, fail_message: string) {
    f : jout.CliFailure = .{ code = fail_code, message = fail_message };
    raw : [4096]u8 = ---;
    werr := false;
    n := jout.write_error(f, string.{ ptr = @raw[0], len = 4096 }) catch { werr = true; 0 };
    body := "{\"status\":\"error\"}";
    if !werr { body = string.{ ptr = @raw[0], len = n }; }
    http.respond(client, code, "application/json", "", body);
}

// ── /api renders (builders own the `try`, callers catch) ─────────────

// Reload the persisted model. Null means the store has no readable
// db.json — the 503 error response has already been sent.
load_or_503 :: (client: i32, store_dir: string) -> ?Repo {
    if !exists(path_join(store_dir, "db.json")) {
        respond_error(client, 503, "store.load",
            concat("no db.json under the store (nothing published yet): ", store_dir));
        return null;
    }
    loaded, le := db.load(store_dir);
    if le {
        respond_error(client, 503, "store.load",
            concat("db.json under the store could not be loaded: ", store_dir));
        return null;
    }
    return loaded;
}

// `{"apps":[...]}` into `dst`, returning the bytes written.
render_apps_json :: (repo: *Repo, dst: []u8) -> (i64, !JsonError) {
    alloc := context.allocator;
    root : Object = .{};
    arr : Array = .{};
    i := 0;
    while i < repo.apps.len {
        arr.add(db.app_to_json(repo.apps.items[i], alloc), alloc);
        i += 1;
    }
    root.put("apps", .array(arr), alloc);
    rootv : Value = .object(root);
    n := try write_to_buffer(rootv, dst);
    return n;
}

// `{"app":..,"releases":[..],"channels":[..]}` for `app` into `dst`.
render_app_detail_json :: (repo: *Repo, app: App, dst: []u8) -> (i64, !JsonError) {
    alloc := context.allocator;
    root : Object = .{};
    root.put("app", db.app_to_json(app, alloc), alloc);

    rels : Array = .{};
    i := 0;
    while i < repo.releases.len {
        r := repo.releases.items[i];
        if r.app_id == app.id { rels.add(db.release_to_json(r, alloc), alloc); }
        i += 1;
    }
    root.put("releases", .array(rels), alloc);

    chans : Array = .{};
    i = 0;
    while i < repo.channels.len {
        c := repo.channels.items[i];
        if c.app_id == app.id { chans.add(db.channel_to_json(c, alloc), alloc); }
        i += 1;
    }
    root.put("channels", .array(chans), alloc);

    rootv : Value = .object(root);
    n := try write_to_buffer(rootv, dst);
    return n;
}

// Send `n` rendered bytes of `buf` as 200 JSON — or the overflow error
// when the render didn't fit RENDER_CAP.
respond_render :: (client: i32, buf: string, n: i64, overflowed: bool) {
    if overflowed {
        respond_error(client, 500, "http.response_overflow",
            "response exceeded the server's render buffer");
        return;
    }
    http.respond(client, 200, "application/json", "", string.{ ptr = buf.ptr, len = n });
}

// ── HTML index (the install-page seed, subplan 04 Slice 5) ───────────

// Escape &, <, >, " for HTML text/attribute positions. Returns `s`
// unchanged (no copy) when nothing needs escaping.
html_escape :: (s: string) -> string {
    acc := "";
    start := 0;
    i := 0;
    while i < s.len {
        c := s[i];
        rep := "";
        if c == 38 { rep = "&amp;"; }    // &
        if c == 60 { rep = "&lt;"; }     // <
        if c == 62 { rep = "&gt;"; }     // >
        if c == 34 { rep = "&quot;"; }   // "
        if rep.len > 0 {
            if i > start { acc = concat(acc, string.{ ptr = @s[start], len = i - start }); }
            acc = concat(acc, rep);
            start = i + 1;
        }
        i += 1;
    }
    if start == 0 { return s; }
    if start < s.len { acc = concat(acc, string.{ ptr = @s[start], len = s.len - start }); }
    return acc;
}

// First 12 hex chars of a digest, for display next to the full-link.
short_sha :: (sha: string) -> string {
    if sha.len < 12 { return sha; }
    return string.{ ptr = sha.ptr, len = 12 };
}

INDEX_HEAD :: "<!doctype html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>dist</title><style>body{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;background:#101216;color:#d6dae1;margin:2rem auto;max-width:60rem;padding:0 1rem}h1{font-size:1.1rem;letter-spacing:.04em}h2{font-size:1rem;margin-top:2rem;border-bottom:1px solid #2a2f3a;padding-bottom:.3rem}table{border-collapse:collapse;width:100%;font-size:.85rem}td,th{text-align:left;padding:.3rem .8rem .3rem 0;border-bottom:1px solid #1d212a;vertical-align:top}th{color:#8b93a3;font-weight:normal}a{color:#7ab7ff;text-decoration:none}a:hover{text-decoration:underline}small,.dim{color:#737b8c}</style></head><body><h1>dist — release console</h1>";

INDEX_FOOT :: "<p class=\"dim\"><small>API: <a href=\"/api/apps\">/api/apps</a> &middot; <a href=\"/healthz\">/healthz</a></small></p></body></html>";

// Server-rendered index: every app with its channels and its releases'
// artifacts as direct /download links. Dense and read-only, per the
// product shape — the full admin UI is subplan 06.
render_index :: (repo: *Repo) -> string {
    page := INDEX_HEAD;
    if repo.apps.len == 0 {
        page = concat(page, "<p class=\"dim\">nothing published yet</p>");
    }
    a := 0;
    while a < repo.apps.len {
        app := repo.apps.items[a];
        page = concat(page, concat("<h2>", concat(html_escape(app.slug), concat(" <small>", concat(html_escape(app.display_name), "</small></h2>")))));

        // Channels: name (linked to the install page) -> current release.
        page = concat(page, "<table><tr><th>channel</th><th>current release</th></tr>");
        c := 0;
        while c < repo.channels.len {
            ch := repo.channels.items[c];
            if ch.app_id == app.id {
                link := concat("<a href=\"/install/", concat(html_escape(app.slug), concat("/", concat(html_escape(ch.name), concat("\">", concat(html_escape(ch.name), "</a>"))))));
                page = concat(page, concat("<tr><td>", concat(link, concat("</td><td>", concat(html_escape(ch.current_release_id), "</td></tr>")))));
            }
            c += 1;
        }
        page = concat(page, "</table>");

        // Releases with their artifacts as download links.
        page = concat(page, "<table><tr><th>release</th><th>platform</th><th>artifact</th><th>size</th><th>sha256</th></tr>");
        r := 0;
        while r < repo.releases.len {
            rel := repo.releases.items[r];
            if rel.app_id == app.id {
                k := 0;
                while k < repo.artifacts.len {
                    art := repo.artifacts.items[k];
                    if art.release_id == rel.id {
                        row := concat("<tr><td>", concat(html_escape(rel.version), concat(" <small>", concat(html_escape(rel.id), "</small></td><td>"))));
                        row = concat(row, concat(db.platform_str(art.platform), "</td><td>"));
                        row = concat(row, concat("<a href=\"/download/", concat(art.sha256, concat("\">", concat(html_escape(art.filename), "</a></td><td>")))));
                        row = concat(row, concat(int_to_string(art.size_bytes), "</td><td><small>"));
                        row = concat(row, concat(short_sha(art.sha256), "&hellip;</small></td></tr>"));
                        page = concat(page, row);
                    }
                    k += 1;
                }
            }
            r += 1;
        }
        page = concat(page, "</table>");
        a += 1;
    }
    return concat(page, INDEX_FOOT);
}

handle_index :: (client: i32, store_dir: string) {
    rq := load_or_503(client, store_dir);
    if rq == null { return; }
    repo := rq!;
    http.respond(client, 200, "text/html; charset=utf-8", "", render_index(@repo));
}

// ── routes ────────────────────────────────────────────────────────────

handle_apps_index :: (client: i32, store_dir: string) {
    rq := load_or_503(client, store_dir);
    if rq == null { return; }
    repo := rq!;

    buf := render_buf();
    werr := false;
    n := render_apps_json(@repo, buf) catch { werr = true; 0 };
    respond_render(client, buf, n, werr);
}

handle_app_detail :: (client: i32, store_dir: string, slug: string) {
    rq := load_or_503(client, store_dir);
    if rq == null { return; }
    repo := rq!;

    aq := repo.find_app_by_slug(slug);
    if aq == null {
        respond_error(client, 404, "api.unknown_app",
            concat("no app with that slug in the store: ", slug));
        return;
    }

    buf := render_buf();
    werr := false;
    n := render_app_detail_json(@repo, aq!, buf) catch { werr = true; 0 };
    respond_render(client, buf, n, werr);
}

handle_download :: (client: i32, store_dir: string, sha: string) {
    if !is_hex64(sha) {
        respond_error(client, 404, "download.bad_key",
            "download key must be a 64-char lowercase-hex sha256");
        return;
    }
    opath := path_join(store_dir, concat("objects/", sha));
    bq := read_file(opath);
    if bq == null {
        respond_error(client, 404, "download.unknown_object",
            concat("no object with that digest in the store: ", sha));
        return;
    }
    extra := concat("X-Checksum-SHA256: ", concat(sha, "\r\n"));
    http.respond(client, 200, "application/octet-stream", extra, bq!);
}

// ── install pages (subplan 04 Slice 5) ────────────────────────────────

// True iff `needle` occurs in `hay` (UA sniffing; both are short).
contains :: (hay: string, needle: string) -> bool {
    if needle.len == 0 { return true; }
    if needle.len > hay.len { return false; }
    i := 0;
    while i + needle.len <= hay.len {
        j := 0;
        ok := true;
        while j < needle.len {
            if hay[i + j] != needle[j] { ok = false; break; }
            j += 1;
        }
        if ok { return true; }
        i += 1;
    }
    return false;
}

// The requesting device's platform, sniffed from the User-Agent. Order
// matters: iPhone UAs contain "like Mac OS X" and Android UAs contain
// "Linux", so the mobile checks run first. Null = no idea.
ua_platform :: (ua: string) -> ?Platform {
    if contains(ua, "iPhone") or contains(ua, "iPad") or contains(ua, "iPod") { return .ios; }
    if contains(ua, "Android") { return .android_apk; }
    if contains(ua, "Macintosh") { return .macos; }
    if contains(ua, "Windows") { return .windows; }
    if contains(ua, "Linux") { return .linux; }
    return null;
}

platform_label :: (p: Platform) -> string {
    if p == .ios         { return "iOS"; }
    if p == .android_apk { return "Android"; }
    if p == .macos       { return "macOS"; }
    if p == .linux       { return "Linux"; }
    return "Windows";
}

// The release the (app, channel) pair currently serves, resolving each
// step or answering the precise 404. Null = response already sent.
// `host` (the request's Host header) feeds the absolute URLs the
// enterprise install flow needs.
InstallCtx :: struct {
    app: App;
    channel_name: string;
    release: Release;
    host: string = "localhost";
}

resolve_install :: (client: i32, repo: *Repo, slug: string, chan_name: string) -> ?InstallCtx {
    aq := repo.find_app_by_slug(slug);
    if aq == null {
        respond_error(client, 404, "install.unknown_app",
            concat("no app with that slug in the store: ", slug));
        return null;
    }
    app := aq!;
    cq := repo.get_channel(app.id, chan_name);
    if cq == null {
        respond_error(client, 404, "install.unknown_channel",
            concat("the app has no channel with that name: ", chan_name));
        return null;
    }
    rq := repo.get_release(cq!.current_release_id);
    if rq == null {
        respond_error(client, 404, "install.no_release",
            concat("the channel does not point at a published release: ", chan_name));
        return null;
    }
    return InstallCtx.{ app = app, channel_name = chan_name, release = rq! };
}

// One artifact row: filename (download link), size, full sha256 — the
// digest is part of the contract, not decoration.
install_artifact_row :: (art: Artifact, label: string) -> string {
    row := concat("<p class=\"artifact\"><a href=\"/download/", concat(art.sha256, "\">"));
    row = concat(row, concat(html_escape(art.filename), "</a>"));
    if label.len > 0 { row = concat(row, concat(" <small>", concat(label, "</small>"))); }
    row = concat(row, concat(" <small>", concat(int_to_string(art.size_bytes), " bytes</small>")));
    row = concat(row, concat("<br><small class=\"sha\">sha256 ", concat(art.sha256, "</small></p>")));
    return row;
}

// The iOS section body for the app's install mode — the honest version
// of "install on iPhone" (see IosMode in the domain).
render_ios_actions :: (ctx: *InstallCtx, art: Artifact) -> string {
    app := ctx.app;
    if app.ios_mode == .testflight {
        s := concat("<p><a class=\"action\" href=\"", concat(html_escape(app.testflight_url), "\">Open in TestFlight</a></p>"));
        s = concat(s, "<p><small>Installation runs through Apple&#39;s TestFlight flow.</small></p>");
        return concat(s, install_artifact_row(art, "(IPA artifact)"));
    }
    if app.ios_mode == .enterprise {
        plist := concat("https://", concat(ctx.host, concat("/install/", concat(app.slug, concat("/", concat(ctx.channel_name, "/manifest.plist"))))));
        itms := concat("itms-services://?action=download-manifest&url=", plist);
        s := concat("<p><a class=\"action\" href=\"", concat(html_escape(itms), "\">Install on enrolled device</a></p>"));
        s = concat(s, "<p><small>Over-the-air enterprise install; requires MDM enrollment and HTTPS.</small></p>");
        return concat(s, install_artifact_row(art, "(IPA artifact)"));
    }
    // artifact_only — a download, never an install action
    s := install_artifact_row(art, "");
    return concat(s, "<p><small>IPA artifact download only &mdash; it cannot be installed on an iPhone or iPad from this page.</small></p>");
}

INSTALL_HEAD :: "<!doctype html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>install</title><style>body{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;background:#101216;color:#d6dae1;margin:2rem auto;max-width:40rem;padding:0 1rem}h1{font-size:1.1rem}h2{font-size:1rem;margin-top:1.6rem;border-bottom:1px solid #2a2f3a;padding-bottom:.3rem}a{color:#7ab7ff;text-decoration:none}a:hover{text-decoration:underline}a.action{display:inline-block;border:1px solid #7ab7ff;border-radius:4px;padding:.4rem .8rem;margin:.2rem 0}small,.dim{color:#737b8c}.sha{word-break:break-all}.detected h2{color:#9fe09f}</style></head><body>";

// The install page: the channel's current release, one section per
// platform artifact, the requester's platform first and marked.
render_install :: (ctx: *InstallCtx, repo: *Repo, detected: ?Platform) -> string {
    app := ctx.app;
    rel := ctx.release;

    page := INSTALL_HEAD;
    page = concat(page, concat("<h1>", concat(html_escape(app.display_name), concat(" <small>", concat(html_escape(rel.version), concat(" &middot; ", concat(html_escape(ctx.channel_name), "</small></h1>")))))));

    // platform sections in fixed order — except the detected platform,
    // which moves to the front
    order : [5]Platform = .[ .ios, .android_apk, .macos, .linux, .windows ];
    if detected != null {
        d := detected!;
        order[0] = d;
        slot := 1;
        j := 0;
        while j < 5 {
            cand : Platform = .ios;
            if j == 1 { cand = .android_apk; }
            if j == 2 { cand = .macos; }
            if j == 3 { cand = .linux; }
            if j == 4 { cand = .windows; }
            if cand != d {
                order[slot] = cand;
                slot += 1;
            }
            j += 1;
        }
    }

    k := 0;
    while k < 5 {
        p := order[k];
        // the release's artifact for this platform, if any
        ai := 0;
        while ai < repo.artifacts.len {
            art := repo.artifacts.items[ai];
            if art.release_id == rel.id and art.platform == p {
                marked := detected != null and k == 0;
                if marked { page = concat(page, "<section class=\"detected\">"); }
                else { page = concat(page, "<section>"); }
                page = concat(page, concat("<h2>", platform_label(p)));
                if marked { page = concat(page, " <small>your device</small>"); }
                page = concat(page, "</h2>");
                if p == .ios {
                    page = concat(page, render_ios_actions(ctx, art));
                } else {
                    label := "";
                    if p == .android_apk { label = "(APK)"; }
                    page = concat(page, install_artifact_row(art, label));
                }
                page = concat(page, "</section>");
            }
            ai += 1;
        }
        k += 1;
    }

    page = concat(page, "<p class=\"dim\"><small><a href=\"/\">&larr; all apps</a></small></p>");
    return concat(page, "</body></html>");
}

handle_install_page :: (client: i32, store_dir: string, req: *http.Request, slug: string, chan_name: string) {
    rq := load_or_503(client, store_dir);
    if rq == null { return; }
    repo := rq!;
    ctxq := resolve_install(client, @repo, slug, chan_name);
    if ctxq == null { return; }
    ctx := ctxq!;

    ua := "";
    uq := http.header_value(req.headers, "user-agent");
    if uq != null { ua = uq!; }
    hostq := http.header_value(req.headers, "host");
    if hostq != null { ctx.host = hostq!; }

    http.respond(client, 200, "text/html; charset=utf-8", "",
                 render_install(@ctx, @repo, ua_platform(ua)));
}

// The enterprise OTA manifest: Apple's plist shape, with the software
// package URL pointing back at this server over HTTPS (itms-services
// refuses plain http; TLS terminates at the reverse proxy).
handle_manifest_plist :: (client: i32, store_dir: string, req: *http.Request, slug: string, chan_name: string) {
    rq := load_or_503(client, store_dir);
    if rq == null { return; }
    repo := rq!;
    ctxq := resolve_install(client, @repo, slug, chan_name);
    if ctxq == null { return; }
    ctx := ctxq!;

    if ctx.app.ios_mode != .enterprise {
        respond_error(client, 404, "install.not_enterprise",
            "the app's iOS install mode is not enterprise; no OTA manifest exists");
        return;
    }
    bid := bundle_id_for(ctx.app, .ios);
    if bid.len == 0 {
        respond_error(client, 404, "install.no_bundle_id",
            "the app has no iOS bundle id; set one with: dist app set --ios-bundle-id");
        return;
    }
    // the release's ios artifact
    found := false;
    art : Artifact = .{};
    ai := 0;
    while ai < repo.artifacts.len {
        a := repo.artifacts.items[ai];
        if a.release_id == ctx.release.id and a.platform == .ios { art = a; found = true; break; }
        ai += 1;
    }
    if !found {
        respond_error(client, 404, "install.no_ios_artifact",
            "the channel's current release carries no iOS artifact");
        return;
    }

    host := "localhost";
    hostq := http.header_value(req.headers, "host");
    if hostq != null { host = hostq!; }
    url := concat("https://", concat(host, concat("/download/", art.sha256)));

    x := "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\"><dict><key>items</key><array><dict>";
    x = concat(x, "<key>assets</key><array><dict><key>kind</key><string>software-package</string><key>url</key><string>");
    x = concat(x, html_escape(url));
    x = concat(x, "</string></dict></array><key>metadata</key><dict><key>bundle-identifier</key><string>");
    x = concat(x, html_escape(bid));
    x = concat(x, "</string><key>bundle-version</key><string>");
    x = concat(x, html_escape(ctx.release.version));
    x = concat(x, "</string><key>kind</key><string>software</string><key>title</key><string>");
    x = concat(x, html_escape(ctx.app.display_name));
    x = concat(x, "</string></dict></dict></array></dict></plist>\n");

    http.respond(client, 200, "application/xml", "", x);
}

// GET /install/<slug>/<channel>[/manifest.plist]
handle_install_route :: (client: i32, store_dir: string, req: *http.Request, tail: string) {
    s1 := seg_split(tail);
    if s1.head.len == 0 or s1.rest.len == 0 {
        respond_error(client, 404, "http.not_found",
            "install pages live at /install/<slug>/<channel>");
        return;
    }
    s2 := seg_split(s1.rest);
    if s2.rest.len == 0 {
        handle_install_page(client, store_dir, req, s1.head, s2.head);
        return;
    }
    if s2.rest == "manifest.plist" {
        handle_manifest_plist(client, store_dir, req, s1.head, s2.head);
        return;
    }
    respond_error(client, 404, "http.not_found",
        concat("no install route for ", tail));
}

// ── write surface (POST, token-gated) ─────────────────────────────────

// `s` split at its first '/': head before it, rest after it ("" when no
// slash exists).
Seg :: struct {
    head: string;
    rest: string;
}

seg_split :: (s: string) -> Seg {
    i := 0;
    while i < s.len {
        if s[i] == 47 {   // '/'
            return Seg.{
                head = string.{ ptr = s.ptr, len = i },
                rest = string.{ ptr = @s[i + 1], len = s.len - i - 1 },
            };
        }
        i += 1;
    }
    return Seg.{ head = s, rest = "" };
}

// Authenticate a write request or answer it. Null means the refusal
// response has already been sent.
auth_or_respond :: (client: i32, store_dir: string, req: *http.Request, app_slug: string, channel: string) -> ?Token {
    fail : jout.CliFailure = .{};
    t, ae := au.authenticate(store_dir, req.headers, "publish", app_slug, channel, @fail);
    if ae {
        status : i64 = 401;
        if ae == error.Forbidden   { status = 403; }
        if ae == error.Unavailable { status = 503; }
        respond_error(client, status, fail.code, fail.message);
        return null;
    }
    return t;
}

// JSON-object body, or null after answering 400.
body_object :: (client: i32, body: string) -> ?Object {
    v, pe := jsrv.parse(body, context.allocator);
    if pe {
        respond_error(client, 400, "api.bad_json", "request body is not valid JSON");
        return null;
    }
    if v != .object {
        respond_error(client, 400, "api.bad_json", "request body must be a JSON object");
        return null;
    }
    return v.object;
}

wb_find :: (o: Object, key: string) -> ?Value {
    i := 0;
    while i < o.len {
        if o.items[i].key == key { return o.items[i].val; }
        i += 1;
    }
    return null;
}

// Required string member, or null after answering 400.
wb_req_str :: (client: i32, o: Object, key: string) -> ?string {
    vq := wb_find(o, key);
    if vq != null {
        v := vq!;
        if v == .str {
            if v.str.len > 0 { return v.str; }
        }
    }
    respond_error(client, 400, "api.missing_field",
        concat("body requires a non-empty string member: ", key));
    return null;
}

wb_opt_str :: (o: Object, key: string) -> string {
    vq := wb_find(o, key);
    if vq == null { return ""; }
    v := vq!;
    if v != .str { return ""; }
    return v.str;
}

wb_opt_int :: (o: Object, key: string, default_: i64) -> i64 {
    vq := wb_find(o, key);
    if vq == null { return default_; }
    v := vq!;
    if v != .int_ { return default_; }
    return v.int_;
}

// POST /api/upload — content-address the raw body into the store. The
// upload is app-agnostic (the bytes are inert until a release references
// them), so auth demands only the publish scope.
handle_upload :: (client: i32, store_dir: string, req: *http.Request) {
    if auth_or_respond(client, store_dir, req, "", "") == null { return; }
    if req.body.len == 0 {
        respond_error(client, 400, "upload.empty", "upload body is empty");
        return;
    }
    st := Store.init(store_dir);
    werr := false;
    key := "";
    k, se := st.put_bytes(req.body);
    if se { werr = true; }
    if !se { key = k; }
    if werr {
        respond_error(client, 500, "store.write",
            "upload bytes could not be content-addressed into the store");
        return;
    }
    body := concat("{\"status\":\"stored\",\"sha256\":\"", concat(key, concat("\",\"size_bytes\":", concat(int_to_string(req.body.len), "}"))));
    http.respond(client, 200, "application/json", "", body);
}

// POST /api/apps/<slug>/releases — publish a release whose artifacts name
// already-uploaded objects by digest. Mirrors the CLI manifest checks
// (platform, digest, size, content type, filename extension) against the
// STORE rather than local files, then commits through the shared pipeline.
handle_release_create :: (client: i32, store_dir: string, req: *http.Request, slug: string) {
    oq := body_object(client, req.body);
    if oq == null { return; }
    o := oq!;

    versionq := wb_req_str(client, o, "version");
    if versionq == null { return; }
    version := versionq!;
    channelq := wb_req_str(client, o, "channel");
    if channelq == null { return; }
    channel := channelq!;

    tokq := auth_or_respond(client, store_dir, req, slug, channel);
    if tokq == null { return; }
    tok := tokq!;

    artsq := wb_find(o, "artifacts");
    arts_ok := false;
    arts_arr : Array = .{};
    if artsq != null {
        av := artsq!;
        if av == .array {
            if av.array.len > 0 { arts_ok = true; arts_arr = av.array; }
        }
    }
    if !arts_ok {
        respond_error(client, 400, "api.missing_field",
            "body requires a non-empty artifacts array");
        return;
    }

    alloc := context.allocator;
    resolved : List(ResolvedArtifact) = .{};
    i := 0;
    while i < arts_arr.len {
        if arts_arr.items[i] != .object {
            respond_error(client, 400, "api.bad_json", "each artifact must be a JSON object");
            return;
        }
        ao := arts_arr.items[i].object;

        pidq := wb_req_str(client, ao, "platform");
        if pidq == null { return; }
        platform, perr := parse_platform(pidq!);
        if perr {
            respond_error(client, 400, "api.unknown_platform",
                concat("unknown platform id: ", pidq!));
            return;
        }

        shaq := wb_req_str(client, ao, "sha256");
        if shaq == null { return; }
        sha := shaq!;
        if !is_hex64(sha) {
            respond_error(client, 400, "api.bad_digest",
                "artifact sha256 must be a 64-char lowercase-hex digest");
            return;
        }

        // The named object must already live in the store (uploaded via
        // /api/upload or an earlier publish).
        opath := path_join(store_dir, concat("objects/", sha));
        ob := read_file(opath);
        if ob == null {
            respond_error(client, 404, "api.unknown_object",
                concat("no object with that digest in the store (upload it first): ", sha));
            return;
        }
        actual_size := ob!.len;
        declared := wb_opt_int(ao, "size_bytes", -1);
        if declared >= 0 and declared != actual_size {
            respond_error(client, 400, "api.size_mismatch",
                concat("declared size_bytes does not match the stored object: ", sha));
            return;
        }

        ct := wb_opt_str(ao, "content_type");
        if ct.len == 0 { ct = pl.default_content_type(platform); }
        if !is_allowed_content_type(ct) {
            respond_error(client, 400, "api.content_type_denied",
                concat("artifact content type is not on the allow-list: ", ct));
            return;
        }

        fname := wb_opt_str(ao, "filename");
        if fname.len == 0 {
            fname = concat(slug, concat("-", concat(version, concat(".", expected_ext(platform)))));
        }
        if !ext_matches_platform(fname, platform) {
            respond_error(client, 400, "api.extension_mismatch",
                concat("artifact filename extension does not match its platform: ", fname));
            return;
        }

        resolved.append(ResolvedArtifact.{
            platform = platform, key = sha, size_bytes = actual_size,
            filename = fname, content_type = ct,
            metadata = wb_opt_str(ao, "metadata"),
        }, alloc);
        i += 1;
    }

    fail : jout.CliFailure = .{};
    actor := concat("token:", tok.name);
    arts_view : []ResolvedArtifact = .{ ptr = resolved.items, len = resolved.len };
    outcome, ce := pl.commit_publish(store_dir, slug, version, channel, actor, "/download/", arts_view, @fail);
    if ce {
        status : i64 = 500;
        if ce == error.Transaction {
            status = 400;
            if fail.code == "transaction.integrity" { status = 409; }
        }
        if ce == error.Persist {
            if fail.code == "persist.load" { status = 503; }
        }
        respond_error(client, status, fail.code, fail.message);
        return;
    }
    if !ce {
        buf := render_buf();
        werr := false;
        n := pl.write_json(@outcome, buf) catch { werr = true; 0 };
        respond_render(client, buf, n, werr);
    }
}

// HTTP status for a failed P3.5 channel operation.
op_http_status :: (e: ops.OpError) -> i64 {
    if e == error.Load     { return 503; }
    if e == error.NotFound { return 404; }
    if e == error.Invalid  { return 409; }
    return 500;
}

// POST /api/apps/<slug>/channels/<name>/promote — body {"release_id":..};
// delegates to the CLI's promote pipeline.
handle_promote :: (client: i32, store_dir: string, req: *http.Request, slug: string, chan_name: string) {
    oq := body_object(client, req.body);
    if oq == null { return; }
    relq := wb_req_str(client, oq!, "release_id");
    if relq == null { return; }

    if auth_or_respond(client, store_dir, req, slug, chan_name) == null { return; }

    fail : jout.CliFailure = .{};
    o, e := ops.run_promote(store_dir, slug, chan_name, relq!, @fail);
    if e {
        respond_error(client, op_http_status(e), fail.code, fail.message);
        return;
    }
    if !e {
        buf := render_buf();
        werr := false;
        n := ops.write_promote_json(@o, buf) catch { werr = true; 0 };
        respond_render(client, buf, n, werr);
    }
}

// POST /api/apps/<slug>/channels/<name>/rollback — empty body; delegates
// to the CLI's rollback pipeline.
handle_rollback :: (client: i32, store_dir: string, req: *http.Request, slug: string, chan_name: string) {
    if auth_or_respond(client, store_dir, req, slug, chan_name) == null { return; }

    fail : jout.CliFailure = .{};
    o, e := ops.run_rollback(store_dir, slug, chan_name, @fail);
    if e {
        respond_error(client, op_http_status(e), fail.code, fail.message);
        return;
    }
    if !e {
        buf := render_buf();
        werr := false;
        n := ops.write_rollback_json(@o, buf) catch { werr = true; 0 };
        respond_render(client, buf, n, werr);
    }
}

// Route a write request under POST /api/. Returns false when no write
// route matches (the caller 404s).
route_post :: (client: i32, store_dir: string, req: *http.Request) -> bool {
    path := req.path;
    if path == "/api/upload" {
        handle_upload(client, store_dir, req);
        return true;
    }
    if starts_with(path, "/api/apps/") {
        s1 := seg_split(tail_after(path, "/api/apps/"));
        if s1.head.len == 0 { return false; }
        if s1.rest == "releases" {
            handle_release_create(client, store_dir, req, s1.head);
            return true;
        }
        if starts_with(s1.rest, "channels/") {
            s2 := seg_split(tail_after(s1.rest, "channels/"));
            if s2.head.len == 0 { return false; }
            if s2.rest == "promote" {
                handle_promote(client, store_dir, req, s1.head, s2.head);
                return true;
            }
            if s2.rest == "rollback" {
                handle_rollback(client, store_dir, req, s1.head, s2.head);
                return true;
            }
        }
    }
    return false;
}

// Route one parsed request: GET reads, POST writes.
route :: (client: i32, store_dir: string, req: *http.Request) -> i64 {
    method := req.method;
    path := req.path;
    if method == "GET" {
        if path == "/" {
            handle_index(client, store_dir);
            return 200;
        }
        if path == "/healthz" {
            http.respond(client, 200, "application/json", "", "{\"status\":\"ok\"}");
            return 200;
        }
        if path == "/api/apps" {
            handle_apps_index(client, store_dir);
            return 200;
        }
        if starts_with(path, "/api/apps/") {
            handle_app_detail(client, store_dir, tail_after(path, "/api/apps/"));
            return 200;
        }
        if starts_with(path, "/download/") {
            handle_download(client, store_dir, tail_after(path, "/download/"));
            return 200;
        }
        if starts_with(path, "/install/") {
            handle_install_route(client, store_dir, req, tail_after(path, "/install/"));
            return 200;
        }
        respond_error(client, 404, "http.not_found",
            concat("no route for ", path));
        return 404;
    }
    if method == "POST" {
        if route_post(client, store_dir, req) { return 200; }
        respond_error(client, 404, "http.not_found",
            concat("no write route for ", path));
        return 404;
    }
    respond_error(client, 405, "http.method_not_allowed",
        "distd routes are GET (reads) or POST (token-gated writes)");
    return 405;
}

// ── server loop ───────────────────────────────────────────────────────

// Read one request off `client`, route it, log the result line. All
// allocations land in the pushed per-request context allocator.
serve_one :: (client: i32, store_dir: string) {
    req : http.Request = .{};
    closed := false;
    rstatus : i64 = 0;
    rcode := "";
    rmsg := "";
    http.read_request(client, @req) catch (e) {
        if e == error.Closed { closed = true; }
        if !closed {
            rstatus = 400; rcode = "http.bad_request";
            rmsg = "request could not be read as HTTP (bad request line or truncated body)";
            if e == error.LengthRequired {
                rstatus = 411; rcode = "http.length_required";
                rmsg = "POST/PUT requires a Content-Length header";
            }
            if e == error.BodyTooLarge {
                rstatus = 413; rcode = "http.body_too_large";
                rmsg = "request body exceeds the server's size cap";
            }
            if e == error.HeadersTooLarge {
                rstatus = 400; rcode = "http.headers_too_large";
                rmsg = "request header block exceeds 8K";
            }
        }
    };
    if closed { return; }
    if rstatus > 0 {
        respond_error(client, rstatus, rcode, rmsg);
        slog(concat("distd: unreadable request -> ", concat(int_to_string(rstatus), "\n")));
        return;
    }

    code := route(client, store_dir, @req);
    line := concat("distd: ", concat(req.method, concat(" ", concat(req.path, concat(" -> ", concat(int_to_string(code), "\n"))))));
    slog(line);
}

// Bind 0.0.0.0:<port> and serve forever (sequential, one connection at a
// time — the deployment story is LAN/NAS-scale). Returns only when the
// socket can't be opened. Per-request allocations live in an arena that
// dies with the request.
run_server :: (store_dir: string, port: i64) -> !ServeErr {
    fd, fe := http.listen_on(port);
    if fe { raise error.Bind; }

    slog(concat("distd: serving store ", concat(store_dir, concat(" on http://0.0.0.0:", concat(int_to_string(port), "\n")))));

    while true {
        client := sock.accept(fd, null, null);
        if client < 0 { continue; }
        http.set_read_timeout(client, 2);

        gpa := GPA.init();
        arena := Arena.init(xx gpa, 65536);
        push Context.{ allocator = xx arena } {
            serve_one(client, store_dir);
        }
        arena.deinit();
        sock.close(client);
    }
    return;
}
