// =====================================================================
// dist.sx — the `dist` distribution CLI entry point (subplan 03, Slice 1).
//
// Wires the real process argv (via `std.cli`'s `os_args`) to subcommand
// handlers through `cli.parse`.
//
//   dist ci publish        local publish pipeline (P3.4a/b) — see publish.sx
//   dist release promote   point a channel at a release (P3.5) — see release/ops.sx
//   dist release rollback  channel pointer to the previous release (P3.5)
//   dist server run        HTTP API over the store: public reads (P4.1),
//                          token-gated writes (P4.4) — see server/distd.sx
//   dist token create      mint a scoped CI token, secret shown once (P4.3) — see token/ops.sx
//   dist token list        tokens with lifecycle status, never the secret (P4.3)
//   dist token revoke      revoke a token by id (P4.3)
//
// EXIT-CODE CONTRACT (sysexits, via std.cli): success ends with
// `exit_ok()` (EX_OK = 0); a no-command / unknown-or-missing
// group/command/flag ends with `exit_usage()` (EX_USAGE = 64). An
// explicit `-h`/`--help` is not an error and ends 0. A command that parsed
// correctly but FAILED in execution (publish abort) ends 1 — under `--json`
// the failure is also a single machine-readable error object on stdout
// (`{"status":"error","error":{code,message}}`).
//
// `--json` PURITY: every command accepts the reserved global `--json`
// flag (surfaced by the parser as `parsed.json`). In json mode stdout
// carries ONLY the machine-readable JSON object (emitted via `std.json`,
// isolated in `json_out.sx` / `publish.sx`); ALL human-readable text (help,
// progress, errors) goes to stderr. In non-json mode human text on stdout
// is fine.
// =====================================================================

#import "modules/std.sx";
#import "modules/std/cli.sx";
jout :: #import "json_out.sx";
pl   :: #import "publish/publish.sx";
rem  :: #import "publish/remote.sx";
ops  :: #import "release/ops.sx";
tops :: #import "token/ops.sx";
aops :: #import "app/ops.sx";
srv  :: #import "server/distd.sx";

// Direct stderr writer (fd 2), so human help/usage/progress never lands on
// stdout's data stream. `out` (std builtin) targets stdout (fd 1).
cstdlib  :: #library "c";
fd_write :: (fd: i32, buf: [*]u8, count: usize) -> isize #foreign cstdlib "write";

eputs :: (s: string) { if s.len > 0 { fd_write(2, s.ptr, xx s.len); } }

// Human text router: stderr in json mode (keep stdout pure), stdout
// otherwise.
emit_human :: (s: string, json_mode: bool) {
    if json_mode { eputs(s); } else { out(s); }
}

