import fs from 'fs/promises';
import { createReadStream, createWriteStream } from 'fs';
import { createInterface } from 'readline';
import { once } from 'events';
import { finished } from 'stream/promises';
import path from 'path';
import cgdb from '@ladybugdb/core';
import { createKnowledgeGraph } from '../graph/graph.js';
import { NODE_TABLES, REL_TABLE_NAME, SCHEMA_QUERIES, EMBEDDING_TABLE_NAME, STALE_HASH_SENTINEL, } from './schema.js';
import { streamAllCSVsToDisk } from './csv-generator.js';
/**
 * Split a relationship CSV into per-label-pair files on disk.
 *
 * Streams the CSV line-by-line, routing each relationship to a file named
 * `rel_{fromLabel}_{toLabel}.csv`. Handles backpressure correctly: only one
 * drain listener per stream at a time, and readline resumes only when ALL
 * backpressured streams have drained.
 *
 * @param csvPath       Path to the combined relationship CSV
 * @param csvDir        Directory to write per-pair CSV files
 * @param validTables   Set of valid node table names
 * @param getNodeLabel  Function to extract the label from a node ID
 * @param wsFactory     Optional WriteStream factory (defaults to fs.createWriteStream)
 */
export const splitRelCsvByLabelPair = async (csvPath, csvDir, validTables, getNodeLabel, wsFactory = (p) => createWriteStream(p, 'utf-8')) => {
    let relHeader = '';
    const relsByPairMeta = new Map();
    const pairWriteStreams = new Map();
    let skippedRels = 0;
    let totalValidRels = 0;
    const inputStream = createReadStream(csvPath, 'utf-8');
    const rl = createInterface({ input: inputStream, crlfDelay: Infinity });
    // If any pair WriteStream errors (disk full, EMFILE, etc.) or the input
    // stream fails, we need to abort the pending `once(ws, 'drain')` await.
    // An AbortController gives us one signal to cancel all pending waits
    // without a custom state machine.
    const abortOnError = new AbortController();
    let streamError = null;
    const markStreamError = (err) => {
        streamError ??= err;
        abortOnError.abort(err);
    };
    try {
        // `for await (const line of rl)` replaces the old manual
        // on('line')/pause()/resume()/waitingForDrain state machine: readline's
        // async iterator naturally serializes line delivery with our awaits, so
        // at most one ws can be in backpressure at a time and we just await its
        // 'drain' event.
        let isFirst = true;
        for await (const line of rl) {
            if (streamError)
                throw streamError;
            if (isFirst) {
                relHeader = line;
                isFirst = false;
                continue;
            }
            if (!line.trim())
                continue;
            const match = line.match(/"([^"]*)","([^"]*)"/);
            if (!match) {
                skippedRels++;
                continue;
            }
            const fromLabel = getNodeLabel(match[1]);
            const toLabel = getNodeLabel(match[2]);
            if (!validTables.has(fromLabel) || !validTables.has(toLabel)) {
                skippedRels++;
                continue;
            }
            const pairKey = `${fromLabel}|${toLabel}`;
            let ws = pairWriteStreams.get(pairKey);
            if (!ws) {
                const pairCsvPath = path.join(csvDir, `rel_${fromLabel}_${toLabel}.csv`);
                ws = wsFactory(pairCsvPath);
                ws.on('error', markStreamError);
                pairWriteStreams.set(pairKey, ws);
                relsByPairMeta.set(pairKey, { csvPath: pairCsvPath, rows: 0 });
                if (!ws.write(relHeader + '\n')) {
                    await once(ws, 'drain', { signal: abortOnError.signal });
                }
            }
            if (!ws.write(line + '\n')) {
                await once(ws, 'drain', { signal: abortOnError.signal });
            }
            relsByPairMeta.get(pairKey).rows++;
            totalValidRels++;
        }
        if (streamError)
            throw streamError;
    }
    catch (err) {
        // Tear down everything so no fd is left dangling. If the abort was caused
        // by a stream error, rethrow that error (more actionable than AbortError).
        for (const ws of pairWriteStreams.values())
            ws.destroy();
        inputStream.destroy();
        throw streamError ?? err;
    }
    finally {
        // Readline 'close' fires before the underlying fs.ReadStream releases its
        // fd — on Windows that race caused ENOTEMPTY on the parent dir.
        // stream/promises.finished is the stdlib "wait until this stream is fully
        // closed" primitive and handles both success and error paths.
        await finished(inputStream).catch(() => { });
    }
    return { relHeader, relsByPairMeta, pairWriteStreams, skippedRels, totalValidRels };
};
let db = null;
let conn = null;
let currentDbPath = null;
let ftsLoaded = false;
let vectorExtensionLoaded = false;
/**
 * In-process cache of FTS indexes that have been ensured against the current
 * connection. Prevents repeated `CALL CREATE_FTS_INDEX` round-trips inside a
 * single CLI/MCP session — the first call to `ensureFTSIndex` for a given
 * `(tableName, indexName)` pays the LadybugDB cost (~440 ms even when the
 * index already exists on disk), subsequent calls are a Set lookup. Cleared
 * by `closeCgdb` so a re-init starts fresh.
 *
 * Key format: `${tableName}:${indexName}`.
 */
const ensuredFTSIndexes = new Set();
/**
 * Check if an error indicates a missing column or table (schema-level problem)
 * rather than a transient/connection error. Used for legacy DB fallback logic.
 */
const isMissingColumnOrTableError = (msg) => msg.includes('does not exist') ||
    // Kuzu-specific: "(table|column|property) ... not found" — narrow enough to avoid
    // matching transient errors like "connection not found" or "key not found".
    /(table|column|property).*not found/i.test(msg);
/** Expose the current Database for pool adapter reuse in tests. */
export const getDatabase = () => db;
// Global session lock for operations that touch module-level cgdb globals.
// This guarantees no DB switch can happen while an operation is running.
let sessionLock = Promise.resolve();
/** Number of times to retry on a BUSY / lock-held error before giving up. */
const DB_LOCK_RETRY_ATTEMPTS = 3;
/** Base back-off in ms between BUSY retries (multiplied by attempt number). */
const DB_LOCK_RETRY_DELAY_MS = 500;
/**
 * Return true when the error message indicates that another process holds
 * an exclusive lock on the LadybugDB file (e.g. `codragraph analyze` or
 * `codragraph serve` running at the same time).
 */
export const isDbBusyError = (err) => {
    const msg = (err instanceof Error ? err.message : String(err)).toLowerCase();
    return (msg.includes('busy') ||
        msg.includes('lock') ||
        msg.includes('already in use') ||
        msg.includes('could not set lock'));
};
const runWithSessionLock = async (operation) => {
    const previous = sessionLock;
    let release = null;
    sessionLock = new Promise((resolve) => {
        release = resolve;
    });
    await previous;
    try {
        return await operation();
    }
    finally {
        release?.();
    }
};
const normalizeCopyPath = (filePath) => filePath.replace(/\\/g, '/');
export const initCgdb = async (dbPath) => {
    return runWithSessionLock(() => ensureCgdbInitialized(dbPath));
};
/**
 * Execute multiple queries against one repo DB atomically.
 * While the callback runs, no other request can switch the active DB.
 *
 * Automatically retries up to DB_LOCK_RETRY_ATTEMPTS times when the
 * database is busy (e.g. `codragraph analyze` holds the write lock).
 * Each retry waits DB_LOCK_RETRY_DELAY_MS * attempt milliseconds.
 */
