// =====================================================================
// auth.sx — bearer-token authentication for distd's write surface
// (subplan 04, Slice 2).
//
// `authenticate` takes the raw header block plus the (scope, app, channel)
// the operation demands, and either returns the live Token or refuses:
//
//   401 Unauthorized — no `Authorization` header, a non-Bearer scheme, or
//       a secret whose hash matches no stored token. The response never
//       distinguishes "no such token" from "wrong secret": both are
//       `auth.unknown_token`.
//   403 Forbidden — the token exists but `check_token` refuses it; the
//       code names the refusal (auth.revoked / auth.expired /
//       auth.missing_scope / auth.app_forbidden / auth.channel_forbidden).
//   503 Unavailable — db.json exists but could not be loaded.
//
// The presented secret is re-hashed (`digest_of_bytes`, the store's
// SHA-256) and matched against hashes at rest — the secret itself is never
// stored or logged. Successful auth stamps `last_used_at` and persists;
// the stamp is best-effort (a failed save must not fail an authorized
// request — the operation's own save is the authoritative write).
// =====================================================================

#import "modules/std.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 "../repo/repo.sx";
#import "../store/store.sx";
db   :: #import "../repo/db.sx";
jout :: #import "../json_out.sx";
pl   :: #import "../publish/publish.sx";
tops :: #import "../token/ops.sx";
http :: #import "http.sx";

AuthErr :: error {
    Unauthorized,   // -> 401
    Forbidden,      // -> 403
    Unavailable,    // -> 503
}

// The secret inside `Authorization: Bearer <secret>`, or null when the
// value does not carry the Bearer scheme (case-insensitive, per RFC 7235)
// or carries an empty credential.
bearer_secret :: (header: string) -> ?string {
    SCHEME :: "bearer ";
    if header.len <= 7 { return null; }
    i := 0;
    while i < 7 {
        c := header[i];
        if c >= 65 and c <= 90 { c += 32; }   // ASCII lower
        if c != SCHEME[i] { return null; }
        i += 1;
    }
    return string.{ ptr = @header[7], len = header.len - 7 };
}

check_code :: (e: TokenCheckErr) -> string {
    if e == error.Revoked         { return "auth.revoked"; }
    if e == error.Expired         { return "auth.expired"; }
    if e == error.ScopeMissing    { return "auth.missing_scope"; }
    if e == error.AppMismatch     { return "auth.app_forbidden"; }
    return "auth.channel_forbidden";
}

check_message :: (e: TokenCheckErr, scope: string, app_slug: string, channel: string) -> string {
    if e == error.Revoked      { return "token has been revoked"; }
    if e == error.Expired      { return "token has expired"; }
    if e == error.ScopeMissing { return concat("token lacks the required scope: ", scope); }
    if e == error.AppMismatch  { return concat("token is not scoped to this app: ", app_slug); }
    return concat("token is not scoped to this channel: ", channel);
}

// Authenticate the request these headers came with, demanding `scope`
// against (`app_slug`, `channel`) — "" where the operation has no app or
// channel to constrain.
authenticate :: (store_dir: string, headers: string, scope: string, app_slug: string, channel: string, fail_out: *jout.CliFailure) -> (Token, !AuthErr) {
    hq := http.header_value(headers, "authorization");
    if hq == null {
        fail_out.code    = "auth.missing";
        fail_out.message = "this route requires Authorization: Bearer <token>";
        raise error.Unauthorized;
    }
    sq := bearer_secret(hq!);
    if sq == null {
        fail_out.code    = "auth.malformed";
        fail_out.message = "Authorization header is not a Bearer credential";
        raise error.Unauthorized;
    }
    presented_hash := digest_of_bytes(sq!);

    if !exists(path_join(store_dir, "db.json")) {
        fail_out.code    = "auth.unknown_token";
        fail_out.message = "unknown token";
        raise error.Unauthorized;
    }
    repo, 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.Unavailable;
    }

    tq := repo.find_token_by_hash(presented_hash);
    if tq == null {
        fail_out.code    = "auth.unknown_token";
        fail_out.message = "unknown token";
        raise error.Unauthorized;
    }
    t := tq!;

    now := pl.now_secs();
    cerr := false;
    ccode := "";
    cmsg := "";
    check_token(t, scope, app_slug, channel, now) catch (e) {
        cerr = true;
        ccode = check_code(e);
        cmsg = check_message(e, scope, app_slug, channel);
    };
    if cerr {
        fail_out.code    = ccode;
        fail_out.message = cmsg;
        raise error.Forbidden;
    }

    tops.mark_token_used(@repo, t.id, now);
    db.save(@repo, store_dir) catch {};   // best-effort stamp (see header)

    return t;
}