HELP :: "dist — application distribution CLI\n\nUsage:\n  dist <group> <command> [flags]\n\nGroups & commands:\n  ci\n    ci publish            publish a release from CI\n      --manifest <path>     publish manifest (dist.json) to read\n      --local-store <dir>   publish into a local store directory, OR:\n      --server <url>        publish against a running distd (http://<ipv4-or-localhost>:<port>)\n      --token <secret>      bearer token for --server (mint with: dist token create)\n  release\n    release promote       point a channel at a release\n      --app <slug>          app the channel belongs to\n      --channel <name>      channel to move\n      --release <id>        release id to promote\n      --local-store <dir>   local artifact store + db.json directory\n    release rollback      move a channel back to its previous release\n      --app <slug>          app the channel belongs to\n      --channel <name>      channel to roll back\n      --local-store <dir>   local artifact store + db.json directory\n  server\n    server run            serve the store over HTTP (0.0.0.0)\n      --local-store <dir>   local artifact store + db.json directory\n      --port <n>            TCP port (default 8787)\n      GET (public): / (HTML index), /healthz, /api/apps, /api/apps/<slug>, /download/<sha256>\n      POST (Bearer token, publish scope): /api/upload, /api/apps/<slug>/releases,\n        /api/apps/<slug>/channels/<name>/promote, /api/apps/<slug>/channels/<name>/rollback\n  app\n    app set               edit an existing app's display name / iOS install policy\n      --app <slug>          app to edit (apps are created by publish)\n      --local-store <dir>   local artifact store + db.json directory\n      --display-name <s>    new display name\n      --ios-mode <m>        artifact_only | testflight | enterprise\n      --testflight-url <u>  TestFlight link (required for testflight mode)\n      --ios-bundle-id <id>  iOS bundle identifier (required for enterprise mode)\n  token\n    token create          mint a scoped automation token (secret shown ONCE)\n      --name <name>         token name, [a-z0-9._-]\n      --local-store <dir>   local artifact store + db.json directory\n      --scope <words>       space-separated scopes: publish read (default: publish)\n      --app <slug>          restrict to one app (default: any)\n      --channel <name>      restrict to one channel (default: any)\n      --expires-in <secs>   lifetime in seconds (default: never expires)\n    token list            tokens with lifecycle status (never the secret)\n      --local-store <dir>   local artifact store + db.json directory\n    token revoke          revoke a token by id\n      --id <token-id>       token to revoke\n      --local-store <dir>   local artifact store + db.json directory\n\nGlobal flags:\n  --json                  emit machine-readable JSON on stdout; human text to stderr\n  -h, --help              show this help and exit\n\nExit codes:\n  0   success\n  1   command failed (publish/promote/rollback/token op aborted or server could not bind)\n  64  usage error (no command, or an unknown/missing command or flag)\n";

// True if `name` appears as a token in `args`.
has_flag :: (args: []string, name: string) -> bool {
    i := 0;
    while i < args.len {
        if args[i] == name { return true; }
        i += 1;
    }
    return false;
}

// Human phrase for a parser failure, used in the stderr diagnostic.
error_phrase :: (e: CliError) -> string {
    if e == error.UnknownCommand  { return "unknown or missing command"; }
    if e == error.UnknownFlag     { return "unknown flag"; }
    if e == error.MissingValue    { return "flag is missing its value"; }
    if e == error.MissingRequired { return "missing required flag"; }
    if e == error.TooManyFlags    { return "too many flags for command"; }
    return "usage error";
}

// ── Handlers ─────────────────────────────────────────────────────────

// A command that parsed correctly but failed in execution exits 1 —
// distinct from the parser's EX_USAGE (64), so CI can tell "you called it
// wrong" from "the publish itself aborted".
exit_command_failed :: () -> noreturn { process.exit(1); }

// Report a failed command and exit 1: the human sentence goes to stderr;
// under `--json` the machine-readable error object is the only thing on
// stdout (the purity contract holds on failure paths too).
report_failure :: (cmd: string, fail: jout.CliFailure, json_mode: bool) -> noreturn {
    eputs(concat(concat(concat(concat("dist: ", cmd), " failed: "), fail.message), "\n"));
    if json_mode {
        raw : [4096]u8 = ---;
        werr := false;
        n := jout.write_error(fail, string.{ ptr = @raw[0], len = 4096 }) catch { werr = true; 0 };
        if !werr {
            out(string.{ ptr = @raw[0], len = n });
            out("\n");
        }
    }
    exit_command_failed();
}