export const withCgdbDb = async (dbPath, operation) => {
    let lastError;
    for (let attempt = 1; attempt <= DB_LOCK_RETRY_ATTEMPTS; attempt++) {
        try {
            return await runWithSessionLock(async () => {
                await ensureCgdbInitialized(dbPath);
                return operation();
            });
        }
        catch (err) {
            lastError = err;
            if (!isDbBusyError(err) || attempt === DB_LOCK_RETRY_ATTEMPTS) {
                throw err;
            }
            // Close stale connection inside the session lock to prevent race conditions
            // with concurrent operations that might acquire the lock between cleanup steps
            await runWithSessionLock(async () => {
                try {
                    if (conn)
                        await conn.close();
                }
                catch {
                    /* best-effort */
                }
                try {
                    if (db)
                        await db.close();
                }
                catch {
                    /* best-effort */
                }
                conn = null;
                db = null;
                currentDbPath = null;
                ftsLoaded = false;
                vectorExtensionLoaded = false;
            });
            // Sleep outside the lock — no need to block others while waiting
            await new Promise((resolve) => setTimeout(resolve, DB_LOCK_RETRY_DELAY_MS * attempt));
        }
    }
    // This line is unreachable — the loop either returns or throws inside,
    // but TypeScript needs an explicit throw to satisfy the return type.
    throw lastError;
};
const ensureCgdbInitialized = async (dbPath) => {
    if (conn && currentDbPath === dbPath) {
        return { db, conn };
    }
    await doInitCgdb(dbPath);
    return { db, conn };
};
const doInitCgdb = async (dbPath) => {
    // Different database requested — close the old one first
    if (conn || db) {
        try {
            if (conn)
                await conn.close();
        }
        catch { }
        try {
            if (db)
                await db.close();
        }
        catch { }
        conn = null;
        db = null;
        currentDbPath = null;
        ftsLoaded = false;
        vectorExtensionLoaded = false;
    }
    // LadybugDB stores the database as a single file (not a directory).
    // If the path already exists, it must be a valid LadybugDB database file.
    // Remove stale empty directories or files from older versions.
    try {
        const stat = await fs.lstat(dbPath);
        if (stat.isSymbolicLink()) {
            // Never follow symlinks — just remove the link itself
            await fs.unlink(dbPath);
        }
        else if (stat.isDirectory()) {
            // Verify path is within expected storage directory before deleting
            const realPath = await fs.realpath(dbPath);
            const parentDir = path.dirname(dbPath);
            const realParent = await fs.realpath(parentDir);
            if (!realPath.startsWith(realParent + path.sep) && realPath !== realParent) {
                throw new Error(`Refusing to delete ${dbPath}: resolved path ${realPath} is outside storage directory`);
            }
            // Old-style directory database or empty leftover - remove it
            await fs.rm(dbPath, { recursive: true, force: true });
        }
        // If it's a file, assume it's an existing LadybugDB database - LadybugDB will open it
    }
    catch {
        // Path doesn't exist, which is what LadybugDB wants for a new database
    }
    // Ensure parent directory exists
    const parentDir = path.dirname(dbPath);
    await fs.mkdir(parentDir, { recursive: true });
    db = new cgdb.Database(dbPath);
    conn = trackConnection(new cgdb.Connection(db));
    for (const schemaQuery of SCHEMA_QUERIES) {
        try {
            await conn.query(schemaQuery);
        }
        catch (err) {
            // Only ignore "already exists" errors - log everything else
            const msg = err instanceof Error ? err.message : String(err);
            if (!msg.includes('already exists')) {
                console.warn(`⚠️ Schema creation warning: ${msg.slice(0, 120)}`);
            }
        }
    }
    // Load VECTOR extension for semantic search support
    await loadVectorExtension();
    currentDbPath = dbPath;
    return { db, conn };
};
export const loadGraphToCgdb = async (graph, repoPath, storagePath, onProgress, options) => {
    if (!conn) {
        throw new Error('LadybugDB not initialized. Call initCgdb first.');
    }
    const log = onProgress || (() => { });
    const csvDir = path.join(storagePath, 'csv');
    log('Streaming CSVs to disk...');
    const csvResult = await streamAllCSVsToDisk(graph, repoPath, csvDir, options?.compress);
    const validTables = new Set(NODE_TABLES);
    const getNodeLabel = (nodeId) => {
        if (nodeId.startsWith('comm_'))
            return 'Community';
        if (nodeId.startsWith('proc_'))
            return 'Process';
        return nodeId.split(':')[0];
    };
    // Bulk COPY all node CSVs (sequential — LadybugDB allows only one write txn at a time)
    const nodeFiles = [...csvResult.nodeFiles.entries()];
    const totalSteps = nodeFiles.length + 1; // +1 for relationships
    let stepsDone = 0;
    for (const [table, { csvPath, rows }] of nodeFiles) {
        stepsDone++;
        log(`Loading nodes ${stepsDone}/${totalSteps}: ${table} (${rows.toLocaleString()} rows)`);
        const normalizedPath = normalizeCopyPath(csvPath);
        const copyQuery = getCopyQuery(table, normalizedPath);
        try {
            await conn.query(copyQuery);
        }
        catch (_err) {
            try {
                const retryQuery = copyQuery.replace('auto_detect=false)', 'auto_detect=false, IGNORE_ERRORS=true)');
                await conn.query(retryQuery);
            }
            catch (retryErr) {
                const retryMsg = retryErr instanceof Error ? retryErr.message : String(retryErr);
                throw new Error(`COPY failed for ${table}: ${retryMsg.slice(0, 200)}`);
            }
        }
    }
    // Bulk COPY relationships — split by FROM→TO label pair (LadybugDB requires it)
    const { relHeader, relsByPairMeta, pairWriteStreams, skippedRels, totalValidRels } = await splitRelCsvByLabelPair(csvResult.relCsvPath, csvDir, validTables, getNodeLabel);
    // Close all per-pair write streams before COPY. `stream/promises.finished`
    // resolves on the stream's 'finish' event and rejects on 'error' — replaces
    // a hand-rolled promisification with the stdlib primitive.
    await Promise.all(Array.from(pairWriteStreams.values()).map(async (ws) => {
        ws.end();
        await finished(ws);
    }));
    const insertedRels = totalValidRels;
    const warnings = [];
    if (insertedRels > 0) {
        log(`Loading edges: ${insertedRels.toLocaleString()} across ${relsByPairMeta.size} types`);
        let pairIdx = 0;
        let failedPairEdges = 0;
        const failedPairCsvPaths = new Set();
        for (const [pairKey, { csvPath: pairCsvPath, rows }] of relsByPairMeta) {
            pairIdx++;
            const [fromLabel, toLabel] = pairKey.split('|');
            const normalizedPath = normalizeCopyPath(pairCsvPath);
            const copyQuery = `COPY ${REL_TABLE_NAME} FROM "${normalizedPath}" (from="${fromLabel}", to="${toLabel}", HEADER=true, ESCAPE='"', DELIM=',', QUOTE='"', PARALLEL=false, auto_detect=false)`;
            if (pairIdx % 5 === 0 || rows > 1000) {
                log(`Loading edges: ${pairIdx}/${relsByPairMeta.size} types (${fromLabel} -> ${toLabel})`);
            }
            try {
                await conn.query(copyQuery);
            }
            catch (_err) {
                try {
                    const retryQuery = copyQuery.replace('auto_detect=false)', 'auto_detect=false, IGNORE_ERRORS=true)');
                    await conn.query(retryQuery);
                }
                catch (retryErr) {
                    const retryMsg = retryErr instanceof Error ? retryErr.message : String(retryErr);
                    warnings.push(`${fromLabel}->${toLabel} (${rows} edges): ${retryMsg.slice(0, 80)}`);
                    failedPairEdges += rows;
                    failedPairCsvPaths.add(pairCsvPath);
                }
            }
            // Only delete if not in failedPairCsvPaths (needed for fallback)
            if (!failedPairCsvPaths.has(pairCsvPath)) {
                try {
                    await fs.unlink(pairCsvPath);
                }
                catch { }
            }
        }
        if (failedPairCsvPaths.size > 0) {
            log(`Inserting ${failedPairEdges} edges individually (missing schema pairs)`);
            // Read failed pair files and merge for fallback inserts
            const allLines = [relHeader];
            for (const failedPath of failedPairCsvPaths) {
                try {
                    const content = await fs.readFile(failedPath, 'utf-8');
                    const lines = content.split('\n');
                    // Skip header line (first) and empty lines
                    for (let i = 1; i < lines.length; i++) {
                        if (lines[i].trim())
                            allLines.push(lines[i]);
                    }
                }
                catch { }
                try {
                    await fs.unlink(failedPath);
                }
                catch { }
            }
            if (allLines.length > 1) {
                await fallbackRelationshipInserts(allLines, validTables, getNodeLabel);
            }
        }
    }
    // Cleanup all CSVs
    try {
        await fs.unlink(csvResult.relCsvPath);
    }
    catch { }
    for (const [, { csvPath }] of csvResult.nodeFiles) {
        try {
            await fs.unlink(csvPath);
        }
        catch { }
    }
    try {
        const remaining = await fs.readdir(csvDir);
        for (const f of remaining) {
            try {
                await fs.unlink(path.join(csvDir, f));
            }
            catch { }
        }
    }
    catch { }
    try {
        await fs.rmdir(csvDir);
    }
    catch { }
    return { success: true, insertedRels, skippedRels, warnings };
};
const FILE_SCOPED_NODE_TABLES = NODE_TABLES.filter((table) => table !== 'Community' && table !== 'Process' && table !== 'FeatureCluster');
const GLOBAL_NODE_TABLES = new Set(['Community', 'Process', 'FeatureCluster']);
const PRESERVABLE_OUTGOING_REL_TYPES = new Set([
    'MEMBER_OF',
    'STEP_IN_PROCESS',
    'ENTRY_POINT_OF',
    'FEATURE_MEMBER_OF',
]);
const rowString = (row, named, positional) => String(row[named] ?? row[positional] ?? '');
const rowNumber = (row, named, positional, fallback) => {
    const value = Number(row[named] ?? row[positional] ?? fallback);
    return Number.isFinite(value) ? value : fallback;
};
const nodeLabelForId = (nodeId) => {
    if (nodeId.startsWith('comm_'))
        return 'Community';
    if (nodeId.startsWith('proc_'))
        return 'Process';
    return nodeId.split(':')[0] || 'CodeElement';
};
const fileScopedNodeSignature = (label, name, filePath) => {
    if (!name || !filePath)
        return null;
    return `${label}\0${name}\0${filePath}`;
};
const normalizePathAliases = (aliases) => {
    if (!aliases)
        return new Map();
    if (aliases instanceof Map) {
        return new Map([...aliases].map(([from, to]) => [from.replace(/\\/g, '/'), to.replace(/\\/g, '/')]));
    }
    return new Map(Object.entries(aliases).map(([from, to]) => [
        from.replace(/\\/g, '/'),
        String(to).replace(/\\/g, '/'),
    ]));
};
const remapFilePath = (filePath, aliases) => {
    const normalized = filePath.replace(/\\/g, '/');
    return aliases.get(normalized) ?? normalized;
};
const collectNodeIdsForFiles = async (filePaths) => {
    const nodeIds = new Set();
    if (filePaths.length === 0)
        return nodeIds;
    for (const table of FILE_SCOPED_NODE_TABLES) {
        for (const filePath of filePaths) {
            try {
                const rows = await executePrepared(`MATCH (n:${escapeTableName(table)}) WHERE n.filePath = $filePath RETURN n.id AS id`, { filePath });
                for (const row of rows) {
                    const id = rowString(row, 'id', 0);
                    if (id)
                        nodeIds.add(id);
                }
            }
            catch {
                /* table may be absent in old indexes */
            }
        }
    }
    return nodeIds;
};
const buildPatchNodeLookup = (graph, filePaths) => {
    const ids = new Set();
    const signatureBuckets = new Map();
    for (const node of graph.iterNodes()) {
        const filePath = node.properties?.filePath;
        if (typeof filePath === 'string' && filePaths.has(filePath)) {
            ids.add(node.id);
            const signature = fileScopedNodeSignature(node.label, typeof node.properties?.name === 'string' ? node.properties.name : undefined, filePath);
            if (signature) {
                let bucket = signatureBuckets.get(signature);
                if (bucket === undefined) {
                    bucket = new Set();
                    signatureBuckets.set(signature, bucket);
                }
                bucket.add(node.id);
            }
        }
    }
    const signatureToId = new Map();
    for (const [signature, nodeIds] of signatureBuckets) {
        if (nodeIds.size === 1) {
            signatureToId.set(signature, [...nodeIds][0]);
        }
    }
    return { ids, signatureToId };
};
const prunePatchGraphRelationships = (graph, patchNodeIds) => {
    for (const rel of [...graph.iterRelationships()]) {
        if (!patchNodeIds.has(rel.sourceId) && !patchNodeIds.has(rel.targetId)) {
            graph.removeRelationship(rel.id);
        }
    }
};
const collectPreservedRelationships = async (oldNodeIds) => {
    const preserved = [];
    for (const nodeId of oldNodeIds) {
        try {
            const incoming = await executePrepared(`MATCH (a)-[r:${REL_TABLE_NAME}]->(b) WHERE b.id = $nodeId RETURN a.id AS sourceId, b.id AS targetId, r.type AS type, r.confidence AS confidence, r.reason AS reason, r.step AS step, a.name AS sourceName, a.filePath AS sourceFilePath, b.name AS targetName, b.filePath AS targetFilePath`, { nodeId });
            for (const row of incoming) {
                const sourceId = rowString(row, 'sourceId', 0);
                const targetId = rowString(row, 'targetId', 1);
                if (!sourceId || !targetId || oldNodeIds.has(sourceId))
                    continue;
                preserved.push({
                    sourceId,
                    targetId,
                    type: rowString(row, 'type', 2),
                    confidence: rowNumber(row, 'confidence', 3, 1),
                    reason: rowString(row, 'reason', 4),
                    step: rowNumber(row, 'step', 5, 0),
                    sourceName: rowString(row, 'sourceName', 6),
                    sourceFilePath: rowString(row, 'sourceFilePath', 7),
                    targetName: rowString(row, 'targetName', 8),
                    targetFilePath: rowString(row, 'targetFilePath', 9),
                });
            }
        }
        catch {
            /* best-effort preservation */
        }
        try {
            const outgoing = await executePrepared(`MATCH (a)-[r:${REL_TABLE_NAME}]->(b) WHERE a.id = $nodeId RETURN a.id AS sourceId, b.id AS targetId, r.type AS type, r.confidence AS confidence, r.reason AS reason, r.step AS step, a.name AS sourceName, a.filePath AS sourceFilePath, b.name AS targetName, b.filePath AS targetFilePath`, { nodeId });
            for (const row of outgoing) {
                const sourceId = rowString(row, 'sourceId', 0);
                const targetId = rowString(row, 'targetId', 1);
                const type = rowString(row, 'type', 2);
                if (!sourceId ||
                    !targetId ||
                    oldNodeIds.has(targetId) ||
                    !PRESERVABLE_OUTGOING_REL_TYPES.has(type)) {
                    continue;
                }
                preserved.push({
                    sourceId,
                    targetId,
                    type,
                    confidence: rowNumber(row, 'confidence', 3, 1),
                    reason: rowString(row, 'reason', 4),
                    step: rowNumber(row, 'step', 5, 0),
                    sourceName: rowString(row, 'sourceName', 6),
                    sourceFilePath: rowString(row, 'sourceFilePath', 7),
                    targetName: rowString(row, 'targetName', 8),
                    targetFilePath: rowString(row, 'targetFilePath', 9),
                });
            }
        }
        catch {
            /* best-effort preservation */
        }
    }
    return preserved;
};
const deleteEmbeddingsForNodeIds = async (nodeIds) => {
    await executeWithReusedStatement(`MATCH (e:${EMBEDDING_TABLE_NAME} {nodeId: $nodeId}) DELETE e`, [...nodeIds].map((nodeId) => ({ nodeId })));
};
const deleteFileScopedNodes = async (filePaths) => {
    for (const table of FILE_SCOPED_NODE_TABLES) {
        for (const filePath of filePaths) {
            try {
                await executePrepared(`MATCH (n:${escapeTableName(table)}) WHERE n.filePath = $filePath DETACH DELETE n`, { filePath });
            }
            catch (err) {
                if (!isMissingColumnOrTableError(err instanceof Error ? err.message : String(err))) {
                    throw err;
                }
                /* table may be absent in old indexes */
            }
        }
    }
};
const restorePreservedRelationships = async (relationships, patchLookup, pathAliases) => {
    let restored = 0;
    const seen = new Set();
    for (const rel of relationships) {
        const sourceSignature = fileScopedNodeSignature(nodeLabelForId(rel.sourceId), rel.sourceName, remapFilePath(rel.sourceFilePath, pathAliases));
        const targetSignature = fileScopedNodeSignature(nodeLabelForId(rel.targetId), rel.targetName, remapFilePath(rel.targetFilePath, pathAliases));
        const resolvedSourceId = (patchLookup.ids.has(rel.sourceId) ? rel.sourceId : undefined) ??
            (sourceSignature ? patchLookup.signatureToId.get(sourceSignature) : undefined) ??
            rel.sourceId;
        const resolvedTargetId = (patchLookup.ids.has(rel.targetId) ? rel.targetId : undefined) ??
            (targetSignature ? patchLookup.signatureToId.get(targetSignature) : undefined) ??
            rel.targetId;
        const patchSideSurvives = patchLookup.ids.has(resolvedSourceId) || patchLookup.ids.has(resolvedTargetId);
        if (!patchSideSurvives)
            continue;
        const key = `${resolvedSourceId}\0${rel.type}\0${resolvedTargetId}\0${rel.step}`;
        if (seen.has(key))
            continue;
        seen.add(key);
        const sourceLabel = escapeTableName(nodeLabelForId(resolvedSourceId));
        const targetLabel = escapeTableName(nodeLabelForId(resolvedTargetId));
        try {
            const existingRows = await executePrepared(`MATCH (a:${sourceLabel})-[r:${REL_TABLE_NAME}]->(b:${targetLabel}) WHERE a.id = $sourceId AND b.id = $targetId AND r.type = $type AND r.step = $step RETURN count(r) AS cnt`, { ...rel, sourceId: resolvedSourceId, targetId: resolvedTargetId });
            const existing = rowNumber(existingRows?.[0] ?? {}, 'cnt', 0, 0);
            if (existing > 0)
                continue;
            await executePrepared(`MATCH (a:${sourceLabel} {id: $sourceId}), (b:${targetLabel} {id: $targetId}) CREATE (a)-[:${REL_TABLE_NAME} {type: $type, confidence: $confidence, reason: $reason, step: $step}]->(b)`, { ...rel, sourceId: resolvedSourceId, targetId: resolvedTargetId });
            restored++;
        }
        catch {
            /* source or target disappeared; skip */
        }
    }
    return restored;
};
export const applyFileGraphPatchToCgdb = async (graph, repoPath, storagePath, filePaths, onProgress, options) => {
    if (!conn) {
        throw new Error('LadybugDB not initialized. Call initCgdb first.');
    }
    const normalizedFiles = [...new Set(filePaths.map((p) => p.replace(/\\/g, '/')))];
    const pathAliases = normalizePathAliases(options?.pathAliases);
    const patchFileSet = new Set(normalizedFiles);
    const oldNodeIds = await collectNodeIdsForFiles(normalizedFiles);
    const preservedRels = await collectPreservedRelationships(oldNodeIds);
    await loadFTSExtension();
    await deleteEmbeddingsForNodeIds(oldNodeIds);
    await deleteFileScopedNodes(normalizedFiles);
    const patchLookup = buildPatchNodeLookup(graph, patchFileSet);
    prunePatchGraphRelationships(graph, patchLookup.ids);
    const loadResult = graph.nodeCount > 0
        ? await loadGraphToCgdb(graph, repoPath, storagePath, onProgress, options)
        : { insertedRels: 0 };
    const restoredRels = await restorePreservedRelationships(preservedRels, patchLookup, pathAliases);
    const prunedFolders = await pruneEmptyFoldersFromCgdb();
    return {
        replacedFiles: normalizedFiles.length,
        deletedNodeIds: oldNodeIds.size,
        insertedRels: loadResult.insertedRels,
        restoredRels,
        prunedFolders,
    };
};
const deleteEmbeddingsForAllNodes = async () => {
    try {
        await executeQuery(`MATCH (e:${EMBEDDING_TABLE_NAME}) DELETE e`);
    }
    catch {
        /* table may be absent in old indexes */
    }
};
const deleteAllFileScopedNodes = async () => {
    let deleted = 0;
    for (const table of FILE_SCOPED_NODE_TABLES) {
        try {
            const rows = await executeQuery(`MATCH (n:${escapeTableName(table)}) RETURN count(n) AS cnt`);
            deleted += Number(rows?.[0]?.cnt ?? rows?.[0]?.[0] ?? 0);
        }
        catch (err) {
            if (!isMissingColumnOrTableError(err instanceof Error ? err.message : String(err))) {
                throw err;
            }
            /* table may be absent in old indexes */
        }
        try {
            await executeQuery(`MATCH (n:${escapeTableName(table)}) DETACH DELETE n`);
        }
        catch (err) {
            if (!isMissingColumnOrTableError(err instanceof Error ? err.message : String(err))) {
                throw err;
            }
            /* table may be absent in old indexes */
        }
    }
    return deleted;
};
export const replaceFileScopedGraphInCgdb = async (graph, repoPath, storagePath, onProgress, options) => {
    if (!conn) {
        throw new Error('LadybugDB not initialized. Call initCgdb first.');
    }
    await loadFTSExtension();
    await deleteEmbeddingsForAllNodes();
    const deletedNodes = await deleteAllFileScopedNodes();
    const loadResult = graph.nodeCount > 0
        ? await loadGraphToCgdb(graph, repoPath, storagePath, onProgress, options)
        : { insertedRels: 0 };
    return { deletedNodes, insertedRels: loadResult.insertedRels };
};
export const replaceGlobalGraphLayersInCgdb = async (graph, repoPath, storagePath, onProgress, options) => {
    if (!conn) {
        throw new Error('LadybugDB not initialized. Call initCgdb first.');
    }
    await loadFTSExtension();
    let deletedGlobalNodes = 0;
    for (const table of GLOBAL_NODE_TABLES) {
        try {
            const rows = await executeQuery(`MATCH (n:${escapeTableName(table)}) RETURN count(n) AS cnt`);
            deletedGlobalNodes += Number(rows?.[0]?.cnt ?? rows?.[0]?.[0] ?? 0);
        }
        catch {
            /* table may be absent in old indexes */
        }
        try {
            await executeQuery(`MATCH (n:${escapeTableName(table)}) DETACH DELETE n`);
        }
        catch {
            /* table may be absent in old indexes */
        }
    }
    const loadResult = graph.nodeCount > 0
        ? await loadGraphToCgdb(graph, repoPath, storagePath, onProgress, options)
        : { insertedRels: 0 };
    return { deletedGlobalNodes, insertedRels: loadResult.insertedRels };
};
const unwrapNodeResult = (raw) => {
    if (!raw || typeof raw !== 'object')
        return null;
    const row = raw;
    const candidate = row.n ?? row[0];
    return candidate && typeof candidate === 'object' && !Array.isArray(candidate)
        ? candidate
        : null;
};
const nodePropertiesFromCgdbRow = (row) => {
    const properties = {
        name: String(row.name ?? ''),
        filePath: String(row.filePath ?? ''),
    };
    for (const [key, value] of Object.entries(row)) {
        if (key === 'id' || key === '_id' || key === '_label')
            continue;
        properties[key] = value;
    }
    return properties;
};
export const loadKnowledgeGraphFromCgdb = async (options = {}) => {
    if (!conn) {
        throw new Error('LadybugDB not initialized. Call initCgdb first.');
    }
    const includeGlobal = options.includeGlobal ?? false;
    const graph = createKnowledgeGraph();
    for (const table of NODE_TABLES) {
        if (!includeGlobal && GLOBAL_NODE_TABLES.has(table))
            continue;
        try {
            const rows = await executeQuery(`MATCH (n:${escapeTableName(table)}) RETURN n`);
            for (const raw of rows) {
                const node = unwrapNodeResult(raw);
                const id = node ? String(node.id ?? '') : '';
                if (!node || !id)
                    continue;
                graph.addNode({
                    id,
                    label: table,
                    properties: nodePropertiesFromCgdbRow(node),
                });
            }
        }
        catch {
            /* table may be absent in old indexes */
        }
    }
    try {
        const rows = await executeQuery(`MATCH (a)-[r:${REL_TABLE_NAME}]->(b) RETURN a.id AS sourceId, b.id AS targetId, r.type AS type, r.confidence AS confidence, r.reason AS reason, r.step AS step`);
        let relIndex = 0;
        for (const row of rows) {
            const sourceId = rowString(row, 'sourceId', 0);
            const targetId = rowString(row, 'targetId', 1);
            if (!sourceId || !targetId)
                continue;
            if (!includeGlobal) {
                const sourceLabel = nodeLabelForId(sourceId);
                const targetLabel = nodeLabelForId(targetId);
                if (GLOBAL_NODE_TABLES.has(sourceLabel) || GLOBAL_NODE_TABLES.has(targetLabel))
                    continue;
            }
            const type = rowString(row, 'type', 2);
            graph.addRelationship({
                id: `cgdb:${relIndex++}:${sourceId}->${type}->${targetId}`,
                sourceId,
                targetId,
                type: type,
                confidence: rowNumber(row, 'confidence', 3, 1),
                reason: rowString(row, 'reason', 4),
                step: rowNumber(row, 'step', 5, 0),
            });
        }
    }
    catch {
        /* relationships may be absent in old indexes */
    }
    return graph;
};
const pruneEmptyFoldersFromCgdb = async () => {
    let folderRows = [];
    let fileRows = [];
    try {
        folderRows = await executeQuery('MATCH (f:Folder) RETURN f.id AS id, f.filePath AS filePath');
        fileRows = await executeQuery('MATCH (f:File) RETURN f.filePath AS filePath');
    }
    catch {
        return 0;
    }
    const filePaths = fileRows.map((row) => rowString(row, 'filePath', 0)).filter(Boolean);
    let pruned = 0;
    for (const row of folderRows) {
        const id = rowString(row, 'id', 0);
        const folderPath = rowString(row, 'filePath', 1);
        if (!id || !folderPath)
            continue;
        const hasDescendantFile = filePaths.some((filePath) => filePath.startsWith(`${folderPath}/`));
        if (hasDescendantFile)
            continue;
        try {
            await executePrepared('MATCH (f:Folder {id: $id}) DETACH DELETE f', { id });
            pruned++;
        }
        catch {
            /* best-effort cleanup */
        }
    }
    return pruned;
};
// LadybugDB default ESCAPE is '\' (backslash), but our CSV uses RFC 4180 escaping ("" for literal quotes).
// Source code content is full of backslashes which confuse the auto-detection.
// We MUST explicitly set ESCAPE='"' to use RFC 4180 escaping, and disable auto_detect to prevent
// LadybugDB from overriding our settings based on sample rows.
const COPY_CSV_OPTS = `(HEADER=true, ESCAPE='"', DELIM=',', QUOTE='"', PARALLEL=false, auto_detect=false)`;
// Multi-language table names that were created with backticks in CODE_ELEMENT_BASE
// and must always be referenced with backticks in queries
const BACKTICK_TABLES = new Set([
    'Struct',
    'Enum',
    'Macro',
    'Typedef',
    'Union',
    'Namespace',
    'Trait',
    'Impl',
    'TypeAlias',
    'Const',
    'Static',
    'Property',
    'Record',
    'Delegate',
    'Annotation',
    'Constructor',
    'Template',
    'Module',
]);
const escapeTableName = (table) => {
    return BACKTICK_TABLES.has(table) ? `\`${table}\`` : table;
};
/** Fallback: insert relationships one-by-one if COPY fails */
const fallbackRelationshipInserts = async (validRelLines, validTables, getNodeLabel) => {
    if (!conn)
        return;
    const escapeLabel = (label) => {
        return BACKTICK_TABLES.has(label) ? `\`${label}\`` : label;
    };
    for (let i = 1; i < validRelLines.length; i++) {
        const line = validRelLines[i];
        try {
            const match = line.match(/"([^"]*)","([^"]*)","([^"]*)",([0-9.]+),"([^"]*)",([0-9-]+)/);
            if (!match)
                continue;
            const [, fromId, toId, relType, confidenceStr, reason, stepStr] = match;
            const fromLabel = getNodeLabel(fromId);
            const toLabel = getNodeLabel(toId);
            if (!validTables.has(fromLabel) || !validTables.has(toLabel))
                continue;
            const confidence = parseFloat(confidenceStr) || 1.0;
            const step = parseInt(stepStr) || 0;
            const esc = (s) => s.replace(/'/g, "''").replace(/\\/g, '\\\\').replace(/\n/g, '\\n').replace(/\r/g, '\\r');
            await conn.query(`
        MATCH (a:${escapeLabel(fromLabel)} {id: '${esc(fromId)}' }),
              (b:${escapeLabel(toLabel)} {id: '${esc(toId)}' })
        CREATE (a)-[:${REL_TABLE_NAME} {type: '${esc(relType)}', confidence: ${confidence}, reason: '${esc(reason)}', step: ${step}}]->(b)
      `);
        }
        catch {
            // skip
        }
    }
};
/** Tables with isExported column (TypeScript/JS-native types) */
const TABLES_WITH_EXPORTED = new Set([
    'Function',
    'Class',
    'Interface',
    'Method',
    'CodeElement',
]);
const getCopyQuery = (table, filePath) => {
    const t = escapeTableName(table);
    // RFC 0001 Phase 2: every content-bearing table also lists
    // `contentEncoding` immediately after `content` to match the schema +
    // CSV layout. Tables without a content column (Folder, Community,
    // Process, Route, Tool) are unchanged. FeatureCluster carries rich metadata
    // but still has no content/file snippet column.
    if (table === 'File') {
        return `COPY ${t}(id, name, filePath, content, contentEncoding) FROM "${filePath}" ${COPY_CSV_OPTS}`;
    }
    if (table === 'Folder') {
        return `COPY ${t}(id, name, filePath) FROM "${filePath}" ${COPY_CSV_OPTS}`;
    }
    if (table === 'Community') {
        return `COPY ${t}(id, label, heuristicLabel, keywords, description, enrichedBy, cohesion, symbolCount) FROM "${filePath}" ${COPY_CSV_OPTS}`;
    }
    if (table === 'Process') {
        return `COPY ${t}(id, label, heuristicLabel, processType, stepCount, communities, entryPointId, terminalId) FROM "${filePath}" ${COPY_CSV_OPTS}`;
    }
    if (table === 'FeatureCluster') {
        return `COPY ${t}(id, name, slug, featureKind, summary, description, repo, service, signals, memberCount, entryPointIds, routes, tools, testCoverageHints, lastIndexedCommit, confidence, source) FROM "${filePath}" ${COPY_CSV_OPTS}`;
    }
    if (table === 'Section') {
        return `COPY ${t}(id, name, filePath, startLine, endLine, level, content, contentEncoding, description) FROM "${filePath}" ${COPY_CSV_OPTS}`;
    }
    if (table === 'Route') {
        return `COPY ${t}(id, name, filePath, responseKeys, errorKeys, middleware) FROM "${filePath}" ${COPY_CSV_OPTS}`;
    }
    if (table === 'Tool') {
        return `COPY ${t}(id, name, filePath, description) FROM "${filePath}" ${COPY_CSV_OPTS}`;
    }
    if (table === 'Method') {
        return `COPY ${t}(id, name, filePath, startLine, endLine, isExported, content, contentEncoding, description, parameterCount, returnType) FROM "${filePath}" ${COPY_CSV_OPTS}`;
    }
    // TypeScript/JS code element tables have isExported; multi-language tables do not
    if (TABLES_WITH_EXPORTED.has(table)) {
        return `COPY ${t}(id, name, filePath, startLine, endLine, isExported, content, contentEncoding, description) FROM "${filePath}" ${COPY_CSV_OPTS}`;
    }
    // Multi-language tables (Struct, Impl, Trait, Macro, etc.)
    return `COPY ${t}(id, name, filePath, startLine, endLine, content, contentEncoding, description) FROM "${filePath}" ${COPY_CSV_OPTS}`;
};
/**
 * Insert a single node to LadybugDB
 * @param label - Node type (File, Function, Class, etc.)
 * @param properties - Node properties
 * @param dbPath - Path to LadybugDB database (optional if already initialized)
 */
export const insertNodeToCgdb = async (label, properties, dbPath) => {
    // Use provided dbPath or fall back to module-level db
    const targetDbPath = dbPath || (db ? undefined : null);
    if (!targetDbPath && !db) {
        throw new Error('LadybugDB not initialized. Provide dbPath or call initCgdb first.');
    }
    try {
        const escapeValue = (v) => {
            if (v === null || v === undefined)
                return 'NULL';
            if (typeof v === 'number')
                return String(v);
            if (Array.isArray(v)) {
                return `[${v.map((item) => `'${String(item).replace(/\\/g, '\\\\').replace(/'/g, "''")}'`).join(',')}]`;
            }
            // Escape backslashes first (for Windows paths), then single quotes
            return `'${String(v).replace(/\\/g, '\\\\').replace(/'/g, "''").replace(/\n/g, '\\n').replace(/\r/g, '\\r')}'`;
        };
        // Build INSERT query based on node type
        const t = escapeTableName(label);
        let query;
        if (label === 'File') {
            query = `CREATE (n:File {id: ${escapeValue(properties.id)}, name: ${escapeValue(properties.name)}, filePath: ${escapeValue(properties.filePath)}, content: ${escapeValue(properties.content || '')}})`;
        }
        else if (label === 'Folder') {
            query = `CREATE (n:Folder {id: ${escapeValue(properties.id)}, name: ${escapeValue(properties.name)}, filePath: ${escapeValue(properties.filePath)}})`;
        }
        else if (label === 'Section') {
            const descPart = properties.description
                ? `, description: ${escapeValue(properties.description)}`
                : '';
            query = `CREATE (n:Section {id: ${escapeValue(properties.id)}, name: ${escapeValue(properties.name)}, filePath: ${escapeValue(properties.filePath)}, startLine: ${properties.startLine || 0}, endLine: ${properties.endLine || 0}, level: ${properties.level || 1}, content: ${escapeValue(properties.content || '')}${descPart}})`;
        }
        else if (label === 'FeatureCluster') {
            query = `CREATE (n:FeatureCluster {id: ${escapeValue(properties.id)}, name: ${escapeValue(properties.name)}, slug: ${escapeValue(properties.slug)}, featureKind: ${escapeValue(properties.featureKind || 'feature')}, summary: ${escapeValue(properties.summary || '')}, description: ${escapeValue(properties.description || '')}, repo: ${escapeValue(properties.repo || '')}, service: ${escapeValue(properties.service || '')}, signals: ${escapeValue(properties.signals || [])}, memberCount: ${properties.memberCount || 0}, entryPointIds: ${escapeValue(properties.entryPointIds || [])}, routes: ${escapeValue(properties.routes || [])}, tools: ${escapeValue(properties.tools || [])}, testCoverageHints: ${escapeValue(properties.testCoverageHints || [])}, lastIndexedCommit: ${escapeValue(properties.lastIndexedCommit || '')}, confidence: ${properties.confidence || 0}, source: ${escapeValue(properties.source || 'heuristic')}})`;
        }
        else if (TABLES_WITH_EXPORTED.has(label)) {
            const descPart = properties.description
                ? `, description: ${escapeValue(properties.description)}`
                : '';
            query = `CREATE (n:${t} {id: ${escapeValue(properties.id)}, name: ${escapeValue(properties.name)}, filePath: ${escapeValue(properties.filePath)}, startLine: ${properties.startLine || 0}, endLine: ${properties.endLine || 0}, isExported: ${!!properties.isExported}, content: ${escapeValue(properties.content || '')}${descPart}})`;
        }
        else {
            // Multi-language tables (Struct, Impl, Trait, Macro, etc.) — no isExported
            const descPart = properties.description
                ? `, description: ${escapeValue(properties.description)}`
                : '';
            query = `CREATE (n:${t} {id: ${escapeValue(properties.id)}, name: ${escapeValue(properties.name)}, filePath: ${escapeValue(properties.filePath)}, startLine: ${properties.startLine || 0}, endLine: ${properties.endLine || 0}, content: ${escapeValue(properties.content || '')}${descPart}})`;
        }
        // Use per-query connection if dbPath provided (avoids lock conflicts)
        if (targetDbPath) {
            const tempDb = new cgdb.Database(targetDbPath);
            const tempConn = trackConnection(new cgdb.Connection(tempDb));
            try {
                const qr = await tempConn.query(query);
                await closeQueryResult(qr);
                return true;
            }
            finally {
                try {
                    await tempConn.close();
                }
                catch { }
                try {
                    await tempDb.close();
                }
                catch { }
            }
        }
        else if (conn) {
            // Use existing persistent connection (when called from analyze)
            await conn.query(query);
            return true;
        }
        return false;
    }
    catch (e) {
        // Node may already exist or other error
        console.error(`Failed to insert ${label} node:`, e.message);
        return false;
    }
};
/**
 * Batch insert multiple nodes to LadybugDB using a single connection
 * @param nodes - Array of {label, properties} to insert
 * @param dbPath - Path to LadybugDB database
 * @returns Object with success count and error count
 */
