// =====================================================================
// publish.sx — the local `dist ci publish` pipeline (subplan 03, Slice 1).
// Wires the prior modules into one end-to-end publish:
//
//   manifest (P3.2) -> store (P2.2) -> common validation (P3.3) ->
//   repository transaction + audit (P2.3) -> db.json persistence (P2.3)
//
// `run_publish(manifest_path, store_dir)` validates the manifest, LOADS any
// prior `<store>/db.json` so separate invocations share state (a new version
// accumulates; a duplicate release id is rejected), finds or creates the app,
// drafts a release, content-addresses every artifact into
// `<store>/objects/<sha256>`, validates each stored file, commits the whole
// aggregate through the integrity-checked repo transaction (channel
// promotion included), records an audit event per upload / publish /
// promotion, persists the merged `<store>/db.json`, and returns a
// `PublishOutcome` the CLI renders as stable JSON or a human summary.
//
// DECLARED-vs-DERIVED EXPECTATIONS (PO ruling): a manifest artifact may
// DECLARE `size` / `sha256`; when it does, that value is the expectation the
// common validation pass checks the on-disk file against. When it does NOT
// (size == -1 / sha256 == ""), the expectation is DERIVED from the stored
// object — the size read off disk and the sha256 returned by the store — so
// a no-declaration manifest validates trivially.
//
// FAILURE CONTRACT (P3.4b): every abort happens BEFORE `db.save`, and the
// repo transaction rolls itself back, so a failed publish never changes
// db.json — no partially-published release, no moved channel pointer. Each
// raise site first writes a `jout.CliFailure` (stable dotted code + human
// message naming the offending input) for the CLI to report.
//
// LOCAL DOWNLOAD URL FORM: `file://<abs-store>/objects/<sha256>`, where
// <abs-store> is the `--local-store` directory resolved to an absolute path
// (left as-is if already absolute, else joined onto the process cwd).
//
// ALLOCATION: everything the published `Repo` and the returned outcome hold
// is allocated from `context.allocator` (the process-lifetime default), so a
// one-shot publish never frees and never dangles.
// =====================================================================

#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 "../store/store.sx";
#import "../repo/repo.sx";
#import "../validation/artifact_file.sx";
mani :: #import "../manifest/manifest.sx";
db   :: #import "../repo/db.sx";
jout :: #import "../json_out.sx";

// libc handles the two facts the publish needs from the OS: the wall-clock
// time stamped onto the release / audit events, and the process cwd used to
// absolutize a relative `--local-store` into the download URL.
cstd     :: #library "c";
c_time   :: (tloc: *i64) -> i64 #foreign cstd "time";
c_getcwd :: (buf: [*]u8, size: usize) -> *u8 #foreign cstd "getcwd";

// Failure classes for the publish pipeline. One distinct tag per stage so
// the CLI (and P3.4b) can report precisely where a publish stopped.
//   Manifest    — the manifest failed to load / parse / validate.
//   Store       — an artifact's bytes could not be content-addressed.
//   Validation  — a stored artifact failed the common validation pass.
//   Transaction — the repo's integrity-checked publish rejected the aggregate.
//   Persist     — db.json could not be loaded at startup or written at the end.
PublishError :: error {
    Manifest,
    Store,
    Validation,
    Transaction,
    Persist,
}

// One artifact as the CLI reports it: its id, platform, size, content
// digest, and the local download URL its bytes live at.
PublishedArtifact :: struct {
    id: string;
    platform_name: string;
    size_bytes: i64;
    sha256: string;
    url: string;
}

// One artifact whose bytes are ALREADY content-addressed in the store,
// carrying everything `commit_publish` needs to build the Artifact entity.
// Both publish fronts produce these: the CLI from manifest paths it stored
// itself, the HTTP API from digests of previously-uploaded objects.
ResolvedArtifact :: struct {
    platform: Platform;
    key: string;          // sha256 == storage key
    size_bytes: i64;
    filename: string;
    content_type: string;
    metadata: string;
}

// The machine-readable result of a successful publish: the release identity
// and the artifacts that were stored under it.
PublishOutcome :: struct {
    release_id: string;
    app_id: string;
    version: string;
    channel: string;
    artifacts: List(PublishedArtifact);
}

// ── helpers ───────────────────────────────────────────────────────────

// Current wall-clock time as unix epoch seconds (time(2) via a local slot,
// so no null pointer is needed).
now_secs :: () -> i64 {
    t : i64 = 0;
    return c_time(@t);
}

