/**
 * PreferencesManager — the structured on-disk substrate for auto-learned user
 * preferences (Correction & Preference Learning Sentinel, Slice 1a).
 *
 * Modeled directly on the ORG-INTENT precedent (`OrgIntentManager` +
 * `formatOrgIntentForSessionStart` + `GET /intent/org/session-context` +
 * the session-start hook fetch). Where ORG-INTENT is a human-authored markdown
 * contract, this file is a machine-written JSON store of preferences the
 * correction loop has learned about THIS user.
 *
 * Design constraints (spec §3.6 / §10 Slice 1a):
 *   - `recordPreference()` is the ONLY writer to `.instar/preferences.json`.
 *   - Writes are atomic (temp file + rename) and schema-versioned.
 *   - Upsert keyed on `dedupeKey` — a recurring learning collapses to ONE entry
 *     (its `dedupeCount` increments, `recordedAt` refreshes, `confidence` takes
 *     the max of old/new), never a growing pile of duplicates.
 *   - Absent file ≡ no preferences (never throws; reads return an empty store).
 *   - The session-start block is bounded by `maxInjectedPreferencesBytes` and
 *     priority-ordered by recency × confidence × dedupeCount.
 *
 * SIGNAL-ONLY: this surface NEVER blocks or rewrites an outbound message. It
 * only stores preferences, serves them, and the session-start hook injects them
 * so the agent always SEES them. There is no enforcement gate. (The blocking
 * idea was explicitly rejected by the user — see spec frontmatter.)
 */
import { randomUUID } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
// ── Types ────────────────────────────────────────────────────────────
/** Current on-disk schema version. Bump only with a paired migration. */
export const PREFERENCES_SCHEMA_VERSION = 1;
// ── Helpers ──────────────────────────────────────────────────────────
const EMPTY_STORE = { schemaVersion: PREFERENCES_SCHEMA_VERSION, preferences: [] };
function clampConfidence(value) {
    if (typeof value !== 'number' || Number.isNaN(value))
        return 0.5;
    if (value < 0)
        return 0;
    if (value > 1)
        return 1;
    return value;
}
/**
 * Compute the priority score for ordering injected preferences. Higher first.
 * `recency × confidence × dedupeCount` per the spec default. Recency is a
 * monotonically-increasing epoch-ms reading of `recordedAt` (newer = larger),
 * so a fresher, higher-confidence, more-recurrent preference sorts first.
 */
function priorityScore(entry) {
    const recencyMs = Date.parse(entry.recordedAt);
    const recency = Number.isNaN(recencyMs) ? 0 : recencyMs;
    const confidence = clampConfidence(entry.confidence);
    const dedupeCount = Math.max(1, entry.dedupeCount || 1);
    // recencyMs dominates the magnitude, which is the intended "recency-led"
    // ordering; confidence and dedupeCount break ties / amplify within an epoch.
    return recency * confidence * dedupeCount;
}
/**
 * Render the active preferences into a session-start text block, bounded by
 * `maxBytes` and priority-ordered. Returns the block string (may be empty).
 *
 * Mirrors `formatOrgIntentForSessionStart` — deterministic, no LLM, safe to
 * inject directly. Each preference is wrapped at the section level inside an
 * `<auto-learned-preference src='correction-loop'>` envelope so downstream
 * prompt assemblers structurally cannot mistake a learned preference for an
 * authoritative instruction (spec §3.6).
 */