export const batchInsertNodesToCgdb = async (nodes, dbPath) => {
    if (nodes.length === 0)
        return { inserted: 0, failed: 0 };
    const escapeValue = (v) => {
        if (v === null || v === undefined)
            return 'NULL';
        if (typeof v === 'number')
            return String(v);
        if (Array.isArray(v)) {
            return `[${v.map((item) => `'${String(item).replace(/\\/g, '\\\\').replace(/'/g, "''")}'`).join(',')}]`;
        }
        // Escape backslashes first (for Windows paths), then single quotes, then newlines
        return `'${String(v).replace(/\\/g, '\\\\').replace(/'/g, "''").replace(/\n/g, '\\n').replace(/\r/g, '\\r')}'`;
    };
    // Open a single connection for all inserts
    const tempDb = new cgdb.Database(dbPath);
    const tempConn = trackConnection(new cgdb.Connection(tempDb));
    let inserted = 0;
    let failed = 0;
    try {
        for (const { label, properties } of nodes) {
            try {
                let query;
                // Use MERGE instead of CREATE for upsert behavior (handles duplicates gracefully)
                const t = escapeTableName(label);
                if (label === 'File') {
                    query = `MERGE (n:File {id: ${escapeValue(properties.id)}}) SET n.name = ${escapeValue(properties.name)}, n.filePath = ${escapeValue(properties.filePath)}, n.content = ${escapeValue(properties.content || '')}`;
                }
                else if (label === 'Folder') {
                    query = `MERGE (n:Folder {id: ${escapeValue(properties.id)}}) SET n.name = ${escapeValue(properties.name)}, n.filePath = ${escapeValue(properties.filePath)}`;
                }
                else if (label === 'Section') {
                    const descPart = properties.description
                        ? `, n.description = ${escapeValue(properties.description)}`
                        : '';
                    query = `MERGE (n:Section {id: ${escapeValue(properties.id)}}) SET n.name = ${escapeValue(properties.name)}, n.filePath = ${escapeValue(properties.filePath)}, n.startLine = ${properties.startLine || 0}, n.endLine = ${properties.endLine || 0}, n.level = ${properties.level || 1}, n.content = ${escapeValue(properties.content || '')}${descPart}`;
                }
                else if (label === 'FeatureCluster') {
                    query = `MERGE (n:FeatureCluster {id: ${escapeValue(properties.id)}}) SET n.name = ${escapeValue(properties.name)}, n.slug = ${escapeValue(properties.slug)}, n.featureKind = ${escapeValue(properties.featureKind || 'feature')}, n.summary = ${escapeValue(properties.summary || '')}, n.description = ${escapeValue(properties.description || '')}, n.repo = ${escapeValue(properties.repo || '')}, n.service = ${escapeValue(properties.service || '')}, n.signals = ${escapeValue(properties.signals || [])}, n.memberCount = ${properties.memberCount || 0}, n.entryPointIds = ${escapeValue(properties.entryPointIds || [])}, n.routes = ${escapeValue(properties.routes || [])}, n.tools = ${escapeValue(properties.tools || [])}, n.testCoverageHints = ${escapeValue(properties.testCoverageHints || [])}, n.lastIndexedCommit = ${escapeValue(properties.lastIndexedCommit || '')}, n.confidence = ${properties.confidence || 0}, n.source = ${escapeValue(properties.source || 'heuristic')}`;
                }
                else if (TABLES_WITH_EXPORTED.has(label)) {
                    const descPart = properties.description
                        ? `, n.description = ${escapeValue(properties.description)}`
                        : '';
                    query = `MERGE (n:${t} {id: ${escapeValue(properties.id)}}) SET n.name = ${escapeValue(properties.name)}, n.filePath = ${escapeValue(properties.filePath)}, n.startLine = ${properties.startLine || 0}, n.endLine = ${properties.endLine || 0}, n.isExported = ${!!properties.isExported}, n.content = ${escapeValue(properties.content || '')}${descPart}`;
                }
                else {
                    const descPart = properties.description
                        ? `, n.description = ${escapeValue(properties.description)}`
                        : '';
                    query = `MERGE (n:${t} {id: ${escapeValue(properties.id)}}) SET n.name = ${escapeValue(properties.name)}, n.filePath = ${escapeValue(properties.filePath)}, n.startLine = ${properties.startLine || 0}, n.endLine = ${properties.endLine || 0}, n.content = ${escapeValue(properties.content || '')}${descPart}`;
                }
                await tempConn.query(query);
                inserted++;
            }
            catch (_e) {
                // Don't console.error here - it corrupts MCP JSON-RPC on stderr
                failed++;
            }
        }
    }
    finally {
        try {
            await tempConn.close();
        }
        catch { }
        try {
            await tempDb.close();
        }
        catch { }
    }
    return { inserted, failed };
};
/**
 * Track every live QueryResult / PreparedStatement returned by the
 * connection, and force-close them in `closeCgdb()` BEFORE `conn.close()`
 * runs.
 *
 * Why this exists: per upstream Kuzu issue #5316 the close-order is
 *   QueryResult & PreparedStatement → Connection → Database
 * Leaking any of these to GC means V8 finalizes the native handles AFTER
 * `db.close()` has run, which corrupts memory and segfaults the process
 * at exit (Windows: 0xC0000005, Linux: SIGSEGV). Kuzu was archived
 * 2025-10-10 and LadybugDB inherits the same C++ core, so the
 * close-discipline is on us — neither upstream is going to ship a fix.
 *
 * Per-call-site close (the `try/finally { closeQueryResult }` blocks
 * below) handles the happy path. The tracking Set is a backstop for any
 * call site that forgets, AND for the fire-and-forget DDL / COPY /
 * extension-load queries that drop the QueryResult on the floor.
 */
