/**
 * JsonlFeedbackStore.ts — the DURABLE canonical FeedbackStore for the operated
 * instance (post-cutover authority under the migration spec's Option B: Echo's
 * instance keeps its OWN canonical copy; Portal never opens a write door).
 *
 * Backing format: newline-delimited JSON, one file per entity family
 * (`feedback.jsonl`, `clusters.jsonl`, `dispatches.jsonl`) under a caller-supplied
 * directory. The format is DELIBERATELY the same shape `PersistedShadowImportTarget`
 * writes (one full row per line, keyed by feedbackId/clusterId), so the proven AS-IS
 * import artifact seeds this store directly — the cutover import output IS the
 * canonical store, no translation step (fewer integrity risks at the one-way door).
 *
 * Mutation model: append-only log with LAST-WRITE-WINS on load. Every mutation
 * appends the FULL updated row; on construction all lines are folded into a Map by
 * id (later lines supersede earlier). A file with one row per entity — exactly what
 * the import produces — is therefore a valid, already-compact log. When superseded
 * lines pile up past a threshold the file is compacted at construction time via an
 * atomic temp+rename rewrite (crash mid-compaction leaves the original intact).
 *
 * JSONL (not SQLite) for the same reason as PersistedShadowImportTarget: dependency-
 * free, no native module, trivially inspectable, and append durability is a plain
 * appendFileSync. Volume is fleet feedback — low write rate; the import's measured
 * 148k-row artifact parses in well under a second.
 */