// `dist ci publish` — the publish pipeline (P3.4a local / P4.5 remote).
// `--local-store <dir>` publishes into a local store; `--server <url>
// --token <secret>` publishes against a running distd. Both produce the
// same outcome shape, rendered identically: in json mode a single stable
// JSON object on stdout with the human progress note on stderr (the
// `--json` purity contract); otherwise a readable summary on stdout. A
// failed publish reports through `report_failure` (stderr sentence, JSON
// error object under --json, exit 1).
handle_ci_publish :: (p: *Parsed, json_mode: bool) {
    manifest_path := p.value_of("manifest");
    has_local  := p.is_set("local-store");
    has_server := p.is_set("server");
    if has_local == has_server {
        eputs("dist: ci publish needs exactly one of --local-store <dir> or --server <url>\n");
        exit_usage();
    }
    if has_server and !p.is_set("token") {
        eputs("dist: --server requires --token <secret>\n");
        exit_usage();
    }

    fail : jout.CliFailure = .{};
    o : pl.PublishOutcome = .{};
    e := false;
    if has_local {
        lo, le := pl.run_publish(manifest_path, p.value_of("local-store"), @fail);
        if le { e = true; }
        if !le { o = lo; }
    } else {
        ro, rerr := rem.run_remote_publish(manifest_path, p.value_of("server"), p.value_of("token"), @fail);
        if rerr { e = true; }
        if !rerr { o = ro; }
    }
    if e {
        report_failure("ci publish", fail, json_mode);
    }

    if !e {
        if !json_mode {
            out(pl.human_summary(@o));
            return;
        }

        eputs("dist: ci publish ok\n");
        raw : [16384]u8 = ---;
        werr := false;
        n := pl.write_json(@o, string.{ ptr = @raw[0], len = 16384 }) catch { werr = true; 0 };
        if werr {
            eputs("dist: internal error: JSON serialization failed\n");
            exit_usage();
        }
        out(string.{ ptr = @raw[0], len = n });
        out("\n");
    }
}

// `dist release promote` — point an (app, channel) at a release over the
// persisted store (P3.5). Same rendering contract as publish: JSON object
// on stdout under --json (human note on stderr), readable summary
// otherwise, `report_failure` on abort.
handle_release_promote :: (p: *Parsed, json_mode: bool) {
    fail : jout.CliFailure = .{};
    o, e := ops.run_promote(p.value_of("local-store"), p.value_of("app"),
                            p.value_of("channel"), p.value_of("release"), @fail);
    if e {
        report_failure("release promote", fail, json_mode);
    }
    if !e {
        if !json_mode {
            out(ops.promote_human(@o));
            return;
        }
        eputs("dist: release promote ok\n");
        raw : [4096]u8 = ---;
        werr := false;
        n := ops.write_promote_json(@o, string.{ ptr = @raw[0], len = 4096 }) catch { werr = true; 0 };
        if werr {
            eputs("dist: internal error: JSON serialization failed\n");
            exit_command_failed();
        }
        out(string.{ ptr = @raw[0], len = n });
        out("\n");
    }
}

// `dist release rollback` — move an (app, channel) back to its previous
// published release (P3.5). Rendering contract as above.
handle_release_rollback :: (p: *Parsed, json_mode: bool) {
    fail : jout.CliFailure = .{};
    o, e := ops.run_rollback(p.value_of("local-store"), p.value_of("app"),
                             p.value_of("channel"), @fail);
    if e {
        report_failure("release rollback", fail, json_mode);
    }
    if !e {
        if !json_mode {
            out(ops.rollback_human(@o));
            return;
        }
        eputs("dist: release rollback ok\n");
        raw : [4096]u8 = ---;
        werr := false;
        n := ops.write_rollback_json(@o, string.{ ptr = @raw[0], len = 4096 }) catch { werr = true; 0 };
        if werr {
            eputs("dist: internal error: JSON serialization failed\n");
            exit_command_failed();
        }
        out(string.{ ptr = @raw[0], len = n });
        out("\n");
    }
}

