// =====================================================================
// ops.sx (token) — token security at rest over the persisted store
// (subplan 02, Slice 5 / P4.3): `dist token create`, `dist token list`,
// `dist token revoke`.
//
// SECRET LIFECYCLE: `create` draws 32 bytes from `arc4random_buf` (libc —
// the same thin-platform-backend boundary as publish's `time(2)`), renders
// them as `dist_<64 lowercase hex>`, and returns that secret to the caller
// EXACTLY ONCE. Only the lowercase-hex SHA-256 of the secret is stored
// (`Token.token_hash`); nothing under the store can reproduce the secret.
// Verification (the P4.4 server, or anything holding a presented secret)
// re-hashes and looks up `find_token_by_hash`, then gates through the
// domain's `check_token` (revocation, expiry, scope, app/channel match)
// and stamps usage via `mark_token_used`.
//
// STORE STATE: `create` and `list` treat an absent db.json as an empty
// store — CI tokens are minted BEFORE the first publish creates any state.
// A present-but-unreadable db.json is still a loud Load failure.
//
// FAILURE CONTRACT (as everywhere): every abort happens before `db.save`,
// so a failed operation never changes db.json. Each raise site first
// writes a `jout.CliFailure` (stable dotted code + human message).
// =====================================================================

#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";
db   :: #import "../repo/db.sx";
jout :: #import "../json_out.sx";
pl   :: #import "../publish/publish.sx";

tokc :: #library "c";
arc4random_buf :: (buf: [*]u8, n: usize) #foreign tokc "arc4random_buf";

// Failure classes for a token operation; the precise reason travels in the
// caller's `jout.CliFailure`.
//   Load     — db.json exists but could not be loaded.
//   NotFound — no token with the given id.
//   Invalid  — the token fails domain validation, or the operation is
//              meaningless (revoking an already-revoked token).
//   Persist  — db.json could not be re-written.
TokOpError :: error {
    Load,
    NotFound,
    Invalid,
    Persist,
}

TokenCreateOutcome :: struct {
    id: string;
    name: string;
    secret: string;       // the only copy that will ever exist
    scopes: string;
    app_slug: string;
    channel: string;
    created_at: i64;
    expires_at: i64;
}

TokenListOutcome :: struct {
    tokens: []Token;
    now: i64;
}

TokenRevokeOutcome :: struct {
    id: string;
    name: string;
    revoked_at: i64;
}

// ── shared steps ──────────────────────────────────────────────────────

// Load the persisted model when one exists; start empty otherwise (a fresh
// store is a valid place to mint the first token). Only a PRESENT db.json
// that fails to load is an error.
tok_load_or_empty :: (store_dir: string, fail_out: *jout.CliFailure) -> (Repo, !TokOpError) {
    if !exists(path_join(store_dir, "db.json")) {
        return Repo.init();
    }
    loaded, le := db.load(store_dir);
    if le {
        fail_out.code    = "store.load";
        fail_out.message = concat("db.json under the store could not be loaded: ", store_dir);
        raise error.Load;
    }
    return loaded;
}

tok_save :: (repo: *Repo, store_dir: string, fail_out: *jout.CliFailure) -> !TokOpError {
    werr := false;
    db.save(repo, store_dir) catch { werr = true; };
    if werr {
        fail_out.code    = "persist.save";
        fail_out.message = concat("db.json could not be written under the store: ", store_dir);
        raise error.Persist;
    }
    return;
}

// 32 entropy bytes as `dist_<64 lowercase hex>`, heap-allocated from the
// context allocator. The `dist_` prefix makes a leaked secret recognizable
// in logs and scanners.
generate_secret :: () -> string {
    HEX :: "0123456789abcdef";
    raw : [32]u8 = ---;
    arc4random_buf(@raw[0], 32);

    n := 5 + 64;
    dst : [*]u8 = xx context.allocator.alloc_bytes(n + 1);
    dst[0] = 100; dst[1] = 105; dst[2] = 115; dst[3] = 116; dst[4] = 95;   // "dist_"
    i := 0;
    while i < 32 {
        b := raw[i];
        dst[5 + i * 2]     = HEX[b >> 4];
        dst[5 + i * 2 + 1] = HEX[b & 15];
        i += 1;
    }
    dst[n] = 0;
    return string.{ ptr = dst, len = n };
}

// Lifecycle status of a token at time `now`, for list output. Revocation
// outranks expiry, matching check_token's refusal order.
token_status :: (t: Token, now: i64) -> string {
    if t.revoked_at > 0 { return "revoked"; }
    if t.expires_at > 0 and now >= t.expires_at { return "expired"; }
    return "active";
}

