/**
 * CorrectionLedger — distilled, scrubbed correction/preference records (spec §3.4).
 *
 * The conversational twin of FailureLedger. Where that ledger stores dev-process
 * failures, this stores the LESSON distilled from a moment the user had to
 * correct the agent — privacy-safe: ONLY the post-scrubbed `learning` +
 * `scrubbed_summary` + metadata ever land here. Raw conversation never persists.
 *
 * Mirrors FailureLedger's discipline (spec §3.4):
 *  - dedupe-upsert keyed on `kind:normalizedLearningHash` so a recurring lesson
 *    collapses to ONE record (occurrenceCount increments) — message volume can
 *    never manufacture support.
 *  - `correction_occurrences` is a bounded forensic log (DEFAULT_MAX_OCCURRENCES_
 *    PER_KEY = 200, prune-in-transaction with the insert). The analyzer's
 *    distinct-day / distinct-topic counts are computed from this bounded table.
 *  - indexes idx_corr_dedupe + idx_corr_detected (explicitly required).
 *  - toApiView() strips everything but scrubbed_summary + metadata — the
 *    /corrections API never serves raw `learning` text.
 *  - countRecords() health metric so distinct-key growth (an unstable-hash LLM)
 *    is observable.
 *  - fail-open: a write error logs + drops; it never throws back into the
 *    capture/distill seam (which must keep its fire-and-forget contract).
 *
 * dayBucket is UTC, CODE-derived (never LLM-set). deterministicWeight is the
 * Layer-0 total weight, CODE-set — it is the provenance field the analyzer's
 * gate keys on (spec §3.5). llm_confidence is LLM-set + advisory only.
 */
import Database from 'better-sqlite3';
import { registerSqliteHandle } from '../core/SqliteRegistry.js';
import crypto from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { NativeModuleHealer } from '../memory/NativeModuleHealer.js';
/** Default forensic-log retention per dedupeKey (mirrors FailureLedger §5). */
export const DEFAULT_MAX_OCCURRENCES_PER_KEY = 200;
/**
 * Stop-words stripped before hashing so semantically-identical learnings phrased
 * differently collapse to ONE dedupeKey. Conservative + deterministic — the hash
 * stability is a unit-tested invariant.
 */