// `dist server run` — bind 0.0.0.0:<port> and serve the store read-only
// (P4.1). Loops forever on success; a bind/listen failure reports through
// `report_failure` like every other command.
handle_server_run :: (p: *Parsed, json_mode: bool) {
    store_dir := p.value_of("local-store");
    port : i64 = 8787;
    if p.is_set("port") {
        pq := parse_port(p.value_of("port"));
        if pq == null {
            eputs(concat(concat("dist: --port must be 1..65535, got: ", p.value_of("port")), "\n"));
            exit_usage();
        }
        port = pq!;
    }

    failed := false;
    srv.run_server(store_dir, port) catch { failed = true; };
    if failed {
        fail : jout.CliFailure = .{
            code = "server.bind",
            message = concat("could not bind/listen on 0.0.0.0:", int_to_string(port)),
        };
        report_failure("server run", fail, json_mode);
    }
}

// `dist token create` — mint a scoped token over the persisted store
// (P4.3). The raw secret appears in this command's output and NOWHERE
// else; only its hash is persisted. Rendering contract as above.
handle_token_create :: (p: *Parsed, json_mode: bool) {
    expires_in : i64 = 0;
    if p.is_set("expires-in") {
        eq := parse_secs(p.value_of("expires-in"));
        if eq == null {
            eputs(concat(concat("dist: --expires-in must be a positive number of seconds, got: ", p.value_of("expires-in")), "\n"));
            exit_usage();
        }
        expires_in = eq!;
    }
    scopes := if p.is_set("scope") then p.value_of("scope") else "";
    app_slug := if p.is_set("app") then p.value_of("app") else "";
    channel := if p.is_set("channel") then p.value_of("channel") else "";

    fail : jout.CliFailure = .{};
    o, e := tops.run_token_create(p.value_of("local-store"), p.value_of("name"),
                                  scopes, app_slug, channel, expires_in, @fail);
    if e {
        report_failure("token create", fail, json_mode);
    }
    if !e {
        if !json_mode {
            out(tops.token_create_human(@o));
            return;
        }
        eputs("dist: token create ok (secret shown once)\n");
        raw : [4096]u8 = ---;
        werr := false;
        n := tops.write_token_create_json(@o, string.{ ptr = @raw[0], len = 4096 }) catch { werr = true; 0 };
        if werr {
            eputs("dist: internal error: JSON serialization failed\n");
            exit_command_failed();
        }
        out(string.{ ptr = @raw[0], len = n });
        out("\n");
    }
}

// `dist token list` — every token with its lifecycle status; secrets and
// hashes never appear. Rendering contract as above.
handle_token_list :: (p: *Parsed, json_mode: bool) {
    fail : jout.CliFailure = .{};
    o, e := tops.run_token_list(p.value_of("local-store"), @fail);
    if e {
        report_failure("token list", fail, json_mode);
    }
    if !e {
        if !json_mode {
            out(tops.token_list_human(@o));
            return;
        }
        eputs("dist: token list ok\n");
        raw : [16384]u8 = ---;
        werr := false;
        n := tops.write_token_list_json(@o, string.{ ptr = @raw[0], len = 16384 }) catch { werr = true; 0 };
        if werr {
            eputs("dist: internal error: JSON serialization failed\n");
            exit_command_failed();
        }
        out(string.{ ptr = @raw[0], len = n });
        out("\n");
    }
}

// `dist token revoke` — revoke by id. Rendering contract as above.
handle_token_revoke :: (p: *Parsed, json_mode: bool) {
    fail : jout.CliFailure = .{};
    o, e := tops.run_token_revoke(p.value_of("local-store"), p.value_of("id"), @fail);
    if e {
        report_failure("token revoke", fail, json_mode);
    }
    if !e {
        if !json_mode {
            out(tops.token_revoke_human(@o));
            return;
        }
        eputs("dist: token revoke ok\n");
        raw : [4096]u8 = ---;
        werr := false;
        n := tops.write_token_revoke_json(@o, string.{ ptr = @raw[0], len = 4096 }) catch { werr = true; 0 };
        if werr {
            eputs("dist: internal error: JSON serialization failed\n");
            exit_command_failed();
        }
        out(string.{ ptr = @raw[0], len = n });
        out("\n");
    }
}