// Resolve `dir` to an absolute path: returned unchanged when already
// absolute, else joined onto the process cwd. Falls back to `dir` if the
// cwd can't be read.
abs_store :: (dir: string) -> string {
    if dir.len > 0 and dir[0] == 47 { return dir; }   // 47='/' already absolute
    buf : [4096]u8 = ---;
    r := c_getcwd(@buf[0], 4096);
    if cast(i64) r == 0 { return dir; }
    n := 0;
    while buf[n] != 0 { n += 1; }
    cwd := string.{ ptr = @buf[0], len = n };
    return path_join(cwd, dir);
}

// The content type to record when the manifest doesn't declare one — the
// canonical type for the platform's artifact form (each is on the common
// validation allow-list).
default_content_type :: (p: Platform) -> string {
    if p == .ios         { return "application/octet-stream"; }
    if p == .android_apk { return "application/vnd.android.package-archive"; }
    if p == .macos       { return "application/x-apple-diskimage"; }
    if p == .linux       { return "application/octet-stream"; }
    return "application/x-msdownload";   // windows
}

// ── failure detail ────────────────────────────────────────────────────
// The error channel carries only a tag; the precise reason (which manifest
// field, which validation check, which artifact) is written into the
// caller's `jout.CliFailure` at the raise site, as a stable dotted code +
// a human sentence naming the offending input where one exists.

manifest_code :: (e: mani.ManifestErr) -> string {
    if e == error.BadJson         { return "manifest.bad_json"; }
    if e == error.WrongType       { return "manifest.wrong_type"; }
    if e == error.MissingField    { return "manifest.missing_field"; }
    if e == error.UnknownPlatform { return "manifest.unknown_platform"; }
    if e == error.MissingArtifact { return "manifest.missing_artifact"; }
    return "manifest.io";
}

manifest_message :: (e: mani.ManifestErr, path: string) -> string {
    if e == error.BadJson         { return concat("manifest is not valid JSON: ", path); }
    if e == error.WrongType       { return concat("manifest field has the wrong JSON type: ", path); }
    if e == error.MissingField    { return concat("manifest is missing a required field: ", path); }
    if e == error.UnknownPlatform { return concat("manifest names an unknown platform id: ", path); }
    if e == error.MissingArtifact { return concat("manifest references an artifact file that does not exist: ", path); }
    return concat("manifest file could not be read: ", path);
}

validation_code :: (r: ValidationReason) -> string {
    if r == .missing_file        { return "validation.missing_file"; }
    if r == .size_mismatch       { return "validation.size_mismatch"; }
    if r == .digest_mismatch     { return "validation.digest_mismatch"; }
    if r == .content_type_denied { return "validation.content_type_denied"; }
    return "validation.extension_mismatch";
}

validation_message :: (r: ValidationReason, path: string) -> string {
    if r == .missing_file        { return concat("artifact file disappeared before validation: ", path); }
    if r == .size_mismatch       { return concat("artifact size does not match the declared size: ", path); }
    if r == .digest_mismatch     { return concat("artifact sha256 does not match the declared digest: ", path); }
    if r == .content_type_denied { return concat("artifact content type is not on the allow-list: ", path); }
    return concat("artifact extension does not match its platform: ", path);
}

// ── pipeline ──────────────────────────────────────────────────────────