const STOP_WORDS = new Set([
    'a', 'an', 'the', 'is', 'are', 'was', 'were', 'be', 'been', 'being',
    'to', 'of', 'in', 'on', 'for', 'with', 'and', 'or', 'but', 'i', 'you',
    'me', 'my', 'your', 'it', 'this', 'that', 'please', 'just', 'always',
    'when', 'do', "don't", 'dont', "i'd", 'id', 'would', 'should', 'so',
    'at', 'by', 'as', 'from', 'into', 'about', 'prefer', 'rather',
]);
// ── Schema ────────────────────────────────────────────────────────────
const SCHEMA = [
    `CREATE TABLE IF NOT EXISTS correction_records (
     id                   TEXT PRIMARY KEY,
     dedupe_key           TEXT NOT NULL UNIQUE,
     kind                 TEXT NOT NULL,
     occurrence_count     INTEGER NOT NULL DEFAULT 1,
     detected_at          TEXT NOT NULL,
     learning             TEXT NOT NULL DEFAULT '',
     scrubbed_summary     TEXT NOT NULL DEFAULT '',
     day_bucket           TEXT NOT NULL,
     deterministic_weight INTEGER NOT NULL DEFAULT 0,
     llm_confidence       REAL NOT NULL DEFAULT 0,
     topic_id             INTEGER,
     session_id           TEXT,
     status               TEXT NOT NULL DEFAULT 'open',
     routed_via           TEXT,
     verify_window_start  TEXT,
     verify_window_end    TEXT,
     reopen_count         INTEGER NOT NULL DEFAULT 0,
     created_at           TEXT NOT NULL,
     updated_at           TEXT NOT NULL,
     version              INTEGER NOT NULL DEFAULT 1
   )`,
    // Detected-at index for the analyzer's windowed scan + keyset pagination.
    `CREATE INDEX IF NOT EXISTS idx_corr_detected ON correction_records(detected_at)`,
    // Bounded forensic log — feeds COUNT(DISTINCT day_bucket / topic_id) for the
    // analyzer's diversity prongs without growing an unbounded set on the record.
    `CREATE TABLE IF NOT EXISTS correction_occurrences (
     id            INTEGER PRIMARY KEY AUTOINCREMENT,
     dedupe_key    TEXT NOT NULL,
     day_bucket    TEXT NOT NULL,
     topic_id      INTEGER,
     deterministic_weight INTEGER NOT NULL DEFAULT 0,
     detected_at   TEXT NOT NULL
   )`,
    `CREATE INDEX IF NOT EXISTS idx_corr_dedupe ON correction_occurrences(dedupe_key)`,
    // Composite index backing the distinctCounts() COUNT(DISTINCT day_bucket)
    // query (spec §10 Slice-2 NEW-4). The analyzer's restart-proof distinct-day
    // prong runs `WHERE dedupe_key = ? AND deterministic_weight >= ?` then groups
    // by day_bucket; (dedupe_key, day_bucket) lets SQLite satisfy the distinct-day
    // count from the index without scanning the per-key occurrence rows.
    `CREATE INDEX IF NOT EXISTS idx_corr_dedupe_day ON correction_occurrences(dedupe_key, day_bucket)`,
    // Per-machine monotonic sequence for machine-scoped IDs.
    `CREATE TABLE IF NOT EXISTS correction_seq (
     machine_id TEXT PRIMARY KEY,
     next_seq   INTEGER NOT NULL DEFAULT 1
   )`,
];
// ── CorrectionLedger ──────────────────────────────────────────────────
export class CorrectionLedger {
    db;
    machineId;
    onError;
    maxOccurrencesPerKey;
    constructor(opts) {
        this.machineId = (opts.machineId || os.hostname() || 'local')
            .replace(/[^a-zA-Z0-9]+/g, '-')
            .replace(/^-+|-+$/g, '')
            .slice(0, 24) || 'local';
        this.maxOccurrencesPerKey =
            opts.maxOccurrencesPerKey && opts.maxOccurrencesPerKey > 0
                ? opts.maxOccurrencesPerKey
                : DEFAULT_MAX_OCCURRENCES_PER_KEY;
        this.onError =
            opts.onError ?? ((where, err) => console.error(`[CorrectionLedger] ${where}:`, err));
        if (opts.dbPath !== ':memory:') {
            fs.mkdirSync(path.dirname(opts.dbPath), { recursive: true });
        }
        this.db = NativeModuleHealer.openWithHealSync('CorrectionLedger', () => new Database(opts.dbPath));
        this.db.pragma('journal_mode = WAL');
        this.db.pragma('synchronous = NORMAL');
        for (const ddl of SCHEMA)
            this.db.exec(ddl);
        // Close-on-exit registry (SqliteRegistry.ts) — closed once at shutdown.
        registerSqliteHandle(() => { try {
            this.db?.close();
        }
        catch { /* already closed */ } });
    }
    /**
     * Canonical normalization of a distilled learning before hashing: lowercase,
     * strip punctuation, collapse whitespace, drop stop-words, sort the remaining
     * tokens. Two semantically-identical learnings phrased differently collapse to
     * the SAME hash. Stability is a unit-tested invariant (spec §3.4).
     */
    static normalizeLearning(learning) {
        const tokens = String(learning)
            .toLowerCase()
            .replace(/[^a-z0-9\s']/g, ' ')
            .split(/\s+/)
            .map((t) => t.trim())
            .filter((t) => t.length > 0 && !STOP_WORDS.has(t));
        return tokens.sort().join(' ');
    }
    /** SHA-256 over the canonical normalized form. */
    static normalizedLearningHash(learning) {
        return crypto.createHash('sha256').update(CorrectionLedger.normalizeLearning(learning)).digest('hex');
    }
    /** Deterministic dedupe key: `kind:normalizedLearningHash` (spec §3.4). */
    static dedupeKey(kind, learning) {
        return `${kind}:${CorrectionLedger.normalizedLearningHash(learning)}`;
    }
    /** UTC day bucket (YYYY-MM-DD), code-derived from an ISO timestamp. */
    static dayBucketOf(isoOrMs) {
        const d = typeof isoOrMs === 'number' ? new Date(isoOrMs) : new Date(isoOrMs);
        return d.toISOString().slice(0, 10);
    }
    nextId() {
        const row = this.db
            .prepare(`SELECT next_seq FROM correction_seq WHERE machine_id = ?`)
            .get(this.machineId);
        const seq = row?.next_seq ?? 1;
        this.db
            .prepare(`INSERT INTO correction_seq (machine_id, next_seq) VALUES (?, ?)
         ON CONFLICT(machine_id) DO UPDATE SET next_seq = ?`)
            .run(this.machineId, seq + 1, seq + 1);
        return `CORR-${this.machineId}-${String(seq).padStart(3, '0')}`;
    }
    /**
     * Record (or upsert) a distilled correction. A repeat on the same dedupeKey
     * increments occurrenceCount + logs a distinct occurrence (feeds the
     * distinct-day / distinct-topic gates) rather than duplicating. Fail-open:
     * returns null on any storage error.
     */
    record(input) {
        try {
            const dedupeKey = CorrectionLedger.dedupeKey(input.kind, input.learning);
            const now = new Date().toISOString();
            const detectedAt = input.detectedAt ?? now;
            const dayBucket = CorrectionLedger.dayBucketOf(detectedAt);
            const llmConfidence = clamp01(input.llmConfidence);
            const txn = this.db.transaction(() => {
                // Always log the occurrence (feeds COUNT(DISTINCT) diversity prongs).
                this.db
                    .prepare(`INSERT INTO correction_occurrences (dedupe_key, day_bucket, topic_id, deterministic_weight, detected_at)
             VALUES (?, ?, ?, ?, ?)`)
                    .run(dedupeKey, dayBucket, input.topicId ?? null, input.deterministicWeight, detectedAt);
                // Bounded forensic log — prune occurrence rows for this key beyond the
                // most-recent N. Safe: the analyzer's distinct counts are also bounded
                // by N, so pruning cannot change a decision (worst case it UNDERcounts,
                // the safe direction — biases toward NOT firing).
                this.db
                    .prepare(`DELETE FROM correction_occurrences
              WHERE dedupe_key = @dedupeKey
                AND id NOT IN (
                  SELECT id FROM correction_occurrences
                   WHERE dedupe_key = @dedupeKey
                   ORDER BY id DESC LIMIT @keep)`)
                    .run({ dedupeKey, keep: this.maxOccurrencesPerKey });
                const id = this.nextId();
                const row = this.db
                    .prepare(`INSERT INTO correction_records
               (id, dedupe_key, kind, occurrence_count, detected_at, learning, scrubbed_summary,
                day_bucket, deterministic_weight, llm_confidence, topic_id, session_id,
                status, reopen_count, created_at, updated_at, version)
             VALUES
               (@id, @dedupeKey, @kind, 1, @detectedAt, @learning, @scrubbedSummary,
                @dayBucket, @deterministicWeight, @llmConfidence, @topicId, @sessionId,
                'open', 0, @createdAt, @updatedAt, 1)
             ON CONFLICT(dedupe_key) DO UPDATE SET
               occurrence_count = occurrence_count + 1,
               detected_at = excluded.detected_at,
               day_bucket = excluded.day_bucket,
               -- keep the strongest deterministic weight seen for this key
               deterministic_weight = MAX(deterministic_weight, excluded.deterministic_weight),
               -- advisory: take the max confidence seen
               llm_confidence = MAX(llm_confidence, excluded.llm_confidence),
               updated_at = excluded.updated_at,
               version = version + 1
             RETURNING id`)
                    .get({
                    id,
                    dedupeKey,
                    kind: input.kind,
                    detectedAt,
                    learning: input.learning,
                    scrubbedSummary: input.scrubbedSummary,
                    dayBucket,
                    deterministicWeight: input.deterministicWeight,
                    llmConfidence,
                    topicId: input.topicId ?? null,
                    sessionId: input.sessionId ?? null,
                    createdAt: now,
                    updatedAt: now,
                });
                return row.id;
            });
            const id = txn();
            return this.get(id);
        }
        catch (err) {
            this.onError('record', err);
            return null;
        }
    }
    get(id) {
        const row = this.db.prepare(`SELECT * FROM correction_records WHERE id = ?`).get(id);
        return row ? this.rowToRecord(row) : null;
    }
    getByDedupeKey(dedupeKey) {
        const row = this.db.prepare(`SELECT * FROM correction_records WHERE dedupe_key = ?`).get(dedupeKey);
        return row ? this.rowToRecord(row) : null;
    }
    list(filter = {}) {
        const where = [];
        const params = {};
        if (filter.kind) {
            where.push('kind = @kind');
            params.kind = filter.kind;
        }
        if (filter.status) {
            where.push('status = @status');
            params.status = filter.status;
        }
        if (filter.sinceMs) {
            where.push('detected_at >= @since');
            params.since = new Date(filter.sinceMs).toISOString();
        }
        if (filter.beforeMs) {
            where.push('detected_at < @before');
            params.before = new Date(filter.beforeMs).toISOString();
        }
        const limit = filter.limit && filter.limit > 0 ? Math.min(filter.limit, 1000) : 100;
        const sql = `SELECT * FROM correction_records` +
            (where.length ? ` WHERE ${where.join(' AND ')}` : '') +
            ` ORDER BY detected_at DESC LIMIT ${limit}`;
        return this.db.prepare(sql).all(params).map((r) => this.rowToRecord(r));
    }
    /**
     * Mutate a record's lifecycle. `ifMatch` is mandatory (OCC) — a stale version
     * loses with {ok:false, conflict:true}.
     */
    update(id, patch, ifMatch) {
        try {
            const current = this.get(id);
            if (!current)
                return { ok: false, conflict: false, reason: 'not-found' };
            if (current.version !== ifMatch)
                return { ok: false, conflict: true, current };
            const map = {
                status: 'status', routedVia: 'routed_via', verifyWindowStart: 'verify_window_start',
                verifyWindowEnd: 'verify_window_end', reopenCount: 'reopen_count',
            };
            const cols = [];
            const params = { id, ifMatch };
            for (const [k, v] of Object.entries(patch)) {
                const col = map[k];
                if (!col)
                    continue;
                cols.push(`${col} = @${k}`);
                params[k] = v ?? null;
            }
            if (cols.length === 0)
                return { ok: true, record: current };
            const now = new Date().toISOString();
            const res = this.db
                .prepare(`UPDATE correction_records SET ${cols.join(', ')}, updated_at = @now, version = version + 1 WHERE id = @id AND version = @ifMatch`)
                .run({ ...params, now });
            if (res.changes === 0)
                return { ok: false, conflict: true, current: this.get(id) ?? undefined };
            return { ok: true, record: this.get(id) };
        }
        catch (err) {
            this.onError('update', err);
            return { ok: false, conflict: false, reason: 'storage-error' };
        }
    }
    /**
     * Distinct calendar days + distinct topics for the recurrence gate (spec §3.5).
     * Computed from the bounded occurrences table. Restart-proof (keys on UTC
     * day_bucket, NOT a session count instar's frequent restarts would inflate).
     * `qualifying` only counts occurrences whose deterministic_weight ≥ threshold
     * (the code-determined provenance filter — an LLM-only-confident occurrence
     * never alone admits a record to the gate).
     */
    distinctCounts(dedupeKey, weightThreshold = 0) {
        const row = this.db
            .prepare(`SELECT COUNT(DISTINCT day_bucket) AS days,
                COUNT(DISTINCT topic_id)  AS topics,
                COUNT(*)                  AS occ
           FROM correction_occurrences
          WHERE dedupe_key = @dedupeKey AND deterministic_weight >= @threshold`)
            .get({ dedupeKey, threshold: weightThreshold });
        return {
            distinctDays: row?.days ?? 0,
            distinctTopics: row?.topics ?? 0,
            qualifyingOccurrences: row?.occ ?? 0,
        };
    }
    /** Total deduped record count — health metric for distinct-key growth (spec §3.4). */
    countRecords() {
        const row = this.db.prepare(`SELECT COUNT(*) c FROM correction_records`).get();
        return row?.c ?? 0;
    }
    /** Raw forensic-log row count (observability + retention-cap test hook). */
    countOccurrences(dedupeKey) {
        const row = dedupeKey
            ? this.db.prepare(`SELECT COUNT(*) c FROM correction_occurrences WHERE dedupe_key = ?`).get(dedupeKey)
            : this.db.prepare(`SELECT COUNT(*) c FROM correction_occurrences`).get();
        return (row?.c) ?? 0;
    }
    /**
     * Names of the indexes present on the occurrences table — observability + a
     * test hook pinning that the distinct-days composite index (spec §10 Slice-2
     * NEW-4) is actually created. Read-only.
     */
    listOccurrenceIndexes() {
        const rows = this.db
            .prepare(`SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'correction_occurrences'`)
            .all();
        return rows.map((r) => r.name).filter((n) => !n.startsWith('sqlite_'));
    }
    /** Strip `learning` (raw distilled text) — the ONLY shape allowed over HTTP (spec §3.4). */
    static toApiView(record) {
        const { learning: _internalLearning, sessionId: _internalSession, ...rest } = record;
        void _internalLearning;
        void _internalSession;
        return { ...rest };
    }
    close() {
        try {
            this.db.close();
        }
        catch { /* ignore */ }
    }
    rowToRecord(r) {
        return {
            id: r.id,
            dedupeKey: r.dedupe_key,
            kind: r.kind,
            occurrenceCount: r.occurrence_count,
            detectedAt: r.detected_at,
            learning: r.learning ?? '',
            scrubbedSummary: r.scrubbed_summary ?? '',
            dayBucket: r.day_bucket,
            deterministicWeight: r.deterministic_weight,
            llmConfidence: r.llm_confidence,
            topicId: r.topic_id ?? null,
            sessionId: r.session_id ?? null,
            status: r.status,
            routedVia: r.routed_via ?? undefined,
            verifyWindowStart: r.verify_window_start ?? undefined,
            verifyWindowEnd: r.verify_window_end ?? undefined,
            reopenCount: r.reopen_count,
            createdAt: r.created_at,
            updatedAt: r.updated_at,
            version: r.version,
        };
    }
}
function clamp01(v) {
    if (typeof v !== 'number' || Number.isNaN(v))
        return 0;
    if (v < 0)
        return 0;
    if (v > 1)
        return 1;
    return v;
}
//# sourceMappingURL=CorrectionLedger.js.map