// =====================================================================
// ops.sx — standalone channel operations over the persisted store
// (subplan 03 / P3.5): `dist release promote` and `dist release rollback`.
//
// Both load `<store>/db.json`, mutate ONE channel pointer, append an audit
// event, and re-persist. They are the human counterpart to the CI publish:
// CI writes releases; a release manager moves channel pointers.
//
// PROMOTE points an (app, channel) at a given release id. The release must
// exist and belong to the app; it does NOT have to target that channel —
// promotion across channels (a beta release promoted onto stable) is the
// point of the command. A missing channel is created. The policy gate is
// the v0 stub: `.manual` always allows.
//
// ROLLBACK moves the channel pointer to the PREVIOUS valid release for
// that channel. "Previous" is publish order: among the app's PUBLISHED
// releases targeting this channel (`release.channel == name`,
// `published_at > 0` — rollback never selects an unpublished release), the
// one immediately before the current pointer. When the current pointer is
// not in that lineage (it was cross-promoted from another channel),
// rollback returns to the channel's own latest release. At the earliest
// release — or with no lineage at all — there is nothing to roll back to.
//
// FAILURE CONTRACT (mirrors P3.4b): 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/audit.sx";
#import "../domain/validate.sx";
#import "../repo/repo.sx";
db   :: #import "../repo/db.sx";
jout :: #import "../json_out.sx";
pl   :: #import "../publish/publish.sx";

// Failure classes for a channel operation. The precise reason travels in
// the caller's `jout.CliFailure` (see the failure contract above).
//   Load     — db.json absent or unreadable (no publishable state).
//   NotFound — the named app / release / channel does not exist.
//   Invalid  — the aggregate is inconsistent (release of another app,
//              channel that fails domain validation, nothing to roll
//              back to).
//   Persist  — db.json could not be re-written.
OpError :: error {
    Load,
    NotFound,
    Invalid,
    Persist,
}

PromoteOutcome :: struct {
    app_id: string;
    channel: string;
    release_id: string;
    version: string;
    previous_release_id: string;   // "" when the channel was just created
}

RollbackOutcome :: struct {
    app_id: string;
    channel: string;
    from_release_id: string;
    to_release_id: string;
    to_version: string;
}

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

// Load the persisted model, or fail with `store.load` when the store has
// no db.json (nothing was ever published there).
op_load_repo :: (store_dir: string, fail_out: *jout.CliFailure) -> (Repo, !OpError) {
    if !exists(path_join(store_dir, "db.json")) {
        fail_out.code    = "store.load";
        fail_out.message = concat("no db.json under the store (nothing published yet): ", store_dir);
        raise error.Load;
    }
    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;
}

op_find_app :: (repo: *Repo, slug: string, code: string, fail_out: *jout.CliFailure) -> (App, !OpError) {
    a := repo.find_app_by_slug(slug);
    if a == null {
        fail_out.code    = code;
        fail_out.message = concat("no app with that slug in the store: ", slug);
        raise error.NotFound;
    }
    return a!;
}

op_save :: (repo: *Repo, store_dir: string, fail_out: *jout.CliFailure) -> !OpError {
    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;
}

// ── promote ───────────────────────────────────────────────────────────

run_promote :: (store_dir: string, app_slug: string, channel_name: string, release_id: string, fail_out: *jout.CliFailure) -> (PromoteOutcome, !OpError) {
    repo := try op_load_repo(store_dir, fail_out);
    app  := try op_find_app(@repo, app_slug, "promote.unknown_app", fail_out);

    relq := repo.get_release(release_id);
    if relq == null {
        fail_out.code    = "promote.unknown_release";
        fail_out.message = concat("no release with that id in the store: ", release_id);
        raise error.NotFound;
    }
    rel := relq!;
    if rel.app_id != app.id {
        fail_out.code    = "promote.wrong_app";
        fail_out.message = concat("release belongs to a different app: ", release_id);
        raise error.Invalid;
    }

    // Policy gate (v0 stub): `.manual` always allows. A real gate (stable
    // requires passed validations, percentage rollouts) lands with policies.
    prev := "";
    chan : Channel = .{};
    cq := repo.get_channel(app.id, channel_name);
    if cq == null {
        chan = Channel.{
            app_id = app.id, name = channel_name, current_release_id = release_id,
            policy = .manual, rollout_percent = 100,
        };
    } else {
        chan = cq!;
        prev = chan.current_release_id;
        chan.current_release_id = release_id;
    }
    cverr := false;
    validate_channel(chan) catch { cverr = true; };
    if cverr {
        fail_out.code    = "promote.bad_channel";
        fail_out.message = concat("channel fails domain validation: ", channel_name);
        raise error.Invalid;
    }
    if cq == null { repo.create_channel(chan); }
    else { repo.update_channel(chan); }

    now := pl.now_secs();
    repo.create_audit_event(AuditEvent.{
        id = concat(concat(concat(concat("evt-cli-promote-", app.id), "-"), channel_name), concat("-", release_id)),
        actor = "cli", action = "channel.promote", target_type = "channel",
        target_id = channel_name, metadata = release_id, created_at = now,
    });

    try op_save(@repo, store_dir, fail_out);

    return PromoteOutcome.{
        app_id = app.id, channel = channel_name,
        release_id = release_id, version = rel.version,
        previous_release_id = prev,
    };
}