// Stamp `id`'s last use. False when the id is unknown (the caller already
// authenticated, so that means the token vanished mid-request).
mark_token_used :: (repo: *Repo, id: string, now: i64) -> bool {
    tq := repo.get_token(id);
    if tq == null { return false; }
    t := tq!;
    t.last_used_at = now;
    return repo.update_token(t);
}

// ── create ────────────────────────────────────────────────────────────

token_invalid_code :: (e: ValidationErr) -> string {
    if e == error.BadTokenName   { return "token.bad_name"; }
    if e == error.BadScope       { return "token.bad_scope"; }
    if e == error.BadSlug        { return "token.bad_app_slug"; }
    if e == error.BadChannelName { return "token.bad_channel"; }
    return "token.invalid";
}

token_invalid_message :: (e: ValidationErr) -> string {
    if e == error.BadTokenName   { return "token name must be non-empty, charset [a-z0-9._-]"; }
    if e == error.BadScope       { return "scopes must be space-separated words from: publish read"; }
    if e == error.BadSlug        { return "app scope must be a valid slug ([a-z0-9-], no edge/double hyphens)"; }
    if e == error.BadChannelName { return "channel scope must be a valid channel name ([a-z0-9-])"; }
    return "token fails domain validation";
}

run_token_create :: (store_dir: string, name: string, scopes: string, app_slug: string, channel: string, expires_in: i64, fail_out: *jout.CliFailure) -> (TokenCreateOutcome, !TokOpError) {
    repo := try tok_load_or_empty(store_dir, fail_out);

    eff_scopes := if scopes.len > 0 then scopes else "publish";
    now := pl.now_secs();

    secret := generate_secret();
    token_hash := digest_of_bytes(secret);
    id := concat("tok-", substr(token_hash, 0, 12));

    t := Token.{
        id = id, name = name, token_hash = token_hash,
        scopes = eff_scopes, app_slug = app_slug, channel = channel,
        created_at = now,
        expires_at = if expires_in > 0 then now + expires_in else 0,
        last_used_at = 0, revoked_at = 0,
    };

    verr := false;
    vcode := "";
    vmsg := "";
    validate_token(t) catch (e) {
        verr = true;
        vcode = token_invalid_code(e);
        vmsg = token_invalid_message(e);
    };
    if verr {
        fail_out.code    = vcode;
        fail_out.message = vmsg;
        raise error.Invalid;
    }
    if repo.get_token(id) != null {
        fail_out.code    = "token.id_collision";
        fail_out.message = concat("a token with this id already exists (re-run create): ", id);
        raise error.Invalid;
    }

    repo.create_token(t);
    repo.create_audit_event(AuditEvent.{
        id = concat("evt-token-create-", id), actor = "cli",
        action = "token.create", target_type = "token",
        target_id = id, metadata = name, created_at = now,
    });
    try tok_save(@repo, store_dir, fail_out);

    return TokenCreateOutcome.{
        id = id, name = name, secret = secret,
        scopes = eff_scopes, app_slug = app_slug, channel = channel,
        created_at = now, expires_at = t.expires_at,
    };
}

// ── list ──────────────────────────────────────────────────────────────

run_token_list :: (store_dir: string, fail_out: *jout.CliFailure) -> (TokenListOutcome, !TokOpError) {
    repo := try tok_load_or_empty(store_dir, fail_out);
    return TokenListOutcome.{ tokens = repo.list_tokens(), now = pl.now_secs() };
}

// ── revoke ────────────────────────────────────────────────────────────

run_token_revoke :: (store_dir: string, id: string, fail_out: *jout.CliFailure) -> (TokenRevokeOutcome, !TokOpError) {
    repo := try tok_load_or_empty(store_dir, fail_out);

    tq := repo.get_token(id);
    if tq == null {
        fail_out.code    = "token.unknown";
        fail_out.message = concat("no token with that id in the store: ", id);
        raise error.NotFound;
    }
    t := tq!;
    if t.revoked_at > 0 {
        fail_out.code    = "token.already_revoked";
        fail_out.message = concat("token is already revoked: ", id);
        raise error.Invalid;
    }

    now := pl.now_secs();
    t.revoked_at = now;
    repo.update_token(t);
    repo.create_audit_event(AuditEvent.{
        id = concat("evt-token-revoke-", id), actor = "cli",
        action = "token.revoke", target_type = "token",
        target_id = id, metadata = t.name, created_at = now,
    });
    try tok_save(@repo, store_dir, fail_out);

    return TokenRevokeOutcome.{ id = id, name = t.name, revoked_at = now };
}

// ── rendering ─────────────────────────────────────────────────────────

