// Pinned acceptance for P4.3 — `dist token create` / `list` / `revoke`
// over the persisted store (subplan 02 Slice 5).
//
// Drives the BUILT `build/dist` binary through the slice contract:
//
//   1. `token create` on a FRESH store (no db.json yet) → exit 0; the JSON
//      carries the raw secret (`dist_` + 64 hex) exactly once; db.json
//      stores ONLY sha256(secret) — the secret's bytes appear nowhere under
//      the store — plus a `token.create` audit event.
//   2. A second token with `--scope read --expires-in 60` → expires_at is
//      created_at + 60.
//   3. `token list` → both tokens "active"; no secret in the output.
//   4. `token revoke` → exit 0; db.json gains revoked_at + a `token.revoke`
//      audit event; list now shows "revoked".
//   5. Revoking an UNKNOWN id → exit 1 + `token.unknown`; revoking AGAIN →
//      exit 1 + `token.already_revoked`; both leave db.json unchanged.
//   6. A publish into the same store still works (the model with tokens
//      round-trips through the publish pipeline's load/save).
#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/token_ops";
MDIR  :: ".sx-tmp/token_ops_m";

MANIFEST :: "{\"app\":\"acme-app\",\"version\":\"1.2.3\",\"channel\":\"beta\",\"artifacts\":[{\"platform\":\"android_apk\",\"path\":\"../../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; }

contains :: (haystack: string, needle: string) -> bool {
    if needle.len == 0 or needle.len > haystack.len { return false; }
    i := 0;
    while i + needle.len <= haystack.len {
        j := 0;
        while j < needle.len and haystack[i + j] == needle[j] { j += 1; }
        if j == needle.len { return true; }
        i += 1;
    }
    return false;
}

count_actor_action :: (events: Array, actor: string, action: string) -> i64 {
    c : i64 = 0;
    i := 0;
    while i < events.len {
        eo := events.items[i].object;
        if get_str(eo, "actor") == actor and get_str(eo, "action") == action { c += 1; }
        i += 1;
    }
    return c;
}

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

load_db :: (scratch: Allocator) -> Object {
    dv, de := parse(db_bytes(), scratch);
    if de { process.assert(false, "db.json must be valid JSON"); dummy : Object = .{}; return dummy; }
    return dv.object;
}

// The db.json token entry with this id.
db_token :: (id: string, scratch: Allocator) -> Object {
    toks := get_arr(load_db(scratch), "tokens");
    i := 0;
    while i < toks.len {
        to := toks.items[i].object;
        if get_str(to, "id") == id { return to; }
        i += 1;
    }
    process.assert(false, concat("token not in db.json: ", id));
    dummy : Object = .{};
    return dummy;
}

// The "status" of token `id` in `token list --json` output.
list_status :: (id: string, scratch: Allocator) -> string {
    rl := process.run("build/dist token list --local-store .sx-tmp/token_ops --json 2>/dev/null");
    process.assert(rl != null and rl!.exit_code == 0, "token list must exit 0");
    lv, le := parse(rl!.stdout, scratch);
    if le { process.assert(false, "list stdout must be one JSON object"); dummy : Object = .{}; return ""; }
    toks := get_arr(lv.object, "tokens");
    i := 0;
    while i < toks.len {
        to := toks.items[i].object;
        if get_str(to, "id") == id { return get_str(to, "status"); }
        i += 1;
    }
    process.assert(false, concat("token not in list output: ", id));
    return "";
}

