// Pinned acceptance for P3.4b — failure paths are loud and machine-readable.
//
// Drives the BUILT `build/dist` binary (via `process.run`, like
// publish_persist.sx) through every publish failure class the slice plan
// names — a malformed manifest, a missing artifact file, an unknown
// platform id, a declared-size mismatch, and a declared-sha256 mismatch —
// and asserts the P3.4b contract for each:
//
//   * exit code 1 (command failed; NOT the parser's EX_USAGE 64),
//   * stdout under `--json` is a SINGLE JSON object
//     `{"status":"error","error":{"code":<dotted code>,"message":...}}`,
//   * nothing is persisted: a fresh store gains no db.json.
//
// The no-partial-state crux is then asserted against a NON-EMPTY store:
// publish version A successfully, fail version B on a digest mismatch into
// the SAME store, and require db.json byte-state unchanged — one release,
// channel still pointing at A (no partially-published release, no moved
// channel pointer).
#import "modules/std.sx";
#import "modules/std/json.sx";
process :: #import "modules/std/process.sx";
fs   :: #import "modules/std/fs.sx";

STORE :: ".sx-tmp/publish_fail";
MDIR  :: ".sx-tmp/publish_fail_m";

// Manifests share the committed 5-byte fixtures; paths resolve relative to
// the manifest's own directory (MDIR), so ../../examples/fixtures reaches
// them.
GOOD_A      :: "{\"app\":\"acme-app\",\"version\":\"1.2.3\",\"channel\":\"stable\",\"artifacts\":[{\"platform\":\"android_apk\",\"path\":\"../../examples/fixtures/acme-1.2.3-android.apk\"}]}";
BAD_DIGEST  :: "{\"app\":\"acme-app\",\"version\":\"1.2.4\",\"channel\":\"stable\",\"artifacts\":[{\"platform\":\"android_apk\",\"path\":\"../../examples/fixtures/acme-1.2.3-android.apk\",\"sha256\":\"0000000000000000000000000000000000000000000000000000000000000000\"}]}";
BAD_SIZE    :: "{\"app\":\"acme-app\",\"version\":\"1.2.5\",\"channel\":\"stable\",\"artifacts\":[{\"platform\":\"android_apk\",\"path\":\"../../examples/fixtures/acme-1.2.3-android.apk\",\"size\":9999}]}";
BAD_PLATFORM:: "{\"app\":\"acme-app\",\"version\":\"1.2.6\",\"channel\":\"stable\",\"artifacts\":[{\"platform\":\"playstation\",\"path\":\"../../examples/fixtures/acme-1.2.3-android.apk\"}]}";
NO_ARTIFACT :: "{\"app\":\"acme-app\",\"version\":\"1.2.7\",\"channel\":\"stable\",\"artifacts\":[{\"platform\":\"android_apk\",\"path\":\"no-such-file.apk\"}]}";
NOT_JSON    :: "this is not a manifest";

get :: (o: Object, key: string) -> Value {
    i := 0;
    while i < o.len {
        if o.items[i].key == key { return o.items[i].val; }
        i += 1;
    }
    process.assert(false, concat("missing json key: ", key));
    dummy : Value = .null_;
    return dummy;
}
get_str :: (o: Object, key: string) -> string { return get(o, key).str; }
get_obj :: (o: Object, key: string) -> Object { return get(o, key).object; }
get_arr :: (o: Object, key: string) -> Array  { return get(o, key).array; }

publish_cmd :: (mpath: string, store: string) -> string {
    c := concat("build/dist ci publish --manifest ", mpath);
    c = concat(c, concat(" --local-store ", store));
    return concat(c, " --json 2>/dev/null");
}

// Write `body` to `path` via the shell (single-quoted, so the JSON's double
// quotes pass through literally).
write_file :: (path: string, body: string) {
    cmd := concat(concat(concat("printf '%s' '", body), "' > "), path);
    process.run(cmd);
}