// ── rollback ──────────────────────────────────────────────────────────

run_rollback :: (store_dir: string, app_slug: string, channel_name: string, fail_out: *jout.CliFailure) -> (RollbackOutcome, !OpError) {
    repo := try op_load_repo(store_dir, fail_out);
    app  := try op_find_app(@repo, app_slug, "rollback.unknown_app", fail_out);

    cq := repo.get_channel(app.id, channel_name);
    if cq == null {
        fail_out.code    = "rollback.unknown_channel";
        fail_out.message = concat("the app has no channel with that name: ", channel_name);
        raise error.NotFound;
    }
    chan := cq!;
    from := chan.current_release_id;

    // The channel's lineage: the app's PUBLISHED releases targeting this
    // channel, in publish (insertion) order. The target is the entry just
    // before the current pointer — or the latest entry when the pointer
    // was cross-promoted from another channel's lineage.
    target_id := "";
    target_version := "";
    prev_id := "";
    prev_version := "";
    found_current := false;
    i := 0;
    while i < repo.releases.len {
        r := repo.releases.items[i];
        if r.app_id == app.id and r.channel == channel_name and r.published_at > 0 {
            if r.id == from {
                found_current = true;
                target_id = prev_id;
                target_version = prev_version;
                break;
            }
            prev_id = r.id;
            prev_version = r.version;
        }
        i += 1;
    }
    if !found_current {
        target_id = prev_id;          // latest of the channel's own lineage
        target_version = prev_version;
    }
    if target_id.len == 0 {
        fail_out.code    = "rollback.no_previous";
        fail_out.message = concat("no previous published release to roll back to on channel: ", channel_name);
        raise error.Invalid;
    }

    chan.current_release_id = target_id;
    repo.update_channel(chan);

    now := pl.now_secs();
    repo.create_audit_event(AuditEvent.{
        id = concat(concat(concat(concat("evt-cli-rollback-", app.id), "-"), channel_name), concat("-", target_id)),
        actor = "cli", action = "channel.rollback", target_type = "channel",
        target_id = channel_name, metadata = target_id, created_at = now,
    });

    try op_save(@repo, store_dir, fail_out);

    return RollbackOutcome.{
        app_id = app.id, channel = channel_name,
        from_release_id = from,
        to_release_id = target_id, to_version = target_version,
    };
}

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

// `{"status":"promoted","app_id":...,"channel":...,"release":{"id":...,
// "version":...},"previous_release_id":...}` — member order fixed by
// `std.json`'s insertion-order guarantee.
write_promote_json :: (o: *PromoteOutcome, dst: []u8) -> (i64, !JsonError) {
    gpa := GPA.init();
    root : Object = .{};
    root.put("status", .str("promoted"), xx gpa);
    root.put("app_id", .str(o.app_id), xx gpa);
    root.put("channel", .str(o.channel), xx gpa);
    rel : Object = .{};
    rel.put("id", .str(o.release_id), xx gpa);
    rel.put("version", .str(o.version), xx gpa);
    root.put("release", .object(rel), xx gpa);
    root.put("previous_release_id", .str(o.previous_release_id), xx gpa);
    rootv : Value = .object(root);
    n := try write_to_buffer(rootv, dst);
    return n;
}

// `{"status":"rolled_back","app_id":...,"channel":...,"from_release_id":...,
// "to":{"id":...,"version":...}}`.
write_rollback_json :: (o: *RollbackOutcome, dst: []u8) -> (i64, !JsonError) {
    gpa := GPA.init();
    root : Object = .{};
    root.put("status", .str("rolled_back"), xx gpa);
    root.put("app_id", .str(o.app_id), xx gpa);
    root.put("channel", .str(o.channel), xx gpa);
    root.put("from_release_id", .str(o.from_release_id), xx gpa);
    to : Object = .{};
    to.put("id", .str(o.to_release_id), xx gpa);
    to.put("version", .str(o.to_version), xx gpa);
    root.put("to", .object(to), xx gpa);
    rootv : Value = .object(root);
    n := try write_to_buffer(rootv, dst);
    return n;
}

promote_human :: (o: *PromoteOutcome) -> string {
    s := concat("promoted ", o.release_id);
    s = concat(s, concat(" (", concat(o.version, ")")));
    s = concat(s, concat(" onto channel ", o.channel));
    if o.previous_release_id.len > 0 {
        s = concat(s, concat(" (was ", concat(o.previous_release_id, ")")));
    }
    return concat(s, "\n");
}

rollback_human :: (o: *RollbackOutcome) -> string {
    s := concat("rolled back channel ", o.channel);
    s = concat(s, concat(": ", o.from_release_id));
    s = concat(s, concat(" -> ", o.to_release_id));
    s = concat(s, concat(" (", concat(o.to_version, ")")));
    return concat(s, "\n");
}
