import { randomUUID } from 'node:crypto';
import * as fs from 'node:fs';
import * as lancedb from '@lancedb/lancedb';
import { runHealthCheck } from './lance-health.js';
import { TOTEM_TABLE_NAME } from './lance-schema.js';
import { runFtsSearch, runHybridSearch, runVectorSearch } from './lance-search.js';
/**
 * Escape a string for use in a LanceDB SQL WHERE clause (single-quoted literal).
 *
 * LanceDB uses DataFusion under the hood, which follows the SQL standard:
 * single quotes are escaped by doubling ('' → ') and backslash is treated
 * as a literal character (NOT an escape character). Therefore only quote
 * doubling is required — escaping backslashes would break path matching.
 */
export function escapeSqlString(input) {
    return input.replace(/'/g, "''");
}
/**
 * Stateless FTS-index probe. Shared by the instance-level `detectFtsIndex`
 * that maintains `this.hasFtsIndex` and by `openReadSnapshot()` which needs
 * to compute the flag for a fresh per-call table handle without touching
 * shared state (mmnto/totem#1418).
 */
async function detectFtsIndexOnTable(table, onWarn) {
    if (!table)
        return false;
    try {
        const indices = await table.listIndices();
        return indices.some((idx) => idx.indexType === 'FTS' || idx.name === 'content_idx');
    }
    catch (err) {
        const msg = err instanceof Error ? err.message : String(err);
        onWarn(`FTS index detection failed: ${msg}`);
        return false;
    }
}
/**
 * Close a per-query LanceDB connection opened by `openReadSnapshot()`.
 * `close()` can throw in some edge cases (e.g., the underlying directory
 * was removed mid-query). Swallow and warn so a failed close never masks
 * a successful search result (mmnto/totem#1418).
 */
function closeReadSnapshot(db, onWarn) {
    try {
        if (db.isOpen()) {
            db.close();
        }
    }
    catch (err) {
        const msg = err instanceof Error ? err.message : String(err);
        onWarn(`LanceDB read-snapshot close failed: ${msg}`);
    }
}
export class LanceStore {
    db = null;
    table = null;
    dbPath;
    embedder;
    hasFtsIndex = false;
    onWarn;
    /**
     * Instrumentation: number of times the read path has reopened the handle
     * via `refreshReadHandle()`. Exposed via `readRefreshCount` so tests
     * (mmnto/totem#1418) can assert the reopen fires on every search.
     */
    readRefreshes = 0;
    /**
     * Source repo context injected at construction time. Primary stores use
     * `{ absolutePathRoot: projectRoot }` with no `sourceRepo`; linked stores
     * (mmnto/totem#1294 Cross-Repo Context Mesh) use
     * `{ sourceRepo: '<link name>', absolutePathRoot: '<linked repo root>' }`.
     * Passed down to every `search` / `searchFts` call so every SearchResult
     * gets stamped with the owning store's identity via `rowToSearchResult`.
     *
     * **Required** since mmnto/totem#1295 — CR flagged that an optional
     * context with a silent `filePath` fallback for `absoluteFilePath` sent
     * legacy callers down the wrong repo root instead of failing fast.
     * Making the parameter required closes that class of bug at the type
     * level and forces every call site to state its source explicitly.
     */
    sourceContext;
    constructor(dbPath, embedder, sourceContext, onWarn) {
        this.dbPath = dbPath;
        this.embedder = embedder;
        this.sourceContext = sourceContext;
        this.onWarn = onWarn ?? (() => { });
    }
    /**
     * Connect to LanceDB. Auto-heals on version mismatch, corruption,
     * or embedder dimension change by deleting the index and signaling
     * that a full rebuild is needed.
     */
    async connect() {
        try {
            this.db = await lancedb.connect(this.dbPath);
            const tableNames = await this.db.tableNames();
            if (tableNames.includes(TOTEM_TABLE_NAME)) {
                this.table = await this.db.openTable(TOTEM_TABLE_NAME);
                // Check for embedder dimension mismatch (#548)
                if (this.table && (await this.hasDimensionMismatch())) {
                    this.onWarn(`[Totem] Embedding dimensions changed. Rebuilding index... Run \`totem sync --full\` if this persists.`);
                    await this.nukeAndReset();
                    return;
                }
                await this.detectFtsIndex();
            }
        }
        catch (err) {
            // Auto-heal: version mismatch, corruption, or schema incompatibility (#500)
            if (this.isHealableError(err)) {
                this.onWarn(`[Totem] Index format incompatible. Upgrading index...`);
                await this.nukeAndReset();
                return;
            }
            throw err;
        }
    }
    /**
     * Connect for FTS-only use — skips embedder dimension validation.
     * Use when the embedder is unavailable (offline, no API key) and
     * only FTS search will be performed.
     */
    async connectFtsOnly() {
        try {
            this.db = await lancedb.connect(this.dbPath);
            const tableNames = await this.db.tableNames();
            if (tableNames.includes(TOTEM_TABLE_NAME)) {
                this.table = await this.db.openTable(TOTEM_TABLE_NAME);
                await this.detectFtsIndex();
            }
        }
        catch (err) {
            // Read-only fallback — never nuke the index. Just warn and leave store empty.
            const detail = err instanceof Error ? (err.stack ?? err.message) : String(err);
            this.onWarn(`[Totem] FTS-only connect failed: ${detail}`);
            this.db = null;
            this.table = null;
        }
    }
    /** Check if the stored vector dimensions differ from the current embedder. */
    async hasDimensionMismatch() {
        if (!this.table)
            return false;
        // Let query errors bubble up to connect()'s catch block for auto-healing
        const sample = await this.table.query().limit(1).toArray();
        if (sample.length === 0)
            return false;
        const row = sample[0];
        const vec = row['vector'];
        if (Array.isArray(vec)) {
            return vec.length !== this.embedder.dimensions;
        }
        return false;
    }
    /** Detect errors that warrant auto-healing (nuke + rebuild). */
    isHealableError(err) {
        if (!(err instanceof Error))
            return false;
        const msg = err.message.toLowerCase();
        return (msg.includes('version mismatch') ||
            msg.includes('data corruption') ||
            msg.includes('invalid schema') ||
            msg.includes('lance error') ||
            msg.includes('not found') ||
            msg.includes('arrow error') ||
            msg.includes('invalid input'));
    }
    /** Delete the entire .lancedb/ directory and reset state. */
    async nukeAndReset() {
        this.db = null;
        this.table = null;
        this.hasFtsIndex = false;
        try {
            await fs.promises.rm(this.dbPath, { recursive: true, force: true });
        }
        catch (err) {
            // OS-level file locks may prevent deletion — warn but don't crash
            const detail = err instanceof Error ? err.message : String(err);
            this.onWarn(`[Totem] Could not delete index at ${this.dbPath}: ${detail}`);
        }
        // Reconnect to create a fresh empty database
        this.db = await lancedb.connect(this.dbPath);
    }
    /** Insert chunks into the store. Embeds them first. */
    async insert(chunks) {
        if (chunks.length === 0)
            return;
        // Build the text to embed: contextPrefix + content
        const textsToEmbed = chunks.map((c) => `${c.contextPrefix} ${c.content}`);
        const vectors = await this.embedder.embed(textsToEmbed);
        const rows = chunks.map((chunk, i) => ({
            id: randomUUID(),
            content: chunk.content,
            contextPrefix: chunk.contextPrefix,
            filePath: chunk.filePath,
            type: chunk.type,
            strategy: chunk.strategy,
            label: chunk.label,
            startLine: chunk.startLine,
            endLine: chunk.endLine,
            metadata: JSON.stringify(chunk.metadata),
            vector: vectors[i],
        }));
        const data = rows;
        if (!this.table) {
            this.table = await this.db.createTable(TOTEM_TABLE_NAME, data);
        }
        else {
            await this.table.add(data);
        }
    }
    /**
     * Create (or rebuild) the FTS index on the `content` column.
     * Must be called after table creation or after incremental adds,
     * because LanceDB FTS indexes do not auto-update on `table.add()`.
     * Uses `replace: true` (the LanceDB default) to overwrite any stale index.
     */
    async createFtsIndex() {
        if (!this.table)
            return;
        try {
            await this.table.createIndex('content', {
                config: lancedb.Index.fts(),
                replace: true,
            });
            this.hasFtsIndex = true;
        }
        catch (err) {
            // Non-fatal: hybrid search degrades to vector-only
            const msg = err instanceof Error ? err.message : String(err);
            this.onWarn(`FTS index creation failed: ${msg}`);
            this.hasFtsIndex = false;
        }
    }
    /** Check whether an FTS index exists on the table. */
    async detectFtsIndex() {
        this.hasFtsIndex = await detectFtsIndexOnTable(this.table, this.onWarn);
    }
    /** Whether the FTS index is available for hybrid search. */
    get ftsIndexReady() {
        return this.hasFtsIndex;
    }
    /**
     * Search with optional hybrid mode (vector + FTS with RRF reranking).
     * Falls back to vector-only if no FTS index exists.
     *
     * Opens a fresh LanceDB snapshot for this call (mmnto/totem#1418) so an
     * external `totem sync` cannot leave this store reading a stale view,
     * and so concurrent searches never invalidate each other's handle.
     */
    async search(options) {
        const snapshot = await this.openReadSnapshot();
        try {
            if (!snapshot.table)
                return [];
            const maxResults = options.maxResults ?? 5;
            const boundary = options.boundary;
            const useHybrid = (options.hybrid ?? true) && snapshot.hasFtsIndex;
            if (useHybrid) {
                return await runHybridSearch(snapshot.table, this.embedder, this.onWarn, options.query, options.typeFilter, maxResults, this.sourceContext, boundary);
            }
            return await runVectorSearch(snapshot.table, this.embedder, options.query, options.typeFilter, maxResults, this.sourceContext, boundary);
        }
        finally {
            closeReadSnapshot(snapshot.db, this.onWarn);
        }
    }
    /**
     * FTS-only search. No embedder required.
     * Use when embedding is unavailable (offline, no API key, cold-start fallback).
     * Requires an FTS index to exist; returns empty if none available.
     *
     * Opens a fresh LanceDB snapshot for this call (mmnto/totem#1418).
     */
    async searchFts(options) {
        const snapshot = await this.openReadSnapshot();
        try {
            if (!snapshot.table || !snapshot.hasFtsIndex)
                return [];
            return await runFtsSearch(snapshot.table, this.onWarn, options.query, options.typeFilter, options.maxResults ?? 5, this.sourceContext, options.boundary);
        }
        finally {
            closeReadSnapshot(snapshot.db, this.onWarn);
        }
    }
    /** Delete all chunks from a specific file (for incremental re-index). */
    async deleteByFile(filePath) {
        if (!this.table)
            return;
        const safePath = escapeSqlString(filePath);
        await this.table.delete(`\`filePath\` = '${safePath}'`);
    }
    /** Drop the entire table. Used for full re-index. */
    async reset() {
        if (!this.db)
            return;
        const tableNames = await this.db.tableNames();
        if (tableNames.includes(TOTEM_TABLE_NAME)) {
            await this.db.dropTable(TOTEM_TABLE_NAME);
        }
        this.table = null;
        this.hasFtsIndex = false;
    }
    /** Re-open the LanceDB connection, picking up rebuilt files after a full sync. */
    async reconnect() {
        this.db = null;
        this.table = null;
        this.hasFtsIndex = false;
        await this.connect();
    }
    /**
     * Open a fresh read snapshot (connection + table + fts flag) for a single
     * query. Scoped to the caller, NOT written onto `this.db` / `this.table`,
     * so concurrent searches don't race each other's handle lifetime
     * (mmnto/totem#1418 Shield CRITICAL follow-up).
     *
     * **Why unconditional reopen.** The MCP server holds LanceStore for the
     * life of the process. When `totem sync` (a separate process) rewrites
     * `.lancedb/` files underneath us, LanceDB's in-memory manifest keeps
     * pointing at the pre-sync snapshot. Vector search against that stale
     * view returns empty, which falls through to FTS-only via the hybrid
     * path, producing uniform ~0.016 RRF scores instead of real similarity
     * ranks. The corrupt path is silent because no exception fires.
     *
     * **Why not mtime-check.** A benchmark (see scripts/bench-lance-open.ts)
     * measured connect+openTable at ~0.5-1.1ms per call against real indexes.
     * That's well under the 10ms threshold where mtime gating starts to pay
     * for its own complexity. Reopening every time is strictly simpler and
     * eliminates a whole category of cache-invalidation bugs.
     *
     * **Why per-call snapshots.** Assigning the fresh connection to `this.db`
     * and then closing the old one from a second concurrent caller would
     * invalidate the first caller's in-flight query. Instead each call holds
     * its own connection + table references through the query lifetime, and
     * closes the connection in a finally block after the results return.
     * The instance-level `this.db` / `this.table` fields are still maintained
     * so write paths and existing consumers (healthCheck, stats, count) see
     * an up-to-date view on the next non-read call via `connect()`.
     *
     * **Why not also refresh write paths.** `totem sync` owns the writer
     * connection exclusively inside a single process. Write operations
     * (insert, deleteByFile, reset) already hold a consistent view for
     * their own workflow and a mid-sequence reopen would drop the in-flight
     * table reference that `insert()` sets during first-table creation.
     */
    async openReadSnapshot() {
        const db = await lancedb.connect(this.dbPath);
        // Shield WARN guard: `tableNames()` and `openTable()` can both throw.
        // Without this try/catch the partially-opened `db` connection would
        // leak if either step failed, because the caller's finally block that
        // calls `closeReadSnapshot` only runs once the snapshot object is
        // returned. Close the connection inline before rethrowing so the
        // failure path is leak-free.
        try {
            const tableNames = await db.tableNames();
            let table = null;
            let hasFtsIndex = false;
            if (tableNames.includes(TOTEM_TABLE_NAME)) {
                table = await db.openTable(TOTEM_TABLE_NAME);
                hasFtsIndex = await detectFtsIndexOnTable(table, this.onWarn);
            }
            this.readRefreshes += 1;
            return { db, table, hasFtsIndex };
        }
        catch (err) {
            closeReadSnapshot(db, this.onWarn);
            throw err;
        }
    }
    /**
     * Test-seam instrumentation (mmnto/totem#1418): total count of read-path
     * handle refreshes since this store was constructed. Asserted by the
     * stale-handle regression test to confirm every search() call reopens.
     */
    get readRefreshCount() {
        return this.readRefreshes;
    }
    /** Return true if the table doesn't exist or has zero rows. */
    async isEmpty() {
        if (!this.table)
            return true;
        return (await this.table.countRows()) === 0;
    }
    /** Return the total number of rows in the store. */
    async count() {
        if (!this.table)
            return 0;
        return this.table.countRows();
    }
    /** Return stats about the current index. */
    async stats() {
        if (!this.table)
            return { totalChunks: 0, byType: {} };
        const totalChunks = await this.table.countRows();
        const typeRows = await this.table.query().select(['type']).toArray();
        const byType = {};
        for (const row of typeRows) {
            const t = row['type'];
            byType[t] = (byType[t] ?? 0) + 1;
        }
        return { totalChunks, byType };
    }
    /** Run a health check against the index, verifying dimensions, search, and FTS. */
    async healthCheck() {
        return runHealthCheck(this.table, this.embedder, (options) => this.search(options), () => this.detectFtsIndex(), () => this.hasFtsIndex);
    }
    /**
     * Returns one entry per distinct `filePath` in the store, with row counts
     * and a derived `origin` (`@scope/pkg` or `pkg` when the path lives under
     * `node_modules/`, otherwise `local`).
     *
     * `lastSynced` on every returned entry is set to the supplied `writtenAt`
     * timestamp (or `new Date()` if omitted). LanceDB rows do not carry per-row
     * sync timestamps in the current schema, so every document in a single
     * manifest necessarily carries the same `lastSynced` value — callers
     * building an `IndexManifest` should pass their `writtenAt` here so that
     * `documents[].lastSynced === manifest.writtenAt` for that run.
     *
     * Output is sorted by `sourceFile` ascending so the manifest payload is
     * deterministic across runs.
     */
    async manifestDocuments(writtenAt = new Date()) {
        if (!this.table)
            return [];
        const snapshot = await this.openReadSnapshot();
        try {
            if (!snapshot.table)
                return [];
            const rows = await snapshot.table.query().select(['filePath']).toArray();
            const counts = new Map();
            for (const r of rows) {
                const p = r['filePath'];
                if (typeof p === 'string') {
                    counts.set(p, (counts.get(p) ?? 0) + 1);
                }
            }
            const docs = [];
            const lastSynced = writtenAt.toISOString();
            for (const [filePath, count] of counts) {
                let origin = 'local';
                // Normalize backslashes so Windows-style paths classify the same as POSIX.
                const normalizedPath = filePath.replace(/\\/g, '/');
                if (normalizedPath.includes('node_modules/')) {
                    const match = normalizedPath.match(/node_modules\/((?:@[^/]+\/)?[^/]+)/);
                    if (match?.[1]) {
                        origin = match[1];
                    }
                }
                docs.push({
                    sourceFile: filePath,
                    origin,
                    rowCount: count,
                    lastSynced,
                });
            }
            return docs.sort((a, b) => a.sourceFile.localeCompare(b.sourceFile));
        }
        finally {
            closeReadSnapshot(snapshot.db, this.onWarn);
        }
    }
    /**
     * Distinct raw `filePath` values currently in the store — the set of source
     * files with at least one chunk. Returns paths AS STORED (NOT normalized),
     * because callers purge via `deleteByFile`, which matches the stored literal;
     * separator normalization for comparison is the caller's job
     * (mmnto-ai/totem#2151 W1). Bounded `select(['filePath'])` projection — never
     * pulls vectors or chunk bodies into memory, but reads O(chunks) rows and
     * dedups in JS (same shape as `manifestDocuments`); a DB-level DISTINCT is
     * tracked in mmnto-ai/totem#2175 for large stores.
     */
    async getDistinctPaths() {
        if (!this.table)
            return [];
        const snapshot = await this.openReadSnapshot();
        try {
            if (!snapshot.table)
                return [];
            const rows = await snapshot.table.query().select(['filePath']).toArray();
            const paths = new Set();
            for (const r of rows) {
                const p = r['filePath'];
                if (typeof p === 'string')
                    paths.add(p);
            }
            return [...paths];
        }
        finally {
            closeReadSnapshot(snapshot.db, this.onWarn);
        }
    }
}
//# sourceMappingURL=lance-store.js.map