// =====================================================================
// db.sx — whole-model persistence to `<root>/db.json` via `std.json`
// (the SQLite stand-in for subplan 02, Slice 1).
//
// FIELD ORDER (the "stable key order" guarantee): `Object.put` preserves
// INSERTION ORDER and the writer emits members in that order, so the only
// thing that fixes db.json's layout is the ORDER these functions `put`.
// Every entity is emitted in its struct's declaration-field order, and the
// top-level object is emitted as apps, releases, artifacts, channels,
// tokens, audit_events. Re-saving an unchanged model yields byte-identical
// output.
//
// Enums serialize as their lowercase variant NAME (e.g. "android_apk",
// "percentage"), never an ordinal — readable and reorder-proof.
//
// READ BACK is strict: a missing field, a wrong JSON type, or an
// unrecognized enum name surfaces as a typed `LoadErr.BadShape` — never a
// silent default. Decoded string fields are COPIED into the loaded repo's
// own allocator, so the reloaded model does not alias the parse scratch or
// the source buffer.
// =====================================================================

#import "modules/std.sx";
#import "modules/std/json.sx";
// Also reached through an alias so the json reader is called as `jsonp.parse`
// — a bare `parse` would bind to `std.cli`'s `parse` once both modules share
// one program (the `dist` CLI), which returns a different type.
jsonp :: #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.sx";

// Persistence failure classes. `Io` = db.json could not be written/read;
// `Parse` = the bytes were not valid JSON; `BadShape` = valid JSON whose
// structure/types/enum-names don't match the model (a missing or
// wrong-typed field, or an unknown enum variant).
LoadErr :: error {
    Io,
    Parse,
    BadShape,
}

// ── enum -> stable variant name ──────────────────────────────────────
visibility_str :: (v: Visibility) -> string {
    if v == .private  { return "private"; }
    if v == .unlisted { return "unlisted"; }
    return "public";
}
platform_str :: (p: Platform) -> string {
    if p == .ios         { return "ios"; }
    if p == .android_apk { return "android_apk"; }
    if p == .macos       { return "macos"; }
    if p == .linux       { return "linux"; }
    return "windows";
}
policy_str :: (p: RolloutPolicy) -> string {
    if p == .manual { return "manual"; }
    return "percentage";
}
ios_mode_str :: (m: IosMode) -> string {
    if m == .testflight { return "testflight"; }
    if m == .enterprise { return "enterprise"; }
    return "artifact_only";
}
status_str :: (s: ValidationStatus) -> string {
    if s == .pending { return "pending"; }
    if s == .valid   { return "valid"; }
    return "invalid";
}

// ── variant name -> enum (typed failure on an unknown name) ──────────
parse_visibility :: (s: string) -> (Visibility, !LoadErr) {
    if s == "private"  { return .private; }
    if s == "unlisted" { return .unlisted; }
    if s == "public"   { return .public; }
    raise error.BadShape;
}
platform_from :: (s: string) -> (Platform, !LoadErr) {
    p, e := parse_platform(s);     // reuse the domain parser
    if e { raise error.BadShape; }
    return p;
}
parse_policy :: (s: string) -> (RolloutPolicy, !LoadErr) {
    if s == "manual"     { return .manual; }
    if s == "percentage" { return .percentage; }
    raise error.BadShape;
}
ios_mode_from :: (s: string) -> (IosMode, !LoadErr) {
    m, e := parse_ios_mode(s);     // reuse the domain parser
    if e { raise error.BadShape; }
    return m;
}
parse_status :: (s: string) -> (ValidationStatus, !LoadErr) {
    if s == "pending" { return .pending; }
    if s == "valid"   { return .valid; }
    if s == "invalid" { return .invalid; }
    raise error.BadShape;
}

