/**
 * LocalFilesystemArtifactWriter — writes AILF run artifacts to the local
 * filesystem under `{rootDir}/runs/{runId}/…`.
 *
 * D0033 M4 inverts D0032's "GCS default, local fallback" stance: the local
 * writer is **always** attached, and GCS layers on top via a fanout writer
 * when credentials are present. The result — every run produces a usable
 * artifact bundle on disk even on airplanes, laptops, and CI without GCS
 * creds. Studio retrieval works uniformly; only `ArtifactRef.store`
 * differentiates.
 *
 * ## Path layout
 *
 * Paths mirror the GCS tree exactly, so the same descriptor's `objectPath`
 * is used verbatim:
 *   - bulk: `{rootDir}/runs/{runId}/{slug}.{ext}`
 *   - per-entry: `{rootDir}/runs/{runId}/{slug}/{sanitizedKey}.{ext}`
 *   - manifest: `{rootDir}/runs/{runId}/manifest.json`
 *
 * This keeps the L6 cross-reader contract test simple — a byte-compare of
 * every object at every path, modulo timestamped manifest fields.
 *
 * ## Design choices
 *
 * - **Redaction at emit boundary (AC10).** `redactArtifactData` runs per
 *   write, not post-hoc on a tarball. Same rules as the legacy collector.
 * - **Exclude list gating (Q3).** `--capture-exclude=LIST` passes through
 *   to the constructor; `emit()` returns null for excluded types before
 *   touching disk.
 * - **NDJSON uses plain `fs.appendFile`.** No compose rollover needed —
 *   unlike GCS, local fs appends are atomic and unbounded. A crash mid-
 *   append leaves a partial row visible; acceptable for dev runs.
 * - **Non-blocking per P5.** Any fs error returns null + warns, never
 *   throws. The pipeline must not fail because local disk is full.
 *
 * @see docs/decisions/D0033-unified-run-anchored-artifact-capture.md (§ M4)
 * @see packages/eval/src/artifact-capture/gcs-artifact-writer.ts (mirror)
 */