// `dist app set` — edit an existing app's display name and iOS install
// policy over the persisted store (P4.6). Rendering contract as above.
handle_app_set :: (p: *Parsed, json_mode: bool) {
    req : aops.AppSetRequest = .{};
    if p.is_set("display-name")   { req.display_name   = p.value_of("display-name"); }
    if p.is_set("ios-mode")       { req.ios_mode       = p.value_of("ios-mode"); }
    if p.is_set("testflight-url") { req.testflight_url = p.value_of("testflight-url"); }
    if p.is_set("ios-bundle-id")  { req.ios_bundle_id  = p.value_of("ios-bundle-id"); }

    fail : jout.CliFailure = .{};
    o, e := aops.run_app_set(p.value_of("local-store"), p.value_of("app"), req, @fail);
    if e {
        report_failure("app set", fail, json_mode);
    }
    if !e {
        if !json_mode {
            out(aops.app_set_human(@o));
            return;
        }
        eputs("dist: app set ok\n");
        raw : [4096]u8 = ---;
        werr := false;
        n := aops.write_app_set_json(@o, string.{ ptr = @raw[0], len = 4096 }) catch { werr = true; 0 };
        if werr {
            eputs("dist: internal error: JSON serialization failed\n");
            exit_command_failed();
        }
        out(string.{ ptr = @raw[0], len = n });
        out("\n");
    }
}

// Positive decimal seconds (a token lifetime); anything else (empty,
// non-digits, zero, absurd length) is null — a usage error at the call
// site.
parse_secs :: (s: string) -> ?i64 {
    if s.len == 0 or s.len > 12 { return null; }
    v : i64 = 0;
    i := 0;
    while i < s.len {
        c := s[i];
        if c < 48 or c > 57 { return null; }    // '0'..'9'
        v = v * 10 + (c - 48);
        i += 1;
    }
    if v < 1 { return null; }
    return v;
}

// Decimal port in 1..65535; anything else (empty, non-digits, out of
// range) is null — a usage error at the call site.
parse_port :: (s: string) -> ?i64 {
    if s.len == 0 or s.len > 5 { return null; }
    v : i64 = 0;
    i := 0;
    while i < s.len {
        c := s[i];
        if c < 48 or c > 57 { return null; }    // '0'..'9'
        v = v * 10 + (c - 48);
        i += 1;
    }
    if v < 1 or v > 65535 { return null; }
    return v;
}

// Route a parsed (group, command) to its handler. `parse` only returns a
// (group, command) present in the table, so one arm always matches.
dispatch :: (p: *Parsed, json_mode: bool) {
    if p.group == "ci"      and p.command == "publish"  { handle_ci_publish(p, json_mode);      return; }
    if p.group == "release" and p.command == "promote"  { handle_release_promote(p, json_mode); return; }
    if p.group == "release" and p.command == "rollback" { handle_release_rollback(p, json_mode); return; }
    if p.group == "server"  and p.command == "run"      { handle_server_run(p, json_mode);      return; }
    if p.group == "token"   and p.command == "create"   { handle_token_create(p, json_mode);    return; }
    if p.group == "token"   and p.command == "list"     { handle_token_list(p, json_mode);      return; }
    if p.group == "token"   and p.command == "revoke"   { handle_token_revoke(p, json_mode);    return; }
    if p.group == "app"     and p.command == "set"      { handle_app_set(p, json_mode);         return; }
    eputs("dist: internal error: unrouted command\n");
    exit_usage();
}