// ── serialize: entity -> json Value (declaration field order) ────────
app_to_json :: (a: App, alloc: Allocator) -> Value {
    o : Object = .{};
    o.put("id", .str(a.id), alloc);
    o.put("slug", .str(a.slug), alloc);
    o.put("display_name", .str(a.display_name), alloc);
    bids : Array = .{};
    i := 0;
    while i < a.bundle_ids.len {
        b := a.bundle_ids.items[i];
        bo : Object = .{};
        bo.put("platform", .str(platform_str(b.platform)), alloc);
        bo.put("value", .str(b.value), alloc);
        bids.add(.object(bo), alloc);
        i += 1;
    }
    o.put("bundle_ids", .array(bids), alloc);
    o.put("owner", .str(a.owner), alloc);
    o.put("visibility", .str(visibility_str(a.visibility)), alloc);
    o.put("ios_mode", .str(ios_mode_str(a.ios_mode)), alloc);
    o.put("testflight_url", .str(a.testflight_url), alloc);
    o.put("created_at", .int_(a.created_at), alloc);
    o.put("updated_at", .int_(a.updated_at), alloc);
    return .object(o);
}

release_to_json :: (r: Release, alloc: Allocator) -> Value {
    o : Object = .{};
    o.put("id", .str(r.id), alloc);
    o.put("app_id", .str(r.app_id), alloc);
    o.put("version", .str(r.version), alloc);
    o.put("build", .int_(r.build), alloc);
    o.put("channel", .str(r.channel), alloc);
    o.put("notes", .str(r.notes), alloc);
    o.put("created_by", .str(r.created_by), alloc);
    o.put("created_at", .int_(r.created_at), alloc);
    o.put("published_at", .int_(r.published_at), alloc);
    return .object(o);
}

artifact_to_json :: (a: Artifact, alloc: Allocator) -> Value {
    o : Object = .{};
    o.put("id", .str(a.id), alloc);
    o.put("app_id", .str(a.app_id), alloc);
    o.put("release_id", .str(a.release_id), alloc);
    o.put("platform", .str(platform_str(a.platform)), alloc);
    o.put("filename", .str(a.filename), alloc);
    o.put("content_type", .str(a.content_type), alloc);
    o.put("size_bytes", .int_(a.size_bytes), alloc);
    o.put("sha256", .str(a.sha256), alloc);
    o.put("storage_key", .str(a.storage_key), alloc);
    o.put("metadata", .str(a.metadata), alloc);
    o.put("validation_status", .str(status_str(a.validation_status)), alloc);
    return .object(o);
}

channel_to_json :: (c: Channel, alloc: Allocator) -> Value {
    o : Object = .{};
    o.put("app_id", .str(c.app_id), alloc);
    o.put("name", .str(c.name), alloc);
    o.put("current_release_id", .str(c.current_release_id), alloc);
    o.put("policy", .str(policy_str(c.policy)), alloc);
    o.put("rollout_percent", .int_(c.rollout_percent), alloc);
    return .object(o);
}

token_to_json :: (t: Token, alloc: Allocator) -> Value {
    o : Object = .{};
    o.put("id", .str(t.id), alloc);
    o.put("name", .str(t.name), alloc);
    o.put("token_hash", .str(t.token_hash), alloc);
    o.put("scopes", .str(t.scopes), alloc);
    o.put("app_slug", .str(t.app_slug), alloc);
    o.put("channel", .str(t.channel), alloc);
    o.put("created_at", .int_(t.created_at), alloc);
    o.put("expires_at", .int_(t.expires_at), alloc);
    o.put("last_used_at", .int_(t.last_used_at), alloc);
    o.put("revoked_at", .int_(t.revoked_at), alloc);
    return .object(o);
}

audit_to_json :: (e: AuditEvent, alloc: Allocator) -> Value {
    o : Object = .{};
    o.put("id", .str(e.id), alloc);
    o.put("actor", .str(e.actor), alloc);
    o.put("action", .str(e.action), alloc);
    o.put("target_type", .str(e.target_type), alloc);
    o.put("target_id", .str(e.target_id), alloc);
    o.put("metadata", .str(e.metadata), alloc);
    o.put("created_at", .int_(e.created_at), alloc);
    return .object(o);
}

