// Pinned acceptance for P4.4 — distd's token-gated write surface.
//
// Mints tokens via the CLI (a publish-scoped one, a read-only one, an
// app-scoped one for the WRONG app, and a revoked one), starts the BUILT
// `build/dist server run`, and asserts over curl:
//
//   * auth:    401 auth.missing / auth.unknown_token without a usable
//              bearer; 403 auth.missing_scope (read token),
//              auth.revoked (revoked token), auth.app_forbidden
//              (token scoped to another app); only tokens that PASSED
//              auth get last_used_at stamped.
//   * upload:  POST /api/upload stores the body content-addressed — the
//              returned sha256 equals an independently computed digest of
//              the fixture, the object lands under objects/<sha>, and a
//              re-upload answers the same key (dedup). A bodyless POST is
//              411.
//   * release: POST /api/apps/<slug>/releases over the uploaded digest
//              publishes through the shared pipeline — visible on the
//              next GET (per-request reload), channel moved; an unknown
//              digest is 404 api.unknown_object and leaves no channel
//              behind; re-publishing the same version is 409.
//   * channel: promote / rollback move the pointer exactly like their
//              CLI twins; unknown release id is 404.
//   * methods: anything but GET/POST is 405.
#import "modules/std.sx";
#import "modules/std/json.sx";
process :: #import "modules/std/process.sx";
fs   :: #import "modules/std/fs.sx";
hash :: #import "modules/std/hash.sx";

STORE   :: ".sx-tmp/server_write";
PORT    :: "18793";
BASE    :: "http://127.0.0.1:18793";
FIXTURE :: "examples/fixtures/acme-1.2.3-android.apk";

get :: (o: Object, key: string) -> Value {
    i := 0;
    while i < o.len {
        if o.items[i].key == key { return o.items[i].val; }
        i += 1;
    }
    process.assert(false, concat("missing json key: ", key));
    dummy : Value = .null_;
    return dummy;
}
get_str :: (o: Object, key: string) -> string { return get(o, key).str; }
get_int :: (o: Object, key: string) -> i64    { return get(o, key).int_; }
get_obj :: (o: Object, key: string) -> Object { return get(o, key).object; }
get_arr :: (o: Object, key: string) -> Array  { return get(o, key).array; }

parse_body :: (body: string, what: string, scratch: Allocator) -> Object {
    v, e := parse(body, scratch);
    if e {
        process.assert(false, concat("response must be valid JSON: ", what));
        dummy : Object = .{};
        return dummy;
    }
    return v.object;
}

// Mint a token via the CLI and return its raw secret.
mint :: (flags: string, scratch: Allocator) -> string {
    cmd := concat("build/dist token create --local-store .sx-tmp/server_write ", flags);
    cmd = concat(cmd, " --json 2>/dev/null");
    r := process.run(cmd);
    process.assert(r != null and r!.exit_code == 0, concat("token create must exit 0: ", flags));
    o := parse_body(r!.stdout, "token create", scratch);
    return get_str(get_obj(o, "token"), "secret");
}

// POST `path` with optional bearer + data flags; returns the body.
post :: (path: string, auth: string, dataflags: string) -> string {
    cmd := "curl -s -m 4 -X POST ";
    if auth.len > 0 {
        cmd = concat(cmd, concat("-H 'Authorization: Bearer ", concat(auth, "' ")));
    }
    cmd = concat(cmd, concat(dataflags, concat(" ", concat(BASE, path))));
    r := process.run(cmd);
    process.assert(r != null, concat("curl spawn failed: ", path));
    return r!.stdout;
}

// POST and return only the HTTP status code as text.
post_code :: (path: string, auth: string, dataflags: string) -> string {
    cmd := "curl -s -m 4 -o /dev/null -w '%{http_code}' -X POST ";
    if auth.len > 0 {
        cmd = concat(cmd, concat("-H 'Authorization: Bearer ", concat(auth, "' ")));
    }
    cmd = concat(cmd, concat(dataflags, concat(" ", concat(BASE, path))));
    r := process.run(cmd);
    process.assert(r != null, concat("curl spawn failed: ", path));
    return r!.stdout;
}

fetch :: (path: string) -> string {
    r := process.run(concat(concat("curl -s -m 4 ", BASE), path));
    process.assert(r != null, concat("curl spawn failed: ", path));
    return r!.stdout;
}

// The error.code member of a JSON error response.
err_code :: (body: string, what: string, scratch: Allocator) -> string {
    return get_str(get_obj(parse_body(body, what, scratch), "error"), "code");
}

load_db :: (scratch: Allocator) -> Object {
    b := fs.read_file(path_join(STORE, "db.json"));
    process.assert(b != null, "db.json must exist under the store");
    return parse_body(b!, "db.json", scratch);
}