revoke_cmd :: (id: string) -> string {
    c := concat("build/dist token revoke --id ", id);
    return concat(c, " --local-store .sx-tmp/token_ops --json 2>/dev/null");
}

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

    process.run(concat("rm -rf ", STORE));
    process.run(concat("rm -rf ", MDIR));
    process.run(concat("mkdir -p ", MDIR));
    process.run(concat(concat(concat("printf '%s' '", MANIFEST), "' > "), path_join(MDIR, "m.json")));

    // ── 1. Create on a fresh store: secret once, only the hash at rest ─
    r1 := process.run("build/dist token create --name ci-main --app acme-app --channel beta --local-store .sx-tmp/token_ops --json 2>/dev/null");
    process.assert(r1 != null and r1!.exit_code == 0, "token create must exit 0 on a fresh store");
    v1, e1 := parse(r1!.stdout, xx arena);
    if e1 { process.assert(false, "create stdout must be one JSON object"); return 1; }
    o1 := v1.object;
    process.assert(get_str(o1, "status") == "created", "create json status");
    t1 := get_obj(o1, "token");
    id1 := get_str(t1, "id");
    secret := get_str(t1, "secret");
    created1 := get_int(t1, "created_at");
    process.assert(contains(id1, "tok-") and id1.len == 16, "id is tok-<12 hex>");
    process.assert(secret.len == 69 and contains(secret, "dist_"), "secret is dist_<64 hex>");
    process.assert(get_str(t1, "scopes") == "publish", "default scope is publish");
    process.assert(get_int(t1, "expires_at") == 0, "default expiry is never");

    dt1 := db_token(id1, xx arena);
    stored_hash := get_str(dt1, "token_hash");
    d := hash.sha256_hex(secret);
    expect_hash := string.{ ptr = @d[0], len = 64 };
    process.assert(stored_hash == expect_hash, "db stores sha256(secret) as token_hash");
    process.assert(!contains(db_bytes(), secret), "the raw secret appears nowhere under the store");
    db1 := load_db(xx arena);
    process.assert(count_actor_action(get_arr(db1, "audit_events"), "cli", "token.create") == 1,
                   "create recorded one cli token.create audit event");
    print("  create: secret shown once, only sha256 at rest, audited\n");

    // ── 2. Second token: read scope, 60s expiry ──────────────────────
    r2 := process.run("build/dist token create --name reader --scope read --expires-in 60 --local-store .sx-tmp/token_ops --json 2>/dev/null");
    process.assert(r2 != null and r2!.exit_code == 0, "second token create must exit 0");
    v2, e2 := parse(r2!.stdout, xx arena);
    if e2 { process.assert(false, "create2 stdout must be one JSON object"); return 1; }
    t2 := get_obj(v2.object, "token");
    id2 := get_str(t2, "id");
    process.assert(get_str(t2, "scopes") == "read", "second token carries --scope read");
    process.assert(get_int(t2, "expires_at") == get_int(t2, "created_at") + 60,
                   "expires_at is created_at + --expires-in");
    print("  create: scope + expiry flags honored\n");

    // ── 3. List: both active, no secret anywhere ─────────────────────
    rl := process.run("build/dist token list --local-store .sx-tmp/token_ops --json 2>/dev/null");
    process.assert(rl != null and rl!.exit_code == 0, "token list must exit 0");
    process.assert(!contains(rl!.stdout, secret), "list output never carries a secret");
    process.assert(!contains(rl!.stdout, stored_hash), "list output never carries a hash");
    process.assert(list_status(id1, xx arena) == "active", "token 1 listed active");
    process.assert(list_status(id2, xx arena) == "active", "token 2 listed active");
    print("  list: two active tokens, no secret/hash in output\n");

    // ── 4. Revoke: status flips, audited ─────────────────────────────
    rr := process.run(revoke_cmd(id1));
    process.assert(rr != null and rr!.exit_code == 0, "revoke must exit 0");
    rv, re := parse(rr!.stdout, xx arena);
    if re { process.assert(false, "revoke stdout must be one JSON object"); return 1; }
    process.assert(get_str(rv.object, "status") == "revoked", "revoke json status");
    dt1b := db_token(id1, xx arena);
    revoked_at := get_int(dt1b, "revoked_at");
    process.assert(revoked_at >= created1, "db records revoked_at");
    process.assert(list_status(id1, xx arena) == "revoked", "token 1 listed revoked");
    db4 := load_db(xx arena);
    process.assert(count_actor_action(get_arr(db4, "audit_events"), "cli", "token.revoke") == 1,
                   "revoke recorded one cli token.revoke audit event");
    print("  revoke: status flips, audited\n");

    // ── 5. Failure paths leave db.json unchanged ─────────────────────
    before := db_bytes();
    rn := process.run(revoke_cmd("tok-nope"));
    process.assert(rn != null and rn!.exit_code == 1, "revoking an unknown id must exit 1");
    nv, ne := parse(rn!.stdout, xx arena);
    if ne { process.assert(false, "unknown-revoke stdout must be one JSON object"); return 1; }
    process.assert(get_str(get_obj(nv.object, "error"), "code") == "token.unknown",
                   "unknown-revoke json names token.unknown");

    ra := process.run(revoke_cmd(id1));
    process.assert(ra != null and ra!.exit_code == 1, "double revoke must exit 1");
    av, ae := parse(ra!.stdout, xx arena);
    if ae { process.assert(false, "double-revoke stdout must be one JSON object"); return 1; }
    process.assert(get_str(get_obj(av.object, "error"), "code") == "token.already_revoked",
                   "double-revoke json names token.already_revoked");
    process.assert(db_bytes() == before, "failed revokes leave db.json byte-identical");
    print("  failures: unknown + double revoke exit 1, db.json untouched\n");

    // ── 6. Publish coexists with tokens in the same store ────────────
    pc := concat("build/dist ci publish --manifest ", path_join(MDIR, "m.json"));
    pc = concat(pc, " --local-store .sx-tmp/token_ops --json 2>/dev/null");
    rp := process.run(pc);
    process.assert(rp != null and rp!.exit_code == 0, "publish into a tokened store must exit 0");
    db6 := load_db(xx arena);
    process.assert(get_arr(db6, "tokens").len == 2, "publish preserved both tokens");
    process.assert(get_arr(db6, "releases").len == 1, "publish landed its release");
    print("  publish: round-trips the model with tokens intact\n");

    process.run(concat("rm -rf ", STORE));
    process.run(concat("rm -rf ", MDIR));
    print("token_ops: ALL CASES PASS\n");
    return 0;
}