const liveCgdbHandles = new Set();
const closeQueryResult = async (qr) => {
    if (!qr)
        return;
    const candidates = Array.isArray(qr) ? qr : [qr];
    for (const r of candidates) {
        liveCgdbHandles.delete(r);
        try {
            const close = r?.close;
            if (typeof close === 'function')
                await Promise.resolve(close.call(r));
        }
        catch {
            /* best-effort */
        }
    }
};
const closeStmt = async (stmt) => {
    if (!stmt)
        return;
    liveCgdbHandles.delete(stmt);
    try {
        const close = stmt?.close;
        if (typeof close === 'function')
            await Promise.resolve(close.call(stmt));
    }
    catch {
        /* best-effort */
    }
};
/**
 * Wrap `conn.query` and `conn.execute` to register every returned
 * QueryResult in the live-handle tracker. PreparedStatements from
 * `conn.prepare` are tracked the same way. Idempotent — safe to call
 * twice on the same Connection.
 */
const trackConnection = (rawConn) => {
    const c = rawConn;
    if (c.__cgdbTracked)
        return rawConn;
    c.__cgdbTracked = true;
    const wrap = (orig) => async function (...args) {
        const qr = await orig.apply(this, args);
        if (qr && typeof qr === 'object') {
            if (Array.isArray(qr))
                qr.forEach((r) => r && liveCgdbHandles.add(r));
            else
                liveCgdbHandles.add(qr);
        }
        return qr;
    };
    if (typeof c.query === 'function')
        c.query = wrap(c.query);
    if (typeof c.execute === 'function')
        c.execute = wrap(c.execute);
    if (typeof c.prepare === 'function')
        c.prepare = wrap(c.prepare);
    return rawConn;
};
export const executeQuery = async (cypher) => {
    if (!conn) {
        throw new Error('LadybugDB not initialized. Call initCgdb first.');
    }
    const queryResult = await conn.query(cypher);
    // LadybugDB uses getAll() instead of hasNext()/getNext()
    // Query returns QueryResult for single queries, QueryResult[] for multi-statement
    const result = Array.isArray(queryResult) ? queryResult[0] : queryResult;
    try {
        return await result.getAll();
    }
    finally {
        await closeQueryResult(queryResult);
    }
};
export const streamQuery = async (cypher, onRow) => {
    if (!conn) {
        throw new Error('LadybugDB not initialized. Call initCgdb first.');
    }
    const queryResult = await conn.query(cypher);
    const result = Array.isArray(queryResult) ? queryResult[0] : queryResult;
    let rowCount = 0;
    try {
        while (await result.hasNext()) {
            const row = await result.getNext();
            await onRow(row);
            rowCount++;
        }
        return rowCount;
    }
    finally {
        try {
            await result.close();
        }
        catch {
            // Best-effort cleanup only.
        }
    }
};
/**
 * Execute a single parameterized query (prepare/execute pattern).
 * Prevents Cypher injection by binding values as parameters.
 */