// current_release_id of channel `name`, "" when the channel doesn't exist.
channel_pointer :: (name: string, scratch: Allocator) -> string {
    chans := get_arr(load_db(scratch), "channels");
    i := 0;
    while i < chans.len {
        co := chans.items[i].object;
        if get_str(co, "name") == name { return get_str(co, "current_release_id"); }
        i += 1;
    }
    return "";
}

// last_used_at of the token named `name`.
token_last_used :: (name: string, scratch: Allocator) -> i64 {
    toks := get_arr(load_db(scratch), "tokens");
    i := 0;
    while i < toks.len {
        to := toks.items[i].object;
        if get_str(to, "name") == name { return get_int(to, "last_used_at"); }
        i += 1;
    }
    process.assert(false, concat("token not in db.json: ", name));
    return -1;
}

// JSON body for a release POST referencing `sha` (single android artifact).
release_body :: (version: string, channel: string, sha: string) -> string {
    b := concat("-d '{\"version\":\"", version);
    b = concat(b, concat("\",\"channel\":\"", channel));
    b = concat(b, "\",\"artifacts\":[{\"platform\":\"android_apk\",\"sha256\":\"");
    b = concat(b, sha);
    return concat(b, "\"}]}'");
}

main :: () -> i32 {
    gpa := GPA.init();
    arena := Arena.init(xx gpa, 1 << 20);
    defer arena.deinit();

    process.run("pkill -f 'dist server run --local-store .sx-tmp/server_write' 2>/dev/null");
    process.run(concat("rm -rf ", STORE));

    // ── tokens (this also creates db.json on the fresh store) ─────────
    publisher := mint("--name publisher", xx arena);
    reader    := mint("--name reader --scope read", xx arena);
    wrong_app := mint("--name wrong-app --app other-app", xx arena);
    revoked   := mint("--name doomed", xx arena);
    // revoke "doomed" by id, looked up via token list
    tl := process.run("build/dist token list --local-store .sx-tmp/server_write --json 2>/dev/null");
    process.assert(tl != null and tl!.exit_code == 0, "token list must exit 0");
    toks := get_arr(parse_body(tl!.stdout, "token list", xx arena), "tokens");
    doomed_id := "";
    ti := 0;
    while ti < toks.len {
        to := toks.items[ti].object;
        if get_str(to, "name") == "doomed" { doomed_id = get_str(to, "id"); }
        ti += 1;
    }
    process.assert(doomed_id.len > 0, "doomed token must be listed");
    rv := process.run(concat(concat("build/dist token revoke --id ", doomed_id), " --local-store .sx-tmp/server_write --json 2>/dev/null"));
    process.assert(rv != null and rv!.exit_code == 0, "token revoke must exit 0");

    // ── server up ──────────────────────────────────────────────────────
    sp := process.run(concat(concat(concat("sh -c 'build/dist server run --local-store ", STORE), concat(concat(" --port ", PORT), " >/dev/null 2>&1 & echo $!'")), ""));
    process.assert(sp != null, "server spawn failed");
    pid := sp!.stdout;

    ready := false;
    tries := 0;
    while tries < 50 {
        c := process.run(concat(concat("curl -s -m 2 -o /dev/null -w '%{http_code}' ", BASE), "/healthz"));
        if c != null {
            if c!.stdout == "200" { ready = true; break; }
        }
        process.run("sleep 0.2");
        tries += 1;
    }
    process.assert(ready, "server must answer /healthz within 10s");
    print("  server up\n");

    upload_data := concat("--data-binary @", FIXTURE);

    // ── auth refusals ──────────────────────────────────────────────────
    process.assert(post_code("/api/upload", "", upload_data) == "401", "no auth is 401");
    process.assert(err_code(post("/api/upload", "", upload_data), "no-auth body", xx arena) == "auth.missing",
                   "no auth names auth.missing");
    process.assert(err_code(post("/api/upload", "dist_bogus", upload_data), "bad-token body", xx arena) == "auth.unknown_token",
                   "unknown secret names auth.unknown_token");
    process.assert(post_code("/api/upload", "dist_bogus", upload_data) == "401", "unknown secret is 401");
    process.assert(post_code("/api/upload", reader, upload_data) == "403", "read scope is 403");
    process.assert(err_code(post("/api/upload", reader, upload_data), "reader body", xx arena) == "auth.missing_scope",
                   "read scope names auth.missing_scope");
    process.assert(post_code("/api/upload", revoked, upload_data) == "403", "revoked token is 403");
    process.assert(err_code(post("/api/upload", revoked, upload_data), "revoked body", xx arena) == "auth.revoked",
                   "revoked token names auth.revoked");
    print("  auth refusals: 401/403 with precise codes\n");

    // ── upload ─────────────────────────────────────────────────────────
    fixture_bytes := fs.read_file(FIXTURE);
    process.assert(fixture_bytes != null, "fixture must be readable");
    d := hash.sha256_hex(fixture_bytes!);
    expect_sha := string.{ ptr = @d[0], len = 64 };

    up := parse_body(post("/api/upload", publisher, upload_data), "upload", xx arena);
    process.assert(get_str(up, "status") == "stored", "upload json status stored");
    sha := get_str(up, "sha256");
    process.assert(sha == expect_sha, "upload sha256 equals an independent digest of the bytes");
    process.assert(fs.exists(path_join(STORE, concat("objects/", sha))), "object lands under objects/<sha>");

    up2 := parse_body(post("/api/upload", publisher, upload_data), "re-upload", xx arena);
    process.assert(get_str(up2, "sha256") == sha, "re-upload answers the same key (dedup)");

    process.assert(post_code("/api/upload", publisher, "") == "411", "bodyless POST is 411");
    print("  upload: content-addressed, dedup, 411 without a body\n");

    // ── release publish over the uploaded object ───────────────────────
    rb := parse_body(post("/api/apps/acme-app/releases", publisher, release_body("1.2.3", "beta", sha)), "release", xx arena);
    process.assert(get_str(rb, "status") == "published", "release json status published");
    process.assert(get_str(get_obj(rb, "release"), "id") == "rel-acme-app-1.2.3", "release id");
    process.assert(channel_pointer("beta", xx arena) == "rel-acme-app-1.2.3", "beta points at the new release");

    det := parse_body(fetch("/api/apps/acme-app"), "detail", xx arena);
    process.assert(get_arr(det, "releases").len == 1, "GET reflects the POSTed release (per-request reload)");

    process.assert(post_code("/api/apps/acme-app/releases", wrong_app, release_body("1.2.9", "beta", sha)) == "403",
                   "token scoped to another app is 403");
    process.assert(err_code(post("/api/apps/acme-app/releases", wrong_app, release_body("1.2.9", "beta", sha)), "wrong-app body", xx arena) == "auth.app_forbidden",
                   "wrong-app scope names auth.app_forbidden");

    UNKNOWN :: "0000000000000000000000000000000000000000000000000000000000000000";
    process.assert(post_code("/api/apps/acme-app/releases", publisher, release_body("9.9.9", "nightly", UNKNOWN)) == "404",
                   "unknown object is 404");
    process.assert(err_code(post("/api/apps/acme-app/releases", publisher, release_body("9.9.9", "nightly", UNKNOWN)), "unknown-object body", xx arena) == "api.unknown_object",
                   "unknown object names api.unknown_object");
    process.assert(channel_pointer("nightly", xx arena) == "", "aborted publish leaves no channel behind");

    process.assert(post_code("/api/apps/acme-app/releases", publisher, release_body("1.2.3", "beta", sha)) == "409",
                   "duplicate release id is 409");
    print("  release: publish ok, scope enforced, aborts leave no trace\n");

    // ── channel ops mirror the CLI ─────────────────────────────────────
    p2 := parse_body(post("/api/apps/acme-app/releases", publisher, release_body("1.2.4", "beta", sha)), "release B", xx arena);
    process.assert(get_str(p2, "status") == "published", "second release published");
    process.assert(channel_pointer("beta", xx arena) == "rel-acme-app-1.2.4", "beta -> 1.2.4");

    rbk := parse_body(post("/api/apps/acme-app/channels/beta/rollback", publisher, "-d ''"), "rollback", xx arena);
    process.assert(get_str(rbk, "status") == "rolled_back", "rollback json status");
    process.assert(channel_pointer("beta", xx arena) == "rel-acme-app-1.2.3", "rollback moved beta back");

    pm := parse_body(post("/api/apps/acme-app/channels/beta/promote", publisher, "-d '{\"release_id\":\"rel-acme-app-1.2.4\"}'"), "promote", xx arena);
    process.assert(get_str(pm, "status") == "promoted", "promote json status");
    process.assert(channel_pointer("beta", xx arena) == "rel-acme-app-1.2.4", "promote moved beta forward");

    process.assert(post_code("/api/apps/acme-app/channels/beta/promote", publisher, "-d '{\"release_id\":\"rel-nope\"}'") == "404",
                   "promoting an unknown release is 404");
    print("  channel ops: promote/rollback mirror the CLI\n");

    // ── last-used stamping + method gate ───────────────────────────────
    process.assert(token_last_used("publisher", xx arena) > 0, "publisher token got last_used_at stamped");
    process.assert(token_last_used("reader", xx arena) == 0, "refused token is never stamped");

    mc := process.run(concat(concat("curl -s -m 2 -o /dev/null -w '%{http_code}' -X DELETE ", BASE), "/healthz"));
    process.assert(mc != null and mc!.stdout == "405", "non-GET/POST method is 405");
    print("  last-used stamped for authed tokens only; 405 method gate\n");

    // ── teardown ───────────────────────────────────────────────────────
    process.run(concat("kill ", pid));
    process.run(concat("rm -rf ", STORE));
    print("server_write: ALL CASES PASS\n");
    return 0;
}