run_publish :: (manifest_path: string, store_dir: string, fail_out: *jout.CliFailure) -> (PublishOutcome, !PublishError) {
    alloc := context.allocator;

    // 1. Validate the manifest (parse + on-disk artifact existence).
    m, me := mani.load_manifest(manifest_path, alloc);
    if me {
        fail_out.code    = manifest_code(me);
        fail_out.message = manifest_message(me, manifest_path);
        raise error.Manifest;
    }

    base_dir := dirname(manifest_path);
    abs := abs_store(store_dir);
    st := Store.init(store_dir);

    // 2. Per artifact: store by digest -> validate -> resolve.
    resolved : List(ResolvedArtifact) = .{};

    i := 0;
    while i < m.artifacts.len {
        ma := m.artifacts.items[i];
        src := path_join(base_dir, ma.path);

        // Content-address the bytes in memory via `put_bytes`. The streaming
        // `put_file` path hashes through `hash.sha256_file`, which the sx
        // LLVM backend miscompiles (codegen crash under `sx build`); reading
        // the bytes and hashing them in memory yields the identical
        // `objects/<sha256>` key with no streaming hash.
        sb := read_file(src);
        if sb == null {
            fail_out.code    = "store.read";
            fail_out.message = concat("artifact file could not be read: ", src);
            raise error.Store;
        }
        bytes := sb!;
        actual_size := bytes.len;

        key, se := st.put_bytes(bytes);
        if se {
            fail_out.code    = "store.write";
            fail_out.message = concat("artifact bytes could not be content-addressed into the store: ", src);
            raise error.Store;
        }

        // Declared expectation when present; derived from the stored object
        // otherwise (so a no-declaration manifest validates trivially).
        exp_size := if ma.size >= 0 then ma.size else actual_size;
        exp_sha  := if ma.sha256.len > 0 then ma.sha256 else key;
        ct       := if ma.content_type.len > 0 then ma.content_type else default_content_type(ma.platform);

        outcome := validate_artifact_file(src, exp_size, exp_sha, ma.platform, ct);
        if outcome.status != .valid {
            fail_out.code    = validation_code(outcome.reason);
            fail_out.message = validation_message(outcome.reason, src);
            raise error.Validation;
        }

        resolved.append(ResolvedArtifact.{
            platform = ma.platform, key = key, size_bytes = actual_size,
            filename = if ma.filename.len > 0 then ma.filename else basename(src),
            content_type = ct, metadata = ma.metadata,
        }, alloc);

        i += 1;
    }

    // 3. Commit through the shared pipeline; the CLI's download URLs are
    //    file:// paths into the absolutized local store.
    url_prefix := concat(concat("file://", abs), "/objects/");
    arts_view : []ResolvedArtifact = .{ ptr = resolved.items, len = resolved.len };
    o := try commit_publish(store_dir, m.app, m.version, m.channel, "ci", url_prefix, arts_view, fail_out);
    return o;
}

// The shared back half of every publish: load prior state, find/create the
// app, draft the release, build the Artifact entities from ALREADY-STORED
// objects, run the integrity-checked transaction (channel promotion
// included), write the audit trail, persist, and shape the outcome. Both
// the CLI (`run_publish`) and the HTTP write API commit through here, so
// publish semantics cannot drift between the two fronts.
commit_publish :: (store_dir: string, slug: string, version: string, channel_name: string, actor: string, url_prefix: string, arts_in: []ResolvedArtifact, fail_out: *jout.CliFailure) -> (PublishOutcome, !PublishError) {
    alloc := context.allocator;
    now := now_secs();

    // Seed the Repo from any prior state so separate invocations SHARE
    // state through the store: a pre-existing `<store>/db.json` is loaded so
    // find-or-create sees earlier apps and the integrity transaction sees
    // earlier releases. A new version then ACCUMULATES (the app is found, not
    // duplicated); re-publishing the SAME release id is rejected as a
    // duplicate by the transaction. An absent db.json starts empty. The loaded
    // model grows through its own owning allocator (`context.allocator`, the
    // process-lifetime default), per the long-lived-container rule.
    repo := Repo.init();
    if exists(path_join(store_dir, "db.json")) {
        loaded, le := db.load(store_dir);
        if le {
            fail_out.code    = "persist.load";
            fail_out.message = concat("existing db.json under the store could not be loaded: ", store_dir);
            raise error.Persist;
        }
        repo = loaded;
    }

    // Find or create the app (keyed by slug).
    app_id := "";
    existing := repo.find_app_by_slug(slug);
    if existing == null {
        app_id = concat("app-", slug);
        repo.create_app(App.{
            id = app_id, slug = slug, display_name = slug,
            owner = actor, visibility = .private,
            created_at = now, updated_at = now,
        });
    } else {
        found := existing!;
        app_id = found.id;
    }

    // Draft the release for this version/channel.
    release_id := concat(concat(concat("rel-", slug), "-"), version);
    rel := Release.{
        id = release_id, app_id = app_id, version = version, build = 1,
        channel = channel_name, notes = "", created_by = actor,
        created_at = now, published_at = now,
    };

    // Build the entities off the resolved inputs.
    arts : List(Artifact) = .{};
    out_arts : List(PublishedArtifact) = .{};
    i := 0;
    while i < arts_in.len {
        ra := arts_in[i];
        pname := db.platform_str(ra.platform);
        art := Artifact.{
            id = concat(concat(release_id, "-"), pname),
            app_id = app_id, release_id = release_id,
            platform = ra.platform, filename = ra.filename,
            content_type = ra.content_type,
            size_bytes = ra.size_bytes, sha256 = ra.key, storage_key = ra.key,
            metadata = ra.metadata, validation_status = .valid,
        };
        arts.append(art, alloc);
        out_arts.append(PublishedArtifact.{
            id = art.id, platform_name = pname, size_bytes = ra.size_bytes,
            sha256 = ra.key, url = concat(url_prefix, ra.key),
        }, alloc);
        i += 1;
    }

    // Publish via the integrity-checked transaction (channel promotion
    // included). Rollback on failure is left intact for P3.4b.
    chan := Channel.{
        app_id = app_id, name = channel_name, current_release_id = "",
        policy = .manual, rollout_percent = 100,
    };
    pe := false;
    integ := false;
    repo.publish(rel, @arts, chan) catch (e) { pe = true; integ = (e == error.Integrity); };
    if pe {
        if integ {
            fail_out.code    = "transaction.integrity";
            fail_out.message = concat("publish rejected: the release/artifact/channel aggregate is identity-inconsistent (e.g. duplicate release id): ", release_id);
        } else {
            fail_out.code    = "transaction.validation";
            fail_out.message = concat("publish rejected: the release, an artifact, or the channel failed domain validation: ", release_id);
        }
        raise error.Transaction;
    }

    // Audit trail — only after a committed publish: one upload event per
    // artifact, one publish event, one channel-promotion event.
    j := 0;
    while j < arts.len {
        aid := arts.items[j].id;
        repo.create_audit_event(AuditEvent.{
            id = concat("evt-upload-", aid), actor = actor,
            action = "artifact.upload", target_type = "artifact",
            target_id = aid, metadata = "", created_at = now,
        });
        j += 1;
    }
    repo.create_audit_event(AuditEvent.{
        id = concat("evt-publish-", release_id), actor = actor,
        action = "release.publish", target_type = "release",
        target_id = release_id, metadata = "", created_at = now,
    });
    repo.create_audit_event(AuditEvent.{
        id = concat(concat(concat("evt-promote-", app_id), "-"), channel_name),
        actor = actor, action = "channel.promote", target_type = "channel",
        target_id = channel_name, metadata = "", created_at = now,
    });

    // Persist the whole model under the store.
    persist_err := false;
    db.save(repo, store_dir) catch { persist_err = true; };
    if persist_err {
        fail_out.code    = "persist.save";
        fail_out.message = concat("db.json could not be written under the store: ", store_dir);
        raise error.Persist;
    }

    return PublishOutcome.{
        release_id = release_id, app_id = app_id,
        version = version, channel = channel_name,
        artifacts = out_arts,
    };
}

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