export const executePrepared = async (cypher, params) => {
    if (!conn) {
        throw new Error('LadybugDB not initialized. Call initCgdb first.');
    }
    const stmt = await conn.prepare(cypher);
    if (!stmt.isSuccess()) {
        const errMsg = await stmt.getErrorMessage();
        await closeStmt(stmt);
        throw new Error(`Prepare failed: ${errMsg}`);
    }
    const queryResult = await conn.execute(stmt, params);
    const result = Array.isArray(queryResult) ? queryResult[0] : queryResult;
    try {
        return await result.getAll();
    }
    finally {
        await closeQueryResult(queryResult);
        await closeStmt(stmt);
    }
};
export const executeWithReusedStatement = async (cypher, paramsList) => {
    if (!conn) {
        throw new Error('LadybugDB not initialized. Call initCgdb first.');
    }
    if (paramsList.length === 0)
        return;
    const SUB_BATCH_SIZE = 4;
    for (let i = 0; i < paramsList.length; i += SUB_BATCH_SIZE) {
        const subBatch = paramsList.slice(i, i + SUB_BATCH_SIZE);
        const stmt = await conn.prepare(cypher);
        if (!stmt.isSuccess()) {
            const errMsg = await stmt.getErrorMessage();
            await closeStmt(stmt);
            throw new Error(`Prepare failed: ${errMsg}`);
        }
        try {
            for (const params of subBatch) {
                // `conn.execute` returns a QueryResult — close it as soon as
                // we don't need its rows. Old code dropped the reference to GC,
                // which is what segfaulted the process at exit on Windows
                // (Kuzu issue #5316: QueryResult & PreparedStatement → Connection
                // → Database close-order is required for native-binding safety).
                const qr = await conn.execute(stmt, params);
                await closeQueryResult(qr);
            }
        }
        catch (e) {
            // Log the error and continue with next batch
            console.warn('Batch execution error:', e);
        }
        finally {
            await closeStmt(stmt);
        }
    }
};
export const getCgdbStats = async () => {
    if (!conn)
        return { nodes: 0, edges: 0 };
    let totalNodes = 0;
    for (const tableName of NODE_TABLES) {
        let queryResult;
        try {
            queryResult = await conn.query(`MATCH (n:${escapeTableName(tableName)}) RETURN count(n) AS cnt`);
            const nodeResult = Array.isArray(queryResult) ? queryResult[0] : queryResult;
            const nodeRows = await nodeResult.getAll();
            if (nodeRows.length > 0) {
                totalNodes += Number(nodeRows[0]?.cnt ?? nodeRows[0]?.[0] ?? 0);
            }
        }
        catch {
            // ignore
        }
        finally {
            await closeQueryResult(queryResult);
        }
    }
    let totalEdges = 0;
    let edgeQueryResult;
    try {
        edgeQueryResult = await conn.query(`MATCH ()-[r:${REL_TABLE_NAME}]->() RETURN count(r) AS cnt`);
        const edgeResult = Array.isArray(edgeQueryResult) ? edgeQueryResult[0] : edgeQueryResult;
        const edgeRows = await edgeResult.getAll();
        if (edgeRows.length > 0) {
            totalEdges = Number(edgeRows[0]?.cnt ?? edgeRows[0]?.[0] ?? 0);
        }
    }
    catch {
        // ignore
    }
    finally {
        await closeQueryResult(edgeQueryResult);
    }
    return { nodes: totalNodes, edges: totalEdges };
};
/**
 * Load cached embeddings from LadybugDB before a rebuild.
 * Returns all embedding vectors so they can be re-inserted after the graph is reloaded,
 * avoiding expensive re-embedding of unchanged nodes.
 *
 * Detects old schema (no chunkIndex column) and returns empty cache to trigger rebuild.
 */
