#import "modules/std.sx";

// A scoped credential for CI and automation (subplan 02, Slice 5). The raw
// secret is NEVER stored: `token_hash` is the lowercase-hex SHA-256 of the
// secret, and the secret itself exists only in the `dist token create`
// output. `scopes` is a space-separated list of scope words ("publish",
// "read"). `app_slug` / `channel` narrow the token to one app and/or one
// channel — empty means unrestricted, and the app scope is by SLUG so a
// token can be minted before its app's first publish creates the app row.
// Timestamps are unix epoch seconds; 0 means never (no expiry, never used,
// not revoked).
Token :: struct {
    id: string;
    name: string;
    token_hash: string;
    scopes: string;
    app_slug: string;
    channel: string;
    created_at: i64;
    expires_at: i64;
    last_used_at: i64;
    revoked_at: i64;
}

// Why a token was refused. One tag per refusal class so the caller (CLI
// now, the HTTP 401/403 paths in P4.4) can name the precise reason.
TokenCheckErr :: error {
    Revoked,
    Expired,
    ScopeMissing,
    AppMismatch,
    ChannelMismatch,
}

// True iff `word` appears as a whole space-separated word in `scopes`.
has_scope :: (scopes: string, word: string) -> bool {
    i := 0;
    while i < scopes.len {
        // start of the next word
        while i < scopes.len and scopes[i] == 32 { i += 1; }   // 32 = ' '
        start := i;
        while i < scopes.len and scopes[i] != 32 { i += 1; }
        if i > start {
            w := string.{ ptr = @scopes[start], len = i - start };
            if w == word { return true; }
        }
    }
    return false;
}

// Decide whether `t` authorizes `scope` against (`app_slug`, `channel`) at
// time `now`. Checks run in refusal-severity order: a revoked token reports
// Revoked even if it is also expired. An empty `t.app_slug` / `t.channel`
// matches anything; an empty REQUEST app or channel also passes its gate —
// the operation has nothing to constrain there (an upload is app-agnostic
// until a release references it; a read has no channel).
check_token :: (t: Token, scope: string, app_slug: string, channel: string, now: i64) -> !TokenCheckErr {
    if t.revoked_at > 0 { raise error.Revoked; }
    if t.expires_at > 0 and now >= t.expires_at { raise error.Expired; }
    if !has_scope(t.scopes, scope) { raise error.ScopeMissing; }
    if t.app_slug.len > 0 and app_slug.len > 0 and t.app_slug != app_slug { raise error.AppMismatch; }
    if t.channel.len > 0 and channel.len > 0 and t.channel != channel { raise error.ChannelMismatch; }
    return;
}