// Serialize a successful publish as one stable JSON object into the caller
// buffer `dst`, returning the bytes written. Member order is fixed (status,
// release{id,app_id,version,channel}, artifacts[]{id,platform,size_bytes,
// sha256,url}) by `std.json`'s insertion-order guarantee. Overflow surfaces
// on the error channel.
write_json :: (o: *PublishOutcome, dst: []u8) -> (i64, !JsonError) {
    gpa := GPA.init();
    root : Object = .{};
    root.put("status", .str("published"), xx gpa);

    rel : Object = .{};
    rel.put("id", .str(o.release_id), xx gpa);
    rel.put("app_id", .str(o.app_id), xx gpa);
    rel.put("version", .str(o.version), xx gpa);
    rel.put("channel", .str(o.channel), xx gpa);
    root.put("release", .object(rel), xx gpa);

    arts : Array = .{};
    i := 0;
    while i < o.artifacts.len {
        a := o.artifacts.items[i];
        ao : Object = .{};
        ao.put("id", .str(a.id), xx gpa);
        ao.put("platform", .str(a.platform_name), xx gpa);
        ao.put("size_bytes", .int_(a.size_bytes), xx gpa);
        ao.put("sha256", .str(a.sha256), xx gpa);
        ao.put("url", .str(a.url), xx gpa);
        arts.add(.object(ao), xx gpa);
        i += 1;
    }
    root.put("artifacts", .array(arts), xx gpa);

    rootv : Value = .object(root);
    n := try write_to_buffer(rootv, dst);
    return n;
}

// A readable one-publish summary for non-json mode: the release line plus
// one indented line per artifact (platform, digest, download URL).
human_summary :: (o: *PublishOutcome) -> string {
    s := concat("published release ", o.release_id);
    s = concat(s, concat(" (", concat(o.app_id, concat(" ", concat(o.version, ")")))));
    s = concat(s, concat(" on channel ", concat(o.channel, "\n")));
    i := 0;
    while i < o.artifacts.len {
        a := o.artifacts.items[i];
        s = concat(s, concat("  ", concat(a.platform_name, concat("  ", concat(a.sha256, concat("  ", concat(a.url, "\n")))))));
        i += 1;
    }
    return s;
}