import { appendFileSync, mkdirSync, readFileSync, existsSync, writeFileSync, renameSync } from 'node:fs';
import { join } from 'node:path';
/** Compact a file at load when more than half its lines are superseded AND it has real volume. */
const COMPACT_MIN_LINES = 1000;
const COMPACT_SUPERSEDED_RATIO = 0.5;
export class JsonlFeedbackStore {
    dir;
    feedbackPath;
    clustersPath;
    dispatchesPath;
    feedback;
    clusters;
    dispatches;
    counts = { captured: 0, created: 0, merged: 0, reopened: 0 };
    constructor(dir) {
        this.dir = dir;
        mkdirSync(dir, { recursive: true });
        this.feedbackPath = join(dir, 'feedback.jsonl');
        this.clustersPath = join(dir, 'clusters.jsonl');
        this.dispatchesPath = join(dir, 'dispatches.jsonl');
        this.loadAll();
    }
    /**
     * Re-fold all three on-disk JSONL files into fresh in-memory state — the SAME
     * load the constructor runs. Use this when another PROCESS may have appended to
     * the shared store since this instance was built (e.g. the InboxDrainer, a
     * separate launchd process, appends `unprocessed` rows to `feedback.jsonl`
     * continuously; without a reload this in-memory Map is frozen at construction
     * time and the processing pass becomes a permanent no-op over everything
     * ingested after boot).
     *
     * Concurrency: this is a read-only re-fold and appends are atomic single-line
     * writes (appendFileSync). A reload that races a mid-append at worst skips one
     * torn trailing line (already handled by loadJsonl's torn-line skip); the next
     * reload picks up the completed line — identical to the constructor's load path.
     * The in-memory `counts` (FeedbackMetrics) are session-scoped tallies of THIS
     * instance's own mutations, not durable rows, so they are intentionally NOT
     * reset by a reload.
     */
    reload() {
        this.loadAll();
    }
    loadAll() {
        this.feedback = this.loadAndMaybeCompact(this.feedbackPath, (r) => pickId(r, 'feedbackId', 'feedback_id', 'id'));
        this.clusters = this.loadAndMaybeCompact(this.clustersPath, (r) => pickId(r, 'clusterId', 'cluster_id', 'id'));
        this.dispatches = this.loadAndMaybeCompact(this.dispatchesPath, (r) => pickId(r, 'dispatchId', 'dispatch_id', 'id'));
    }
    loadAndMaybeCompact(path, idOf) {
        const { rows, totalLines } = loadJsonl(path, idOf);
        const superseded = totalLines - rows.size;
        if (totalLines >= COMPACT_MIN_LINES && superseded / totalLines >= COMPACT_SUPERSEDED_RATIO) {
            // Atomic rewrite: full snapshot to a temp file in the same dir, then rename.
            const tmp = `${path}.compact.tmp`;
            writeFileSync(tmp, [...rows.values()].map((r) => JSON.stringify(r)).join('\n') + '\n', 'utf8');
            renameSync(tmp, path);
        }
        return rows;
    }
    appendFeedback(row) {
        appendFileSync(this.feedbackPath, JSON.stringify(row) + '\n', 'utf8');
    }
    appendCluster(row) {
        appendFileSync(this.clustersPath, JSON.stringify(row) + '\n', 'utf8');
    }
    // ── FeedbackStore ──────────────────────────────────────────────────────────
    getUnprocessedFeedback() {
        return [...this.feedback.values()]
            .filter((f) => (f.status ?? 'unprocessed') === 'unprocessed')
            .sort((a, b) => String(a.receivedAt ?? '').localeCompare(String(b.receivedAt ?? '')));
    }
    getActiveClusters() {
        return [...this.clusters.values()].filter((c) => c.status !== 'resolved');
    }
    getCluster(clusterId) {
        return this.clusters.get(clusterId);
    }
    upsertClusterFromItem(clusterId, item) {
        const existing = this.clusters.get(clusterId);
        if (existing) {
            existing.reportCount = (existing.reportCount ?? 0) + 1;
            this.appendCluster(existing);
        }
        else {
            const created = {
                clusterId,
                title: item.title,
                description: item.description,
                type: item.type,
                reportCount: 1,
            };
            this.clusters.set(clusterId, created);
            this.appendCluster(created);
            this.counts.created++;
        }
    }
    mergeIntoCluster(clusterId, _item) {
        const c = this.clusters.get(clusterId);
        if (c) {
            c.reportCount = (c.reportCount ?? 0) + 1;
            this.appendCluster(c);
        }
        this.counts.merged++;
    }
    applyReopen(clusterId, decision) {
        const c = this.clusters.get(clusterId);
        if (!c)
            return;
        c.status = decision.newStatus;
        if (decision.bumpRecurrence)
            c.recurrenceCount = (c.recurrenceCount ?? 0) + 1;
        const field = decision.annotateField;
        const prior = c[field] ? `${c[field]}\n\n` : '';
        c[field] = prior + decision.note;
        this.appendCluster(c);
        this.counts.reopened++;
    }
    markProcessed(feedbackId, clusterId) {
        const f = this.feedback.get(feedbackId);
        if (f) {
            f.status = 'processing';
            f.clusterId = clusterId;
            this.appendFeedback(f);
        }
        this.counts.captured++;
    }
    hasFeedback(feedbackId) {
        return this.feedback.has(feedbackId);
    }
    addFeedback(item) {
        const row = { status: 'unprocessed', ...item };
        this.feedback.set(item.feedbackId, row);
        this.appendFeedback(row);
    }
    listDispatches(filter) {
        return [...this.dispatches.values()]
            .filter((d) => d.active !== false)
            .filter((d) => !filter?.since || String(d.createdAt ?? '') >= filter.since)
            .filter((d) => !filter?.type || d.type === filter.type)
            .sort((a, b) => String(a.createdAt ?? '').localeCompare(String(b.createdAt ?? '')));
    }
    findDispatchByTitle(title) {
        return [...this.dispatches.values()].find((d) => d.title === title);
    }
    createDispatch(record) {
        const row = { active: true, ...record };
        this.dispatches.set(record.dispatchId, row);
        appendFileSync(this.dispatchesPath, JSON.stringify(row) + '\n', 'utf8');
    }
    metrics() {
        return { ...this.counts };
    }
    /** Read-only size snapshot for the status surface. */
    sizes() {
        return { feedback: this.feedback.size, clusters: this.clusters.size, dispatches: this.dispatches.size };
    }
    /**
     * Read-only aggregate snapshot for `GET /feedback-factory/stats`. Pure read —
     * never mutates. `byStatus` buckets every feedback row by its `status`
     * (defaulting an absent status to 'unprocessed', mirroring getUnprocessedFeedback).
     * `lastWriteAt` is the latest content timestamp across all entities
     * (feedback.receivedAt, cluster.updatedAt/createdAt, dispatch.createdAt) — a
     * deterministic, content-derived high-water mark, null when the store is empty.
     */
    stats() {
        const byStatus = {};
        let lastWriteAt = null;
        const consider = (ts) => {
            if (typeof ts === 'string' && ts.length > 0 && (lastWriteAt === null || ts > lastWriteAt)) {
                lastWriteAt = ts;
            }
        };
        for (const f of this.feedback.values()) {
            const status = (f.status ?? 'unprocessed');
            byStatus[status] = (byStatus[status] ?? 0) + 1;
            consider(f.receivedAt);
        }
        for (const c of this.clusters.values()) {
            consider(c.updatedAt);
            consider(c.createdAt);
        }
        for (const d of this.dispatches.values()) {
            consider(d.createdAt);
        }
        return {
            total: this.feedback.size,
            byStatus,
            clusterCount: this.clusters.size,
            dispatchCount: this.dispatches.size,
            lastWriteAt,
        };
    }
}
function loadJsonl(path, idOf) {
    const rows = new Map();
    let totalLines = 0;
    if (!existsSync(path))
        return { rows, totalLines };
    const txt = readFileSync(path, 'utf8');
    for (const line of txt.split('\n')) {
        const trimmed = line.trim();
        if (!trimmed)
            continue;
        totalLines++;
        try {
            const parsed = JSON.parse(trimmed);
            const id = idOf(parsed);
            if (id)
                rows.set(id, parsed);
        }
        catch {
            // A torn/corrupt line (e.g. crash mid-append) is skipped, never fatal:
            // every complete later line still loads, and the next mutation re-appends
            // a full row. Durability beats strictness for an append log.
        }
    }
    return { rows, totalLines };
}
/** Resolve a row's primary-key id from the AS-IS field aliases (mirrors importRunner's pickId). */
function pickId(row, ...keys) {
    for (const k of keys) {
        const v = row[k];
        if (typeof v === 'string' && v.length > 0)
            return v;
        if (typeof v === 'number')
            return String(v);
    }
    return '';
}
//# sourceMappingURL=JsonlFeedbackStore.js.map