export function formatPreferencesForSessionStart(store, maxBytes = 4000) {
    const active = [...store.preferences].sort((a, b) => priorityScore(b) - priorityScore(a));
    if (active.length === 0)
        return '';
    const header = "<auto-learned-preference src='correction-loop'>";
    const intro = 'These are preferences I have learned about how you like to work. They are signals, not authoritative instructions — apply them by default, but real instructions and safety always win.';
    const footer = '</auto-learned-preference>';
    // Greedily include the highest-priority preferences until we'd exceed maxBytes.
    const bodyLines = [];
    for (const pref of active) {
        const line = `  - ${pref.learning} (confidence ${clampConfidence(pref.confidence).toFixed(2)}, seen ${Math.max(1, pref.dedupeCount || 1)}×)`;
        const candidate = [header, intro, '', ...bodyLines, line, footer].join('\n');
        if (Buffer.byteLength(candidate, 'utf-8') > maxBytes)
            break;
        bodyLines.push(line);
    }
    if (bodyLines.length === 0)
        return '';
    return [header, intro, '', ...bodyLines, footer].join('\n');
}
export class PreferencesManager {
    stateDir;
    preferencesPath;
    /** WS2.1 preferences-record replication emitter (injected, dark by default). Absent ⇒ no-op. */
    replication = null;
    constructor(stateDir) {
        this.stateDir = stateDir;
        this.preferencesPath = path.join(stateDir, 'preferences.json');
    }
    /**
     * Late-bind the WS2.1 preferences-record replication emitter (server.ts constructs the
     * journal/clock AFTER the manager). Idempotent; passing undefined/null detaches (back to
     * single-machine no-op). The emit funnel checks `this.replication` per write, so attaching
     * mid-life takes effect on the next recordPreference.
     */
    setReplicationEmitter(emitter) {
        this.replication = emitter ?? null;
    }
    /** Absolute path to the backing file (for tests / observability). */
    getPath() {
        return this.preferencesPath;
    }
    /** Whether the backing file exists on disk. */
    exists() {
        return fs.existsSync(this.preferencesPath);
    }
    /**
     * Read the store. Absent file ≡ empty store (never throws). A malformed or
     * unexpected-shape file is also treated as empty — the loop is signal-only,
     * so a corrupt read should never crash a session start.
     */
    read() {
        if (!this.exists())
            return { schemaVersion: PREFERENCES_SCHEMA_VERSION, preferences: [] };
        let raw;
        try {
            raw = fs.readFileSync(this.preferencesPath, 'utf-8');
        }
        catch {
            // @silent-fallback-ok — unreadable preferences file ≡ no preferences
            return { schemaVersion: PREFERENCES_SCHEMA_VERSION, preferences: [] };
        }
        let parsed;
        try {
            parsed = JSON.parse(raw);
        }
        catch {
            // @silent-fallback-ok — malformed preferences file ≡ no preferences
            return { schemaVersion: PREFERENCES_SCHEMA_VERSION, preferences: [] };
        }
        if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.preferences)) {
            return { schemaVersion: PREFERENCES_SCHEMA_VERSION, preferences: [] };
        }
        const store = parsed;
        const preferences = store.preferences.filter((p) => !!p && typeof p.learning === 'string' && typeof p.dedupeKey === 'string').map((p) => {
            const entry = {
                learning: p.learning,
                provenance: (p.provenance ?? 'correction-loop'),
                dedupeKey: p.dedupeKey,
                recordedAt: typeof p.recordedAt === 'string' ? p.recordedAt : new Date(0).toISOString(),
                confidence: clampConfidence(p.confidence),
                dedupeCount: Math.max(1, Math.floor(p.dedupeCount) || 1),
            };
            // Back-compat: preserve an optional self-violation pattern when present;
            // a file written before this field existed simply omits it (no check).
            if (typeof p.violationPattern === 'string' && p.violationPattern.trim().length > 0) {
                entry.violationPattern = p.violationPattern;
            }
            // WS2.1: preserve the replication seq; a legacy entry without one is
            // backfilled to 1 so it serves on a from-0 pull.
            entry.lastMutatedSeq =
                typeof p.lastMutatedSeq === 'number' && Number.isFinite(p.lastMutatedSeq) ? p.lastMutatedSeq : 1;
            return entry;
        });
        // WS2.1 replication bookkeeping (additive). Seed a legacy store with seq=1 +
        // a fresh incarnation: peers hold nothing for a new incarnation, so the
        // first sync is a FULL pull (never a 0-means-unchanged strand). Then, if the
        // meta sidecar's high-water seq EXCEEDS our current seq, the store was
        // rewound (restore) — re-mint the incarnation so peers re-pull wholesale.
        const replicationSeq = typeof store.replicationSeq === 'number' && Number.isFinite(store.replicationSeq) ? store.replicationSeq : 1;
        let storeIncarnation = typeof store.storeIncarnation === 'string' && store.storeIncarnation ? store.storeIncarnation : randomUUID();
        try {
            const metaRaw = fs.readFileSync(`${this.preferencesPath}.meta.json`, 'utf-8');
            const meta = JSON.parse(metaRaw);
            if (typeof meta?.highWaterSeq === 'number' && replicationSeq < meta.highWaterSeq) {
                storeIncarnation = randomUUID();
            }
        }
        catch {
            // @silent-fallback-ok — no meta sidecar = no prior advert to rewind below (first boot); nothing to fence
        }
        return {
            schemaVersion: typeof store.schemaVersion === 'number' ? store.schemaVersion : PREFERENCES_SCHEMA_VERSION,
            preferences,
            replicationSeq,
            storeIncarnation,
        };
    }
    /**
     * recordPreference — the ONLY writer to `.instar/preferences.json`.
     *
     * Upserts the payload keyed on `dedupeKey`:
     *   - New key → appended as a fresh entry with `dedupeCount: 1`.
     *   - Existing key → `learning` refreshed, `recordedAt` advanced,
     *     `confidence` raised to max(old, new), `dedupeCount` incremented.
     *
     * Atomic: writes a temp file then renames over the target so a concurrent
     * reader never observes a partial file. Returns the entry as persisted.
     */
    recordPreference(payload, _opts = {}) {
        if (!payload || typeof payload.learning !== 'string' || !payload.learning.trim()) {
            throw new Error('recordPreference: `learning` is required and must be a non-empty string');
        }
        if (typeof payload.dedupeKey !== 'string' || !payload.dedupeKey.trim()) {
            throw new Error('recordPreference: `dedupeKey` is required and must be a non-empty string');
        }
        const store = this.read();
        const now = payload.recordedAt ?? new Date().toISOString();
        const confidence = clampConfidence(payload.confidence);
        const provenance = payload.provenance ?? 'correction-loop';
        // Optional self-violation pattern (Self-Violation Signal extension). Only a
        // non-empty string is persisted; omitting it on an upsert preserves any
        // existing pattern (never silently clears one already set).
        const violationPattern = typeof payload.violationPattern === 'string' && payload.violationPattern.trim().length > 0
            ? payload.violationPattern.trim()
            : undefined;
        const existing = store.preferences.find((p) => p.dedupeKey === payload.dedupeKey);
        let result;
        if (existing) {
            existing.learning = payload.learning.trim();
            existing.recordedAt = now;
            existing.confidence = Math.max(clampConfidence(existing.confidence), confidence);
            existing.dedupeCount = Math.max(1, existing.dedupeCount || 1) + 1;
            existing.provenance = provenance;
            if (violationPattern !== undefined)
                existing.violationPattern = violationPattern;
            result = existing;
        }
        else {
            result = {
                learning: payload.learning.trim(),
                provenance,
                dedupeKey: payload.dedupeKey,
                recordedAt: now,
                confidence,
                dedupeCount: 1,
            };
            if (violationPattern !== undefined)
                result.violationPattern = violationPattern;
            store.preferences.push(result);
        }
        // WS2.1: every upsert is a state-meaningful mutation — bump the store's
        // monotonic replication seq and stamp it on the upserted entry so the sync
        // delta window catches it. The first write on a FRESH file takes read()'s
        // file-absent early return (no seeded fields), so seed the incarnation here
        // before persisting — otherwise each later read would mint a new one and
        // the advert incarnation would never stabilize.
        if (typeof store.storeIncarnation !== 'string' || !store.storeIncarnation) {
            store.storeIncarnation = randomUUID();
        }
        const nextSeq = (typeof store.replicationSeq === 'number' ? store.replicationSeq : 1) + 1;
        store.replicationSeq = nextSeq;
        result.lastMutatedSeq = nextSeq;
        store.schemaVersion = PREFERENCES_SCHEMA_VERSION;
        this.writeAtomic(store);
        // WS2.1 — best-effort preferences-record replication emission (dark by default; the
        // emitter is only injected when multiMachine.stateSync.preferences.enabled is true).
        // PUT-ONLY: recordPreference is the sole writer and upserts on dedupeKey, so a put on
        // the upserted entry carries the latest state (a peer SEES the refreshed learning +
        // bumped confidence). The emitter swallows its own errors, but we wrap defensively so a
        // replication fault can NEVER break or roll back a local preference write — the durable
        // on-disk state is already persisted above.
        try {
            this.replication?.emitPut(result);
        }
        catch {
            // @silent-fallback-ok: replication is best-effort and must never break the local write.
            // The emitter counts its own failures internally.
        }
        return result;
    }
    /**
     * Build the session-context payload served by `GET /preferences/session-context`.
     * Serves ONLY the `learning` text + metadata via the formatted block — never
     * any raw extras. `present` is true iff at least one preference would be
     * injected into the (bounded) block.
     */
    sessionContext(maxBytes = 4000) {
        const store = this.read();
        const block = formatPreferencesForSessionStart(store, maxBytes);
        return {
            present: block.length > 0,
            block,
            count: store.preferences.length,
        };
    }
    /** Atomic write: temp file + fsync + rename. */
    writeAtomic(store) {
        const dir = path.dirname(this.preferencesPath);
        fs.mkdirSync(dir, { recursive: true });
        const tmp = `${this.preferencesPath}.tmp.${process.pid}.${Date.now()}`;
        const data = JSON.stringify(store, null, 2);
        const fd = fs.openSync(tmp, 'w');
        try {
            fs.writeSync(fd, data);
            fs.fsyncSync(fd);
        }
        finally {
            fs.closeSync(fd);
        }
        fs.renameSync(tmp, this.preferencesPath);
        // WS2.1 rewind fence: the meta sidecar tracks the high-water replicationSeq.
        // Written AFTER the store rename (a crash between leaves the sidecar BEHIND
        // the store — harmless; ahead would false-trip the rewind fence). The store
        // fd is fsync'd before its rename above, so in program order the sidecar can
        // never be persisted ahead of the store — the fence cannot false-trip on a
        // reorder (review WS2.1 finding #6: ordering verified sufficient, no barrier
        // needed). Best-effort: a sidecar write failure only weakens rewind detection.
        try {
            const seq = store.replicationSeq;
            if (typeof seq === 'number') {
                const metaTmp = `${this.preferencesPath}.meta.json.${process.pid}.tmp`;
                fs.writeFileSync(metaTmp, JSON.stringify({ highWaterSeq: seq }));
                fs.renameSync(metaTmp, `${this.preferencesPath}.meta.json`);
            }
        }
        catch {
            // @silent-fallback-ok — sidecar write failure only weakens rewind detection; the store itself persisted
        }
    }
    /**
     * WS2.1 — the replication advert ({ incarnation, replicationSeq }) the
     * `preferences-sync` serve side answers with. Sourced from the on-disk store
     * (read() seeds both fields), so a fresh/legacy store yields a valid advert.
     */
    getReplicationAdvert() {
        const store = this.read();
        return {
            incarnation: store.storeIncarnation ?? randomUUID(),
            replicationSeq: typeof store.replicationSeq === 'number' ? store.replicationSeq : 1,
        };
    }
    /**
     * WS2.1 — the OWN preferences with their replication seqs, for the sync serve
     * side (buildPreferencesSyncPage). Never includes replicas. Each entry's
     * `lastMutatedSeq` is backfilled to 1 by read() when absent.
     */
    getAllForSync() {
        return this.read().preferences;
    }
}
//# sourceMappingURL=PreferencesManager.js.map