import fs from "node:fs";
import path from "node:path";
/**
 * Persistent structured key-value store used for cross-run state (processed
 * ids, visited URLs, tickets, observations, counters, cursors, etc.). One
 * JSON file per collection under `<workspace>/store/`.
 *
 * Why a separate tool from memory:
 *   - memory is for freeform human-curated markdown notes; the LLM must read
 *     the full file to answer "have I seen X?", which bloats every transcript.
 *   - store.has does a local O(1) lookup and returns "yes"/"no" — no full
 *     file ever crosses into the LLM context.
 *
 * Design choices:
 *   - JSON file per collection (not SQLite) so the data is portable across
 *     hosts/platforms with zero native deps.
 *   - Lazy load on first access; in-memory Map mirror for fast has/get.
 *   - Atomic writes via tmp-then-rename so a crash mid-write can't corrupt
 *     the file.
 *   - Single-writer assumption (one dipclaw process per workspace).
 */
const STORE_SCHEMA_VERSION = 1;
const COLLECTION_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
const MAX_ID_LENGTH = 200;
const MAX_VALUE_BYTES = 4 * 1024;
const MAX_META_BYTES = 4 * 1024;
const MAX_ENTRIES_PER_COLLECTION = 50_000;
const DEFAULT_LIST_LIMIT = 20;
const MAX_LIST_LIMIT = 100;
function byteLength(s) {
    return Buffer.byteLength(s, "utf-8");
}
export class StoreManager {
    storeDir;
    /** Lazy-loaded in-memory mirror. Null until the file has been opened. */
    cache = new Map();
    /**
     * Collections whose on-disk file couldn't be parsed. Any operation on these
     * collections returns an error until an operator inspects/fixes the file.
     * This is deliberately fail-CLOSED: silently treating a corrupted state
     * file as empty (the prior behavior) would re-process every historical id
     * and let a subsequent put() overwrite the corrupted file with a single-entry
     * blob, permanently destroying recovery information.
     */
    erroredCollections = new Map(); // collection -> error message
    constructor(workspace) {
        this.storeDir = path.join(workspace, "store");
        fs.mkdirSync(this.storeDir, { recursive: true });
    }
    has(collection, id) {
        const validation = this.validateCollectionAndId(collection, id);
        if (validation)
            return validation;
        const errored = this.checkErrored(collection);
        if (errored)
            return errored;
        const map = this.load(collection);
        return map.has(id) ? "yes" : "no";
    }
    put(collection, id, value, meta) {
        const validation = this.validateCollectionAndId(collection, id);
        if (validation)
            return validation;
        const errored = this.checkErrored(collection);
        if (errored)
            return errored;
        if (value !== undefined && value !== null) {
            if (typeof value !== "string") {
                return `store.put rejected: value must be a string (got ${typeof value}).`;
            }
            if (byteLength(value) > MAX_VALUE_BYTES) {
                return `store.put rejected: value is ${byteLength(value)} bytes, cap is ${MAX_VALUE_BYTES}.`;
            }
        }
        if (meta !== undefined && meta !== null) {
            if (typeof meta !== "object" || Array.isArray(meta)) {
                return `store.put rejected: meta must be a JSON object.`;
            }
            let metaJson;
            try {
                metaJson = JSON.stringify(meta);
            }
            catch {
                return `store.put rejected: meta is not JSON-serializable.`;
            }
            if (byteLength(metaJson) > MAX_META_BYTES) {
                return `store.put rejected: meta is ${byteLength(metaJson)} bytes, cap is ${MAX_META_BYTES}.`;
            }
        }
        const map = this.load(collection);
        const existed = map.has(id);
        if (!existed && map.size >= MAX_ENTRIES_PER_COLLECTION) {
            return `store.put rejected: collection "${collection}" is at the ${MAX_ENTRIES_PER_COLLECTION} entry cap; clean up old entries with store.delete before adding more.`;
        }
        const now = Date.now();
        const prior = map.get(id);
        const entry = {
            value: value ?? null,
            meta: meta ?? null,
            created_at: prior?.created_at ?? now,
            updated_at: now,
        };
        map.set(id, entry);
        this.persist(collection, map);
        return existed ? `updated` : `ok`;
    }
    get(collection, id) {
        const validation = this.validateCollectionAndId(collection, id);
        if (validation)
            return validation;
        const errored = this.checkErrored(collection);
        if (errored)
            return errored;
        const map = this.load(collection);
        const entry = map.get(id);
        if (!entry) {
            return `store.get: id "${id}" not found in "${collection}".`;
        }
        return JSON.stringify({ id, ...entry });
    }
    list(collection, options) {
        const validation = this.validateCollectionName(collection);
        if (validation)
            return validation;
        const errored = this.checkErrored(collection);
        if (errored)
            return errored;
        const map = this.load(collection);
        const limit = Math.min(Math.max(options?.limit ?? DEFAULT_LIST_LIMIT, 1), MAX_LIST_LIMIT);
        const sinceMs = options?.sinceMs;
        let entries = Array.from(map.entries()).map(([id, entry]) => ({
            id,
            ...entry,
        }));
        if (typeof sinceMs === "number") {
            entries = entries.filter((e) => e.updated_at >= sinceMs);
        }
        entries.sort((a, b) => b.updated_at - a.updated_at);
        const shown = entries.slice(0, limit);
        const remaining = entries.length - shown.length;
        if (shown.length === 0) {
            return `store.list: collection "${collection}" has no entries${typeof sinceMs === "number" ? ` since ${new Date(sinceMs).toISOString()}` : ""}.`;
        }
        const note = remaining > 0
            ? `\n\n(${remaining} older entries not shown; raise limit up to ${MAX_LIST_LIMIT} or pass sinceMs to filter.)`
            : "";
        return `Found ${shown.length} entr${shown.length === 1 ? "y" : "ies"} in "${collection}" (newest first):\n${JSON.stringify(shown, null, 2)}${note}`;
    }
    count(collection) {
        const validation = this.validateCollectionName(collection);
        if (validation)
            return validation;
        const errored = this.checkErrored(collection);
        if (errored)
            return errored;
        const map = this.load(collection);
        return String(map.size);
    }
    delete(collection, id) {
        const validation = this.validateCollectionAndId(collection, id);
        if (validation)
            return validation;
        const errored = this.checkErrored(collection);
        if (errored)
            return errored;
        const map = this.load(collection);
        if (!map.has(id)) {
            return `store.delete: id "${id}" not in "${collection}" (no-op).`;
        }
        map.delete(id);
        this.persist(collection, map);
        return `ok`;
    }
    /**
     * Bulk insert. Used by the migration script; not exposed as an LLM action.
     * Returns counts so the script can report how many were added vs already
     * present. Throws on validation / errored-collection so the migration
     * script can refuse to archive originals when bulk insert isn't safe.
     */
    bulkPut(collection, entries) {
        const validation = this.validateCollectionName(collection);
        if (validation)
            throw new Error(validation);
        const errored = this.checkErrored(collection);
        if (errored)
            throw new Error(errored);
        const map = this.load(collection);
        let added = 0;
        let updated = 0;
        let rejected = 0;
        const now = Date.now();
        for (const item of entries) {
            if (!item.id || item.id.length > MAX_ID_LENGTH) {
                rejected++;
                continue;
            }
            if (!map.has(item.id) && map.size >= MAX_ENTRIES_PER_COLLECTION) {
                rejected++;
                continue;
            }
            const prior = map.get(item.id);
            const entry = {
                value: item.value ?? null,
                meta: item.meta ?? null,
                created_at: prior?.created_at ?? now,
                updated_at: now,
            };
            if (prior)
                updated++;
            else
                added++;
            map.set(item.id, entry);
        }
        this.persist(collection, map);
        return { added, updated, rejected };
    }
    /** For tests / debug. */
    collectionPath(collection) {
        return path.join(this.storeDir, `${collection}.json`);
    }
    // --- private ---
    validateCollectionName(collection) {
        if (typeof collection !== "string" || !collection) {
            return `store rejected: collection name is required.`;
        }
        if (!COLLECTION_NAME_RE.test(collection)) {
            return `store rejected: collection "${collection}" is invalid. Use lowercase letters, digits, '-' or '_' (1-64 chars, must start with letter/digit).`;
        }
        return null;
    }
    validateCollectionAndId(collection, id) {
        const c = this.validateCollectionName(collection);
        if (c)
            return c;
        if (typeof id !== "string" || !id) {
            return `store rejected: id is required.`;
        }
        if (id.length > MAX_ID_LENGTH) {
            return `store rejected: id is ${id.length} chars, cap is ${MAX_ID_LENGTH}.`;
        }
        return null;
    }
    /**
     * Returns an error message if this collection is in the errored state
     * (its on-disk file failed to parse), else null. Side-effects load() if
     * the collection hasn't been touched yet — load() is what populates
     * erroredCollections.
     */
    checkErrored(collection) {
        // Touch load() so a corrupted file gets discovered on first operation
        // rather than only after a put() attempt.
        this.load(collection);
        const msg = this.erroredCollections.get(collection);
        if (!msg)
            return null;
        return (`store error: collection "${collection}" is in an unrecoverable state ` +
            `and all operations are blocked. ${msg} ` +
            `Inspect ${path.join(this.storeDir, `${collection}.json`)} ` +
            `and either fix the JSON or move the file aside and re-run the migration script.`);
    }
    load(collection) {
        const cached = this.cache.get(collection);
        if (cached)
            return cached;
        const filePath = path.join(this.storeDir, `${collection}.json`);
        const map = new Map();
        if (fs.existsSync(filePath)) {
            try {
                const raw = fs.readFileSync(filePath, "utf-8");
                const parsed = JSON.parse(raw);
                if (!parsed || typeof parsed !== "object" || !parsed.entries) {
                    throw new Error(`schema mismatch — expected { entries: {...} } at top level`);
                }
                for (const [id, entry] of Object.entries(parsed.entries)) {
                    if (entry && typeof entry === "object") {
                        map.set(id, entry);
                    }
                }
            }
            catch (err) {
                // Fail CLOSED: mark the collection as errored. Every subsequent
                // operation returns an error until an operator clears the file.
                // The alternative (continue with empty Map) would silently treat the
                // structured history as gone and let a put() overwrite the bad file —
                // destroying the originals.
                const msg = `parse failed: ${err.message}.`;
                this.erroredCollections.set(collection, msg);
                console.error(`[store] ${filePath}: ${msg}`);
            }
        }
        this.cache.set(collection, map);
        return map;
    }
    persist(collection, map) {
        const filePath = path.join(this.storeDir, `${collection}.json`);
        const tmpPath = `${filePath}.tmp`;
        const entries = {};
        for (const [id, entry] of map)
            entries[id] = entry;
        const body = {
            schemaVersion: STORE_SCHEMA_VERSION,
            collection,
            entries,
        };
        fs.writeFileSync(tmpPath, JSON.stringify(body, null, 2), "utf-8");
        fs.renameSync(tmpPath, filePath);
    }
}
//# sourceMappingURL=store.js.map