// Run one failing publish and assert the full failure contract: exit 1,
// stdout is exactly one JSON error object, and its error.code equals
// `want_code`. `store` distinguishes the per-case fresh stores.
assert_fails :: (label: string, mpath: string, store: string, want_code: string, scratch: Allocator) {
    r := process.run(publish_cmd(mpath, store));
    process.assert(r != null, concat("spawn failed: ", label));
    res := r!;
    process.assert(res.exit_code == 1, concat("must exit 1 (command failed): ", label));

    v, e := parse(res.stdout, scratch);
    if e { process.assert(false, concat("stdout must be one JSON object: ", label)); return; }
    o := v.object;
    process.assert(get_str(o, "status") == "error", concat("status must be \"error\": ", label));
    eo := get_obj(o, "error");
    process.assert(get_str(eo, "code") == want_code, concat("error.code mismatch: ", label));
    process.assert(get_str(eo, "message").len > 0, concat("error.message must be non-empty: ", label));
    out(concat(concat("  ", label), ": exit 1 + JSON error ok\n"));
}

main :: () -> i32 {
    gpa := GPA.init();
    arena := Arena.init(xx gpa, 1 << 20);
    defer arena.deinit();

    process.run(concat("rm -rf ", STORE));
    process.run(concat("rm -rf ", MDIR));
    process.run(concat("mkdir -p ", MDIR));

    write_file(path_join(MDIR, "good_a.json"),       GOOD_A);
    write_file(path_join(MDIR, "bad_digest.json"),   BAD_DIGEST);
    write_file(path_join(MDIR, "bad_size.json"),     BAD_SIZE);
    write_file(path_join(MDIR, "bad_platform.json"), BAD_PLATFORM);
    write_file(path_join(MDIR, "no_artifact.json"),  NO_ARTIFACT);
    write_file(path_join(MDIR, "not_json.json"),     NOT_JSON);

    // ── each failure class: exit 1 + the precise dotted code, into a
    //    fresh per-case store that must gain NO db.json ────────────────
    assert_fails("digest mismatch",   path_join(MDIR, "bad_digest.json"),   concat(STORE, "-digest"),   "validation.digest_mismatch", xx arena);
    assert_fails("size mismatch",     path_join(MDIR, "bad_size.json"),     concat(STORE, "-size"),     "validation.size_mismatch",   xx arena);
    assert_fails("unknown platform",  path_join(MDIR, "bad_platform.json"), concat(STORE, "-platform"), "manifest.unknown_platform",  xx arena);
    assert_fails("missing artifact",  path_join(MDIR, "no_artifact.json"),  concat(STORE, "-missing"),  "manifest.missing_artifact",  xx arena);
    assert_fails("malformed manifest",path_join(MDIR, "not_json.json"),     concat(STORE, "-badjson"),  "manifest.bad_json",          xx arena);

    process.assert(!fs.exists(concat(STORE, "-digest/db.json")),
                   "failed publish into a fresh store must not create db.json");

    // ── no-partial-state against a NON-EMPTY store ────────────────────
    // Publish A successfully, then fail B on a digest mismatch into the
    // SAME store: db.json must be unchanged (one release, channel → A).
    ra := process.run(publish_cmd(path_join(MDIR, "good_a.json"), STORE));
    process.assert(ra != null, "spawn publish A failed");
    process.assert(ra!.exit_code == 0, "publish A must exit 0");

    rb := process.run(publish_cmd(path_join(MDIR, "bad_digest.json"), STORE));
    process.assert(rb != null, "spawn failing publish B failed");
    process.assert(rb!.exit_code == 1, "publish B must exit 1 (digest mismatch)");

    db_bytes := fs.read_file(path_join(STORE, "db.json"));
    process.assert(db_bytes != null, "db.json from publish A must still exist");
    dv, de := parse(db_bytes!, xx arena);
    if de { process.assert(false, "db.json must be valid JSON"); return 1; }
    dbo := dv.object;
    process.assert(get_arr(dbo, "releases").len == 1, "after failed B: db still has ONE release (A)");
    chans := get_arr(dbo, "channels");
    process.assert(chans.len == 1, "after failed B: one channel");
    process.assert(get_str(chans.items[0].object, "current_release_id") == "rel-acme-app-1.2.3",
                   "after failed B: channel still points at A (no moved pointer)");
    print("  non-empty store: failed publish left db.json unchanged\n");

    process.run(concat("rm -rf ", concat(STORE, "-digest")));
    process.run(concat("rm -rf ", concat(STORE, "-size")));
    process.run(concat("rm -rf ", concat(STORE, "-platform")));
    process.run(concat("rm -rf ", concat(STORE, "-missing")));
    process.run(concat("rm -rf ", concat(STORE, "-badjson")));
    process.run(concat("rm -rf ", STORE));
    process.run(concat("rm -rf ", MDIR));
    print("publish_fail: ALL CASES PASS\n");
    return 0;
}