// Build the whole model as one json Value: a top-level object whose five
// members are arrays of entity objects, in the fixed order documented above.
model_to_json :: (self: *Repo, alloc: Allocator) -> Value {
    root : Object = .{};

    apps : Array = .{};
    i := 0;
    while i < self.apps.len { apps.add(app_to_json(self.apps.items[i], alloc), alloc); i += 1; }
    root.put("apps", .array(apps), alloc);

    rels : Array = .{};
    i = 0;
    while i < self.releases.len { rels.add(release_to_json(self.releases.items[i], alloc), alloc); i += 1; }
    root.put("releases", .array(rels), alloc);

    arts : Array = .{};
    i = 0;
    while i < self.artifacts.len { arts.add(artifact_to_json(self.artifacts.items[i], alloc), alloc); i += 1; }
    root.put("artifacts", .array(arts), alloc);

    chans : Array = .{};
    i = 0;
    while i < self.channels.len { chans.add(channel_to_json(self.channels.items[i], alloc), alloc); i += 1; }
    root.put("channels", .array(chans), alloc);

    toks : Array = .{};
    i = 0;
    while i < self.tokens.len { toks.add(token_to_json(self.tokens.items[i], alloc), alloc); i += 1; }
    root.put("tokens", .array(toks), alloc);

    evs : Array = .{};
    i = 0;
    while i < self.audit_events.len { evs.add(audit_to_json(self.audit_events.items[i], alloc), alloc); i += 1; }
    root.put("audit_events", .array(evs), alloc);

    return .object(root);
}

// Serialize the whole repo to `<root>/db.json`. The json value tree is
// built in a local arena (freed on return) and STREAMED to the file
// through a fixed staging buffer, so no whole-document string is held.
save :: (self: *Repo, root_dir: string) -> !LoadErr {
    if !create_dir_all(root_dir) { raise error.Io; }

    gpa := GPA.init();
    arena := Arena.init(xx gpa, 65536);
    defer arena.deinit();

    root_val := model_to_json(self, xx arena);

    path := path_join(root_dir, "db.json");
    fh := open_file(path, .write);
    if fh == null { raise error.Io; }
    f := fh!;
    stage : [4096]u8 = ---;
    werr := false;
    write_to_file(root_val, @f, string.{ ptr = @stage[0], len = 4096 }) catch { werr = true; };
    f.close();
    if werr { raise error.Io; }
    return;
}

// ── read-back helpers (strict; copy strings into `alloc`) ────────────
// These carry a `db_` prefix because the `dist` program links this module
// alongside `manifest.sx`, which declares its own same-purpose `dup_str` /
// `obj_find` / `req_obj` / `req_arr` / `artifact_from_json`. sx resolves a
// bare top-level name across the WHOLE program (see the `jsonp` note above),
// so an unprefixed name here would bind to manifest's version and mismatch
// this module's `LoadErr` error set. The prefix keeps the load path bound to
// its own helpers.

// Copy `s` into `alloc`-owned, null-terminated storage so it survives the
// parse scratch / source buffer being freed.
db_dup_str :: (s: string, alloc: Allocator) -> string {
    raw : [*]u8 = xx alloc.alloc_bytes(s.len + 1);
    if s.len > 0 { memcpy(raw, s.ptr, s.len); }
    raw[s.len] = 0;
    return string.{ ptr = raw, len = s.len };
}

db_obj_find :: (o: Object, key: string) -> ?Value {
    i := 0;
    while i < o.len {
        if o.items[i].key == key { return o.items[i].val; }
        i += 1;
    }
    return null;
}

// Required string field, copied into `alloc`.
req_str :: (o: Object, key: string, alloc: Allocator) -> (string, !LoadErr) {
    v := db_obj_find(o, key);
    if v == null { raise error.BadShape; }
    val := v!;
    if val != .str { raise error.BadShape; }
    return db_dup_str(val.str, alloc);
}

// Required string field as a borrowed VIEW (for enum-name parsing only;
// not stored, so no copy needed).
req_str_view :: (o: Object, key: string) -> (string, !LoadErr) {
    v := db_obj_find(o, key);
    if v == null { raise error.BadShape; }
    val := v!;
    if val != .str { raise error.BadShape; }
    return val.str;
}

req_int :: (o: Object, key: string) -> (i64, !LoadErr) {
    v := db_obj_find(o, key);
    if v == null { raise error.BadShape; }
    val := v!;
    if val != .int_ { raise error.BadShape; }
    return val.int_;
}