main :: () -> ! {
    // Real process argv -> logical args: drop argv[0] (the program path).
    storage : [64]string = ---;
    argbuf : []string = ---;
    argbuf.ptr = @storage[0];
    argbuf.len = 64;
    argv := os_args(argbuf);
    args : []string = if argv.len <= 1 then argv[argv.len ..] else argv[1 ..];

    json_mode := has_flag(args, "--json");

    if has_flag(args, "-h") or has_flag(args, "--help") {
        emit_human(HELP, json_mode);
        exit_ok();
    }

    if args.len == 0 {
        eputs("dist: no command given\n\n");
        eputs(HELP);
        exit_usage();
    }

    // Command table + flag specs live in this scope; `Parsed` holds VIEWS
    // into them, used before `main` returns. Every value flag below is
    // required; the global `--json` is recognized by the parser without
    // being declared here.
    publish_flags : []FlagSpec = .[
        FlagSpec.{ name = "manifest",    takes_value = true, required = true },
        FlagSpec.{ name = "local-store", takes_value = true, required = false },
        FlagSpec.{ name = "server",      takes_value = true, required = false },
        FlagSpec.{ name = "token",       takes_value = true, required = false },
    ];
    promote_flags : []FlagSpec = .[
        FlagSpec.{ name = "app",         takes_value = true, required = true },
        FlagSpec.{ name = "channel",     takes_value = true, required = true },
        FlagSpec.{ name = "release",     takes_value = true, required = true },
        FlagSpec.{ name = "local-store", takes_value = true, required = true },
    ];
    rollback_flags : []FlagSpec = .[
        FlagSpec.{ name = "app",         takes_value = true, required = true },
        FlagSpec.{ name = "channel",     takes_value = true, required = true },
        FlagSpec.{ name = "local-store", takes_value = true, required = true },
    ];
    server_flags : []FlagSpec = .[
        FlagSpec.{ name = "local-store", takes_value = true, required = true },
        FlagSpec.{ name = "port",        takes_value = true, required = false },
    ];
    token_create_flags : []FlagSpec = .[
        FlagSpec.{ name = "name",        takes_value = true, required = true },
        FlagSpec.{ name = "local-store", takes_value = true, required = true },
        FlagSpec.{ name = "scope",       takes_value = true, required = false },
        FlagSpec.{ name = "app",         takes_value = true, required = false },
        FlagSpec.{ name = "channel",     takes_value = true, required = false },
        FlagSpec.{ name = "expires-in",  takes_value = true, required = false },
    ];
    token_list_flags : []FlagSpec = .[
        FlagSpec.{ name = "local-store", takes_value = true, required = true },
    ];
    token_revoke_flags : []FlagSpec = .[
        FlagSpec.{ name = "id",          takes_value = true, required = true },
        FlagSpec.{ name = "local-store", takes_value = true, required = true },
    ];
    app_set_flags : []FlagSpec = .[
        FlagSpec.{ name = "app",            takes_value = true, required = true },
        FlagSpec.{ name = "local-store",    takes_value = true, required = true },
        FlagSpec.{ name = "display-name",   takes_value = true, required = false },
        FlagSpec.{ name = "ios-mode",       takes_value = true, required = false },
        FlagSpec.{ name = "testflight-url", takes_value = true, required = false },
        FlagSpec.{ name = "ios-bundle-id",  takes_value = true, required = false },
    ];
    cmds : []Command = .[
        Command.{ group = "ci",      command = "publish",  flags = publish_flags },
        Command.{ group = "release", command = "promote",  flags = promote_flags },
        Command.{ group = "release", command = "rollback", flags = rollback_flags },
        Command.{ group = "server",  command = "run",      flags = server_flags },
        Command.{ group = "token",   command = "create",   flags = token_create_flags },
        Command.{ group = "token",   command = "list",     flags = token_list_flags },
        Command.{ group = "token",   command = "revoke",   flags = token_revoke_flags },
        Command.{ group = "app",     command = "set",      flags = app_set_flags },
    ];

    diag : Diag = .{};
    p, perr := parse(args, cmds, @diag);
    if perr {
        eputs(concat(concat(concat("dist: ", error_phrase(perr)), ": "), diag.token));
        eputs("\n\n");
        eputs(HELP);
        exit_usage();
    }

    if !perr {
        dispatch(@p, json_mode);
    }
    exit_ok();
}