export const loadCachedEmbeddings = async () => {
    if (!conn) {
        return { embeddingNodeIds: new Set(), embeddings: [] };
    }
    const embeddingNodeIds = new Set();
    const embeddings = [];
    try {
        // Schema migration detection: query with new columns to verify schema version.
        // Old schema only had (nodeId, embedding); new schema adds (id, chunkIndex, startLine, endLine, contentHash).
        // If the query fails (column missing), we return empty cache to force a full rebuild.
        try {
            const check = await conn.query(`MATCH (e:${EMBEDDING_TABLE_NAME}) RETURN e.nodeId AS nodeId, e.chunkIndex AS chunkIndex LIMIT 1`);
            const checkResult = Array.isArray(check) ? check[0] : check;
            await checkResult.getAll();
        }
        catch {
            return { embeddingNodeIds: new Set(), embeddings: [] };
        }
        // Try to read contentHash alongside chunk columns
        let rows;
        let hasContentHash = true;
        try {
            rows = await conn.query(`MATCH (e:${EMBEDDING_TABLE_NAME}) RETURN e.nodeId AS nodeId, e.chunkIndex AS chunkIndex, e.startLine AS startLine, e.endLine AS endLine, e.embedding AS embedding, e.contentHash AS contentHash`);
        }
        catch (err) {
            // Fallback for legacy DBs without contentHash column
            const msg = err?.message ?? '';
            if (isMissingColumnOrTableError(msg)) {
                hasContentHash = false;
                rows = await conn.query(`MATCH (e:${EMBEDDING_TABLE_NAME}) RETURN e.nodeId AS nodeId, e.chunkIndex AS chunkIndex, e.startLine AS startLine, e.endLine AS endLine, e.embedding AS embedding`);
            }
            else {
                throw err;
            }
        }
        const result = Array.isArray(rows) ? rows[0] : rows;
        for (const row of await result.getAll()) {
            const nodeId = String(row.nodeId ?? row[0] ?? '');
            if (!nodeId)
                continue;
            embeddingNodeIds.add(nodeId);
            const embedding = row.embedding ?? row[4];
            if (embedding) {
                embeddings.push({
                    nodeId,
                    chunkIndex: Number(row.chunkIndex ?? row[1] ?? 0),
                    startLine: Number(row.startLine ?? row[2] ?? 0),
                    endLine: Number(row.endLine ?? row[3] ?? 0),
                    embedding: Array.isArray(embedding)
                        ? embedding.map(Number)
                        : Array.from(embedding).map(Number),
                    contentHash: hasContentHash ? (row.contentHash ?? row[5] ?? undefined) : undefined,
                });
            }
        }
    }
    catch {
        /* embedding table may not exist */
    }
    return { embeddingNodeIds, embeddings };
};
/**
 * Fetch existing embedding hashes from CodeEmbedding table for incremental embedding.
 * Returns a Map<nodeId, contentHash> suitable for passing to `runEmbeddingPipeline`.
 * Handles legacy DBs without the `contentHash` column (all rows treated as stale with empty hash).
 * Returns undefined if the CodeEmbedding table does not exist.
 *
 * @param execQuery - Cypher query executor (typically pool-adapter's `executeQuery`)
 */