db_req_arr :: (o: Object, key: string) -> (Array, !LoadErr) {
    v := db_obj_find(o, key);
    if v == null { raise error.BadShape; }
    val := v!;
    if val != .array { raise error.BadShape; }
    return val.array;
}

db_req_obj :: (v: Value) -> (Object, !LoadErr) {
    if v != .object { raise error.BadShape; }
    return v.object;
}

// ── deserialize: json Object -> entity (strings copied into `alloc`) ──
app_from_json :: (o: Object, alloc: Allocator) -> (App, !LoadErr) {
    a : App = .{};
    a.id           = try req_str(o, "id", alloc);
    a.slug         = try req_str(o, "slug", alloc);
    a.display_name = try req_str(o, "display_name", alloc);
    bids := try db_req_arr(o, "bundle_ids");
    i := 0;
    while i < bids.len {
        bo := try db_req_obj(bids.items[i]);
        p  := try platform_from(try req_str_view(bo, "platform"));
        bid : BundleId = .{ platform = p, value = try req_str(bo, "value", alloc) };
        a.bundle_ids.append(bid, alloc);
        i += 1;
    }
    a.owner      = try req_str(o, "owner", alloc);
    a.visibility = try parse_visibility(try req_str_view(o, "visibility"));
    // OPTIONAL members (absent in db.json files from before the iOS
    // install policy landed): default artifact_only / "". Present members
    // are held to full strictness.
    imq := db_obj_find(o, "ios_mode");
    if imq != null {
        imv := imq!;
        if imv != .str { raise error.BadShape; }
        a.ios_mode = try ios_mode_from(imv.str);
    }
    tfq := db_obj_find(o, "testflight_url");
    if tfq != null {
        tfv := tfq!;
        if tfv != .str { raise error.BadShape; }
        a.testflight_url = db_dup_str(tfv.str, alloc);
    }
    a.created_at = try req_int(o, "created_at");
    a.updated_at = try req_int(o, "updated_at");
    return a;
}

release_from_json :: (o: Object, alloc: Allocator) -> (Release, !LoadErr) {
    r : Release = .{};
    r.id           = try req_str(o, "id", alloc);
    r.app_id       = try req_str(o, "app_id", alloc);
    r.version      = try req_str(o, "version", alloc);
    r.build        = try req_int(o, "build");
    r.channel      = try req_str(o, "channel", alloc);
    r.notes        = try req_str(o, "notes", alloc);
    r.created_by   = try req_str(o, "created_by", alloc);
    r.created_at   = try req_int(o, "created_at");
    r.published_at = try req_int(o, "published_at");
    return r;
}

db_artifact_from_json :: (o: Object, alloc: Allocator) -> (Artifact, !LoadErr) {
    a : Artifact = .{};
    a.id           = try req_str(o, "id", alloc);
    a.app_id       = try req_str(o, "app_id", alloc);
    a.release_id   = try req_str(o, "release_id", alloc);
    a.platform     = try platform_from(try req_str_view(o, "platform"));
    a.filename     = try req_str(o, "filename", alloc);
    a.content_type = try req_str(o, "content_type", alloc);
    a.size_bytes   = try req_int(o, "size_bytes");
    a.sha256       = try req_str(o, "sha256", alloc);
    a.storage_key  = try req_str(o, "storage_key", alloc);
    a.metadata     = try req_str(o, "metadata", alloc);
    a.validation_status = try parse_status(try req_str_view(o, "validation_status"));
    return a;
}

channel_from_json :: (o: Object, alloc: Allocator) -> (Channel, !LoadErr) {
    c : Channel = .{};
    c.app_id             = try req_str(o, "app_id", alloc);
    c.name               = try req_str(o, "name", alloc);
    c.current_release_id = try req_str(o, "current_release_id", alloc);
    c.policy             = try parse_policy(try req_str_view(o, "policy"));
    c.rollout_percent    = try req_int(o, "rollout_percent");
    return c;
}