import { promises as fs } from "node:fs";
import path from "node:path";
import { ARTIFACT_REGISTRY, assertWritePolicyMatches, buildManifestPreview, } from "../_vendor/ailf-core/index.js";
import { redactArtifactData } from "./redact-artifact.js";
// ---------------------------------------------------------------------------
// Implementation
// ---------------------------------------------------------------------------
export class LocalFilesystemArtifactWriter {
    options;
    excludeSet;
    writerSource;
    constructor(options) {
        this.options = options;
        this.excludeSet = new Set(options.exclude ?? []);
        this.writerSource = options.writerSource ?? "pipeline";
    }
    reportProgress(ref) {
        const progress = this.options.progress;
        if (!progress)
            return;
        progress.reporter.phaseProgress({
            phaseId: progress.phaseId,
            items: 1,
            bytes: ref.bytes,
            label: ref.path,
        });
    }
    // ---- Canonical W0049 API ------------------------------------------------
    async emit(type, association, payload) {
        if (this.excludeSet.has(type))
            return null;
        const descriptor = ARTIFACT_REGISTRY[type];
        assertWritePolicyMatches(this.writerSource, descriptor);
        const runId = association.run;
        if (!runId) {
            console.warn(`  ⚠️  emit("${type}"): association.run is required, skipping`);
            return null;
        }
        const redacted = redactArtifactData(payload);
        const body = serializeForMime(redacted, descriptor.mime);
        const bytes = Buffer.byteLength(body, "utf-8");
        // Preview is built from the pre-redaction payload so the extract sees
        // the same shape the producer handed us. The full entry is still redacted
        // on disk; the preview lives only on the manifest and is bounded by the
        // descriptor's capBytes.
        const preview = buildManifestPreview(descriptor, payload);
        if (descriptor.layout === "bulk") {
            const extra = descriptor.extractPathArgs?.(association, payload) ?? [];
            const relPath = descriptor.objectPath(runId, ...extra);
            const absPath = this.resolve(relPath);
            const wrote = await this.writeAtomic(absPath, body);
            if (!wrote)
                return null;
            const ref = {
                store: "local",
                bucket: this.options.rootDir,
                path: relPath,
                bytes,
                entryCount: entryCountOf(redacted),
                layout: "bulk",
                ...(preview === undefined ? {} : { preview }),
            };
            this.reportProgress(ref);
            return ref;
        }
        // per-entry
        const entryKey = descriptor.formatEntryKey(association);
        const relPath = descriptor.objectPath(runId, entryKey);
        const absPath = this.resolve(relPath);
        const wrote = await this.writeAtomic(absPath, body);
        if (!wrote)
            return null;
        const ref = {
            store: "local",
            bucket: this.options.rootDir,
            path: `runs/${runId}/${descriptor.slug}`,
            bytes,
            entryCount: 1,
            layout: "per-entry",
            entries: [
                {
                    key: entryKey,
                    bytes,
                    association,
                    ...(preview === undefined ? {} : { preview }),
                },
            ],
        };
        this.reportProgress(ref);
        return ref;
    }
    async appendNdjson(type, association, rows) {
        if (this.excludeSet.has(type))
            return null;
        const descriptor = ARTIFACT_REGISTRY[type];
        assertWritePolicyMatches(this.writerSource, descriptor);
        if (descriptor.mime !== "application/x-ndjson") {
            console.warn(`  ⚠️  appendNdjson("${type}"): descriptor mime is ${descriptor.mime}, not application/x-ndjson — skipping`);
            return null;
        }
        const runId = association.run;
        if (!runId) {
            console.warn(`  ⚠️  appendNdjson("${type}"): association.run is required, skipping`);
            return null;
        }
        if (rows.length === 0)
            return null;
        const entryKey = descriptor.formatEntryKey(association);
        const relPath = descriptor.objectPath(runId, entryKey);
        const absPath = this.resolve(relPath);
        const redactedRows = rows.map((r) => redactArtifactData(r));
        const body = redactedRows.map((r) => JSON.stringify(r)).join("\n") + "\n";
        const bytes = Buffer.byteLength(body, "utf-8");
        try {
            await fs.mkdir(path.dirname(absPath), { recursive: true });
            await fs.appendFile(absPath, body, "utf-8");
        }
        catch (err) {
            const message = err instanceof Error ? err.message : String(err);
            console.warn(`  ⚠️  NDJSON append failed (non-blocking): ${absPath} — ${message}`);
            return null;
        }
        // Local fs appendFile keeps no part state, so the returned ref always
        // reports the *cumulative* size on disk — same semantic as the GCS
        // writer's `state.totalBytes`, just computed differently.
        let cumulative = bytes;
        try {
            const stat = await fs.stat(absPath);
            cumulative = stat.size;
        }
        catch {
            // If stat fails we still have the current batch's bytes — acceptable.
        }
        const ref = {
            store: "local",
            bucket: this.options.rootDir,
            path: `runs/${runId}/${descriptor.slug}`,
            bytes: cumulative,
            entryCount: 1,
            layout: "per-entry",
            entries: [{ key: entryKey, bytes: cumulative, association }],
        };
        this.reportProgress(ref);
        return ref;
    }
    async writeManifest(runId, manifest) {
        const relPath = `runs/${runId}/manifest.json`;
        const absPath = this.resolve(relPath);
        const body = JSON.stringify(manifest);
        const wrote = await this.writeAtomic(absPath, body);
        if (!wrote)
            return null;
        const ref = {
            store: "local",
            bucket: this.options.rootDir,
            path: relPath,
            bytes: Buffer.byteLength(body, "utf-8"),
            layout: "bulk",
        };
        this.reportProgress(ref);
        return ref;
    }
    // ---- Deprecated legacy surface (W0052) ----------------------------------
    /** @deprecated Use `emit()` instead. */
    async writeBulk(type, runId, data) {
        const descriptor = ARTIFACT_REGISTRY[type];
        if (descriptor.layout !== "bulk") {
            console.warn(`  ⚠️  writeBulk("${type}"): descriptor layout is "${descriptor.layout}", not "bulk" — skipping`);
            return null;
        }
        return this.emit(type, { run: runId }, data);
    }
    /** @deprecated Use `emit()` per entry instead. */
    async writePerEntry(type, runId, entries) {
        const descriptor = ARTIFACT_REGISTRY[type];
        if (descriptor.layout !== "per-entry") {
            console.warn(`  ⚠️  writePerEntry("${type}"): descriptor layout is "${descriptor.layout}", not "per-entry" — skipping`);
            return null;
        }
        if (!descriptor.parseEntryKey) {
            console.warn(`  ⚠️  writePerEntry("${type}"): descriptor has no parseEntryKey`);
            return null;
        }
        // Legacy shim: write each entry directly by its raw key, bypassing
        // formatEntryKey. Preserves byte-equivalence with pre-W0050 paths
        // for producers still on the legacy call surface.
        const uploaded = [];
        let totalBytes = 0;
        for (const entry of entries) {
            const parsed = descriptor.parseEntryKey(entry.key);
            if (!parsed.ok) {
                console.warn(`  ⚠️  Skipping entry with invalid key "${entry.key}": ${parsed.reason}`);
                continue;
            }
            const redacted = redactArtifactData(entry.data);
            const body = serializeForMime(redacted, descriptor.mime);
            const bytes = Buffer.byteLength(body, "utf-8");
            const relPath = descriptor.objectPath(runId, entry.key);
            const absPath = this.resolve(relPath);
            const wrote = await this.writeAtomic(absPath, body);
            if (!wrote)
                continue;
            uploaded.push({ key: entry.key, bytes });
            totalBytes += bytes;
        }
        if (uploaded.length === 0)
            return null;
        return {
            store: "local",
            bucket: this.options.rootDir,
            path: `runs/${runId}/${descriptor.slug}`,
            bytes: totalBytes,
            entryCount: uploaded.length,
            layout: "per-entry",
            entries: uploaded,
        };
    }
    // ---- Internals ----------------------------------------------------------
    resolve(relPath) {
        return path.resolve(this.options.rootDir, relPath);
    }
    /**
     * Write the body to `absPath`, creating parent dirs as needed. Returns
     * false + warns on any fs error (P5 non-blocking).
     */
    async writeAtomic(absPath, body) {
        try {
            await fs.mkdir(path.dirname(absPath), { recursive: true });
            await fs.writeFile(absPath, body, "utf-8");
            return true;
        }
        catch (err) {
            const message = err instanceof Error ? err.message : String(err);
            console.warn(`  ⚠️  Artifact write failed (non-blocking): ${absPath} — ${message}`);
            return false;
        }
    }
}
// ---------------------------------------------------------------------------
// Helpers (shared shape with GcsArtifactWriter)
// ---------------------------------------------------------------------------
function serializeForMime(payload, mime) {
    if (mime === "text/markdown" || mime === "application/yaml") {
        if (typeof payload === "string")
            return payload;
        return String(payload ?? "");
    }
    return JSON.stringify(payload);
}
function entryCountOf(data) {
    if (typeof data === "object" &&
        data !== null &&
        "entries" in data &&
        typeof data.entries === "object") {
        return Object.keys(data.entries)
            .length;
    }
    return undefined;
}