export const fetchExistingEmbeddingHashes = async (execQuery) => {
    try {
        const rows = await execQuery(`MATCH (e:${EMBEDDING_TABLE_NAME}) RETURN e.nodeId AS nodeId, e.chunkIndex AS chunkIndex, e.startLine AS startLine, e.endLine AS endLine, e.contentHash AS contentHash`);
        if (!rows || rows.length === 0)
            return undefined;
        const map = new Map();
        for (const r of rows) {
            const nodeId = r.nodeId ?? r[0];
            const chunkIndex = r.chunkIndex ?? r[1];
            const startLine = r.startLine ?? r[2];
            const endLine = r.endLine ?? r[3];
            const hash = r.contentHash ?? r[4] ?? STALE_HASH_SENTINEL;
            if (nodeId) {
                const hasChunkMetadata = chunkIndex !== undefined &&
                    chunkIndex !== null &&
                    startLine !== undefined &&
                    startLine !== null &&
                    endLine !== undefined &&
                    endLine !== null;
                // Empty/null contentHash or missing chunk metadata means legacy row — treat as stale.
                map.set(nodeId, hasChunkMetadata && hash ? hash : STALE_HASH_SENTINEL);
            }
        }
        return map;
    }
    catch (err) {
        const msg = err?.message ?? '';
        if (isMissingColumnOrTableError(msg)) {
            // Legacy rows missing chunk-aware columns — treat every row as stale.
            try {
                const rows = await execQuery(`MATCH (e:${EMBEDDING_TABLE_NAME}) RETURN e.nodeId AS nodeId`);
                if (!rows || rows.length === 0)
                    return undefined;
                const map = new Map();
                for (const r of rows) {
                    const nodeId = r.nodeId ?? r[0];
                    if (nodeId)
                        map.set(nodeId, STALE_HASH_SENTINEL);
                }
                console.log(`[embed] ${map.size} nodes in legacy DB (missing chunk-aware columns) — all treated as stale`);
                return map;
            }
            catch (fallbackErr) {
                const fallbackMsg = fallbackErr?.message ?? '';
                if (isMissingColumnOrTableError(fallbackMsg)) {
                    console.log(`[embed] CodeEmbedding table not yet present — full embedding run (${fallbackMsg})`);
                    return undefined;
                }
                throw fallbackErr;
            }
        }
        throw err;
    }
};
export const closeCgdb = async () => {
    // Drain every live QueryResult / PreparedStatement BEFORE closing the
    // Connection or the Database. Required by upstream Kuzu (#5316) — the
    // close-order is `QueryResult/PreparedStatement → Connection → Database`,
    // and any outstanding handle held when `db.close()` runs corrupts memory
    // and crashes the process at exit (Windows: 0xC0000005, POSIX: SIGSEGV).
    // Snapshot the Set so concurrent close() calls don't deadlock the iteration.
    const handles = Array.from(liveCgdbHandles);
    liveCgdbHandles.clear();
    for (const h of handles) {
        try {
            const close = h?.close;
            if (typeof close === 'function')
                await Promise.resolve(close.call(h));
        }
        catch {
            /* best-effort */
        }
    }
    if (conn) {
        try {
            await conn.close();
        }
        catch { }
        conn = null;
    }
    if (db) {
        try {
            await db.close();
        }
        catch { }
        db = null;
    }
    currentDbPath = null;
    ftsLoaded = false;
    vectorExtensionLoaded = false;
    ensuredFTSIndexes.clear();
};
export const isCgdbReady = () => conn !== null && db !== null;
/**
 * Delete all nodes (and their relationships) for a specific file from LadybugDB
 * @param filePath - The file path to delete nodes for
 * @param dbPath - Optional path to LadybugDB for per-query connection
 * @returns Object with counts of deleted nodes
 */