token_from_json :: (o: Object, alloc: Allocator) -> (Token, !LoadErr) {
    t : Token = .{};
    t.id           = try req_str(o, "id", alloc);
    t.name         = try req_str(o, "name", alloc);
    t.token_hash   = try req_str(o, "token_hash", alloc);
    t.scopes       = try req_str(o, "scopes", alloc);
    t.app_slug     = try req_str(o, "app_slug", alloc);
    t.channel      = try req_str(o, "channel", alloc);
    t.created_at   = try req_int(o, "created_at");
    t.expires_at   = try req_int(o, "expires_at");
    t.last_used_at = try req_int(o, "last_used_at");
    t.revoked_at   = try req_int(o, "revoked_at");
    return t;
}

audit_from_json :: (o: Object, alloc: Allocator) -> (AuditEvent, !LoadErr) {
    e : AuditEvent = .{};
    e.id          = try req_str(o, "id", alloc);
    e.actor       = try req_str(o, "actor", alloc);
    e.action      = try req_str(o, "action", alloc);
    e.target_type = try req_str(o, "target_type", alloc);
    e.target_id   = try req_str(o, "target_id", alloc);
    e.metadata    = try req_str(o, "metadata", alloc);
    e.created_at  = try req_int(o, "created_at");
    return e;
}

// Parse `bytes` and fill `repo` via its create_* methods (which forward the
// repo's own allocator). All string fields are copied into that allocator.
load_into :: (repo: *Repo, bytes: string, scratch: Allocator) -> !LoadErr {
    oa := repo.own_allocator;

    root_val, pe := jsonp.parse(bytes, scratch);
    if pe { raise error.Parse; }
    ro := try db_req_obj(root_val);

    apps_arr := try db_req_arr(ro, "apps");
    i := 0;
    while i < apps_arr.len {
        ao := try db_req_obj(apps_arr.items[i]);
        repo.create_app(try app_from_json(ao, oa));
        i += 1;
    }

    rel_arr := try db_req_arr(ro, "releases");
    i = 0;
    while i < rel_arr.len {
        o := try db_req_obj(rel_arr.items[i]);
        repo.create_release(try release_from_json(o, oa));
        i += 1;
    }

    art_arr := try db_req_arr(ro, "artifacts");
    i = 0;
    while i < art_arr.len {
        o := try db_req_obj(art_arr.items[i]);
        repo.create_artifact(try db_artifact_from_json(o, oa));
        i += 1;
    }

    chan_arr := try db_req_arr(ro, "channels");
    i = 0;
    while i < chan_arr.len {
        o := try db_req_obj(chan_arr.items[i]);
        repo.create_channel(try channel_from_json(o, oa));
        i += 1;
    }

    // OPTIONAL member: an absent `tokens` array loads as zero tokens, so
    // db.json files from layouts without tokens stay readable. A PRESENT
    // member is held to the same strictness as everything else.
    tokq := db_obj_find(ro, "tokens");
    if tokq != null {
        tokv := tokq!;
        if tokv != .array { raise error.BadShape; }
        tok_arr := tokv.array;
        i = 0;
        while i < tok_arr.len {
            o := try db_req_obj(tok_arr.items[i]);
            repo.create_token(try token_from_json(o, oa));
            i += 1;
        }
    }

    ev_arr := try db_req_arr(ro, "audit_events");
    i = 0;
    while i < ev_arr.len {
        o := try db_req_obj(ev_arr.items[i]);
        repo.create_audit_event(try audit_from_json(o, oa));
        i += 1;
    }
    return;
}

// Load `<root>/db.json` into a FRESH repository. The new repo captures the
// active `context.allocator` as its owning allocator; all entity strings
// are copied into it, so the result is independent of the file bytes and
// the parse scratch (both freed before this returns).
load :: (root_dir: string) -> (Repo, !LoadErr) {
    path := path_join(root_dir, "db.json");
    src := read_file(path);
    if src == null { raise error.Io; }
    bytes := src!;

    gpa := GPA.init();
    arena := Arena.init(xx gpa, 65536);
    defer arena.deinit();

    repo := Repo.init();
    try load_into(@repo, bytes, xx arena);
    return repo;
}