// `{"status":"created","token":{id,name,secret,scopes,app_slug,channel,
// created_at,expires_at}}` — the ONE machine-readable copy of the secret.
write_token_create_json :: (o: *TokenCreateOutcome, dst: []u8) -> (i64, !JsonError) {
    gpa := GPA.init();
    root : Object = .{};
    root.put("status", .str("created"), xx gpa);
    to : Object = .{};
    to.put("id", .str(o.id), xx gpa);
    to.put("name", .str(o.name), xx gpa);
    to.put("secret", .str(o.secret), xx gpa);
    to.put("scopes", .str(o.scopes), xx gpa);
    to.put("app_slug", .str(o.app_slug), xx gpa);
    to.put("channel", .str(o.channel), xx gpa);
    to.put("created_at", .int_(o.created_at), xx gpa);
    to.put("expires_at", .int_(o.expires_at), xx gpa);
    root.put("token", .object(to), xx gpa);
    rootv : Value = .object(root);
    n := try write_to_buffer(rootv, dst);
    return n;
}

// `{"status":"ok","tokens":[{id,name,scopes,app_slug,channel,created_at,
// expires_at,last_used_at,revoked_at,status}]}` — hashes and secrets never
// appear in list output.
write_token_list_json :: (o: *TokenListOutcome, dst: []u8) -> (i64, !JsonError) {
    gpa := GPA.init();
    root : Object = .{};
    root.put("status", .str("ok"), xx gpa);
    arr : Array = .{};
    i := 0;
    while i < o.tokens.len {
        t := o.tokens[i];
        to : Object = .{};
        to.put("id", .str(t.id), xx gpa);
        to.put("name", .str(t.name), xx gpa);
        to.put("scopes", .str(t.scopes), xx gpa);
        to.put("app_slug", .str(t.app_slug), xx gpa);
        to.put("channel", .str(t.channel), xx gpa);
        to.put("created_at", .int_(t.created_at), xx gpa);
        to.put("expires_at", .int_(t.expires_at), xx gpa);
        to.put("last_used_at", .int_(t.last_used_at), xx gpa);
        to.put("revoked_at", .int_(t.revoked_at), xx gpa);
        to.put("status", .str(token_status(t, o.now)), xx gpa);
        arr.add(.object(to), xx gpa);
        i += 1;
    }
    root.put("tokens", .array(arr), xx gpa);
    rootv : Value = .object(root);
    n := try write_to_buffer(rootv, dst);
    return n;
}

// `{"status":"revoked","token":{id,name,revoked_at}}`.
write_token_revoke_json :: (o: *TokenRevokeOutcome, dst: []u8) -> (i64, !JsonError) {
    gpa := GPA.init();
    root : Object = .{};
    root.put("status", .str("revoked"), xx gpa);
    to : Object = .{};
    to.put("id", .str(o.id), xx gpa);
    to.put("name", .str(o.name), xx gpa);
    to.put("revoked_at", .int_(o.revoked_at), xx gpa);
    root.put("token", .object(to), xx gpa);
    rootv : Value = .object(root);
    n := try write_to_buffer(rootv, dst);
    return n;
}

token_create_human :: (o: *TokenCreateOutcome) -> string {
    s := concat("created token ", concat(o.id, concat(" (", concat(o.name, ")\n"))));
    s = concat(s, concat("  secret: ", concat(o.secret, "   (shown once — store it now)\n")));
    s = concat(s, concat("  scopes: ", o.scopes));
    s = concat(s, concat("  app: ", if o.app_slug.len > 0 then o.app_slug else "(any)"));
    s = concat(s, concat("  channel: ", if o.channel.len > 0 then o.channel else "(any)"));
    if o.expires_at > 0 {
        s = concat(s, concat("  expires_at: ", int_to_string(o.expires_at)));
    } else {
        s = concat(s, "  expires: never");
    }
    return concat(s, "\n");
}

token_list_human :: (o: *TokenListOutcome) -> string {
    if o.tokens.len == 0 { return "no tokens in the store\n"; }
    s := "";
    i := 0;
    while i < o.tokens.len {
        t := o.tokens[i];
        s = concat(s, concat(t.id, concat("  ", concat(t.name, concat("  ", token_status(t, o.now))))));
        s = concat(s, concat("  scopes: ", t.scopes));
        s = concat(s, concat("  app: ", if t.app_slug.len > 0 then t.app_slug else "(any)"));
        s = concat(s, concat("  channel: ", if t.channel.len > 0 then t.channel else "(any)"));
        s = concat(s, "\n");
        i += 1;
    }
    return s;
}

token_revoke_human :: (o: *TokenRevokeOutcome) -> string {
    return concat("revoked token ", concat(o.id, concat(" (", concat(o.name, ")\n"))));
}