export const deleteNodesForFile = async (filePath, dbPath) => {
    const usePerQuery = !!dbPath;
    // Set up connection (either use existing or create per-query)
    let tempDb = null;
    let tempConn = null;
    let targetConn = conn;
    if (usePerQuery) {
        tempDb = new cgdb.Database(dbPath);
        tempConn = trackConnection(new cgdb.Connection(tempDb));
        targetConn = tempConn;
    }
    else if (!conn) {
        throw new Error('LadybugDB not initialized. Provide dbPath or call initCgdb first.');
    }
    try {
        let deletedNodes = 0;
        const escapedPath = filePath.replace(/'/g, "''");
        // Delete nodes from each table that has filePath
        // DETACH DELETE removes the node and all its relationships
        for (const tableName of NODE_TABLES) {
            // Skip tables that don't have filePath (Community, Process, FeatureCluster)
            if (tableName === 'Community' || tableName === 'Process' || tableName === 'FeatureCluster')
                continue;
            try {
                // First count how many we'll delete
                const tn = escapeTableName(tableName);
                const countResult = await targetConn.query(`MATCH (n:${tn}) WHERE n.filePath = '${escapedPath}' RETURN count(n) AS cnt`);
                const result = Array.isArray(countResult) ? countResult[0] : countResult;
                const rows = await result.getAll();
                const count = Number(rows[0]?.cnt ?? rows[0]?.[0] ?? 0);
                if (count > 0) {
                    // Delete nodes (and implicitly their relationships via DETACH)
                    await targetConn.query(`MATCH (n:${tn}) WHERE n.filePath = '${escapedPath}' DETACH DELETE n`);
                    deletedNodes += count;
                }
            }
            catch (_e) {
                // Some tables may not support this query, skip
            }
        }
        // Also delete any embeddings for nodes in this file
        try {
            await targetConn.query(`MATCH (e:${EMBEDDING_TABLE_NAME}) WHERE e.nodeId STARTS WITH '${escapedPath}' DELETE e`);
        }
        catch {
            // Embedding table may not exist or nodeId format may differ
        }
        return { deletedNodes };
    }
    finally {
        // Close per-query connection if used
        if (tempConn) {
            try {
                await tempConn.close();
            }
            catch { }
        }
        if (tempDb) {
            try {
                await tempDb.close();
            }
            catch { }
        }
    }
};
export const getEmbeddingTableName = () => EMBEDDING_TABLE_NAME;
// ============================================================================
// Full-Text Search (FTS) Functions
// ============================================================================
/**
 * Load the FTS extension (required before using FTS functions).
 * Safe to call multiple times — tracks loaded state via module-level ftsLoaded.
 */
export const loadFTSExtension = async () => {
    if (ftsLoaded)
        return;
    if (!conn) {
        throw new Error('LadybugDB not initialized. Call initCgdb first.');
    }
    try {
        // Try loading locally first (no network required)
        await conn.query('LOAD EXTENSION fts');
        ftsLoaded = true;
    }
    catch {
        // Fall back to install + load (requires network)
        try {
            await conn.query('INSTALL fts');
            await conn.query('LOAD EXTENSION fts');
            ftsLoaded = true;
        }
        catch (err) {
            const msg = err?.message || '';
            if (msg.includes('already loaded') ||
                msg.includes('already installed') ||
                msg.includes('already exists')) {
                ftsLoaded = true;
            }
            else {
                console.error('CodraGraph: FTS extension load failed:', msg);
            }
        }
    }
};
/**
 * Load the VECTOR extension (required before using QUERY_VECTOR_INDEX).
 * Safe to call multiple times -- tracks loaded state via module-level vectorExtensionLoaded.
 */
export const loadVectorExtension = async () => {
    if (vectorExtensionLoaded)
        return;
    if (!conn) {
        throw new Error('LadybugDB not initialized. Call initCgdb first.');
    }
    try {
        await conn.query('INSTALL VECTOR');
        await conn.query('LOAD EXTENSION VECTOR');
        vectorExtensionLoaded = true;
    }
    catch (err) {
        const msg = err?.message || '';
        if (msg.includes('already loaded') ||
            msg.includes('already installed') ||
            msg.includes('already exists')) {
            vectorExtensionLoaded = true;
        }
        else {
            console.error('CodraGraph: VECTOR extension load failed:', msg);
        }
    }
};
/**
 * Create a full-text search index on a table
 * @param tableName - The node table name (e.g., 'File', 'CodeSymbol')
 * @param indexName - Name for the FTS index
 * @param properties - List of properties to index (e.g., ['name', 'code'])
 * @param stemmer - Stemming algorithm (default: 'porter')
 */
export const createFTSIndex = async (tableName, indexName, properties, stemmer = 'porter') => {
    if (!conn) {
        throw new Error('LadybugDB not initialized. Call initCgdb first.');
    }
    await loadFTSExtension();
    const propList = properties.map((p) => `'${p}'`).join(', ');
    const query = `CALL CREATE_FTS_INDEX('${tableName}', '${indexName}', [${propList}], stemmer := '${stemmer}')`;
    try {
        await conn.query(query);
    }
    catch (e) {
        if (!e.message?.includes('already exists')) {
            throw e;
        }
    }
};
/**
 * Create an FTS index if needed, caching the fact in-process.
 *
 * Used by analyze to warm persisted keyword indexes while the DB is writable,
 * and by direct core search as a defensive fallback for older indexes.
 *
 * Safe to call repeatedly — the in-process Set guarantees only the first
 * call hits LadybugDB. `closeCgdb` clears the cache so re-init starts fresh.
 */
export const ensureFTSIndex = async (tableName, indexName, properties, stemmer = 'porter') => {
    const key = `${tableName}:${indexName}`;
    if (ensuredFTSIndexes.has(key))
        return;
    await createFTSIndex(tableName, indexName, properties, stemmer);
    ensuredFTSIndexes.add(key);
};
/**
 * Query a full-text search index
 * @param tableName - The node table name
 * @param indexName - FTS index name
 * @param query - Search query string
 * @param limit - Maximum results
 * @param conjunctive - If true, all terms must match (AND); if false, any term matches (OR)
 * @returns Array of { node properties, score }
 */
export const queryFTS = async (tableName, indexName, query, limit = 20, conjunctive = false) => {
    if (!conn) {
        throw new Error('LadybugDB not initialized. Call initCgdb first.');
    }
    // Escape backslashes and single quotes to prevent Cypher injection
    const escapedQuery = query.replace(/\\/g, '\\\\').replace(/'/g, "''");
    const cypher = `
    CALL QUERY_FTS_INDEX('${tableName}', '${indexName}', '${escapedQuery}', conjunctive := ${conjunctive})
    RETURN node, score
    ORDER BY score DESC
    LIMIT ${limit}
  `;
    let queryResult;
    try {
        queryResult = await conn.query(cypher);
        const result = Array.isArray(queryResult) ? queryResult[0] : queryResult;
        const rows = await result.getAll();
        return rows.map((row) => {
            const node = row.node || row[0] || {};
            const score = row.score ?? row[1] ?? 0;
            return {
                nodeId: node.nodeId || node.id || '',
                name: node.name || '',
                filePath: node.filePath || '',
                score: typeof score === 'number' ? score : parseFloat(score) || 0,
                ...node,
            };
        });
    }
    catch (e) {
        // Return empty if index doesn't exist yet
        if (e.message?.includes('does not exist')) {
            return [];
        }
        throw e;
    }
    finally {
        await closeQueryResult(queryResult);
    }
};
/**
 * Drop an FTS index
 */
export const dropFTSIndex = async (tableName, indexName) => {
    if (!conn) {
        throw new Error('LadybugDB not initialized. Call initCgdb first.');
    }
    await loadFTSExtension();
    try {
        await conn.query(`CALL DROP_FTS_INDEX('${tableName}', '${indexName}')`);
    }
    catch {
        // Index may not exist
    }
};
