import fs from 'fs/promises';
import { createReadStream, createWriteStream, constants as fsConstants } from 'fs';
import { createInterface } from 'readline';
import { once } from 'events';
import { finished } from 'stream/promises';
import path from 'path';
import os from 'os';
import crypto from 'crypto';
import lbug from '@ladybugdb/core';
import { NODE_TABLES, REL_TABLE_NAME, SCHEMA_QUERIES, EMBEDDING_TABLE_NAME, STALE_HASH_SENTINEL, } from './schema.js';
import { streamAllCSVsToDisk } from './csv-generator.js';
import { extensionManager } from './extension-loader.js';
import { closeLbugConnection, isDbBusyError, isOpenRetryExhausted, isWalCorruptionError, openLbugConnection, toNativeSafePath, WAL_RECOVERY_SUGGESTION, waitForWindowsHandleRelease, } from './lbug-config.js';
import { finalizeLbugSidecarsAfterClose, inspectLbugSidecars, isMissingShadowSidecarError, isReadOnlyShadowReplayError, preflightLbugSidecars, quarantineWalForMissingShadow, renameFailureMessage, shadowSidecarRecoveryMessage, } from './sidecar-recovery.js';
import { isVectorExtensionSupportedByPlatform } from '../platform/capabilities.js';
import { logger } from '../logger.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 currentDbReadOnly = false;
let ftsLoaded = false;
let vectorExtensionLoaded = false;
/**
 * In-process cache of FTS indexes observed against the current singleton
 * connection. Avoids repeated `CALL CREATE_FTS_INDEX` calls, which can trip
 * native duplicate-index/WAL edge cases. Cleared on re-init and close.
 *
 * Key format: `${tableName}:${indexName}`.
 */
const ensuredFTSIndexes = new Set();
const ftsIndexKey = (tableName, indexName) => `${tableName}:${indexName}`;
/**
 * 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 lbug 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 a write was attempted against
 * a read-only LadybugDB connection. The MCP query pool opens DBs read-only,
 * so any path that calls a `CREATE_*` procedure there will surface this
 * (e.g. defensive `ensureFTSIndex` calls). Owners of the writable analyze
 * path should ignore this error — index creation is owned by `gitnexus
 * analyze` and either already happened or will happen on the next run.
 */
export const isReadOnlyDbError = (err) => {
    const msg = err instanceof Error ? err.message : String(err);
    return /read-only database/i.test(msg);
};
const isMissingFileError = (err) => {
    const errno = err;
    return errno?.code === 'ENOENT';
};
const extractErrnoCode = (err) => {
    const errno = err;
    return errno?.code;
};
const MAX_LOGGED_ERROR_MESSAGE_LENGTH = 160;
const summarizeError = (err) => (err instanceof Error ? err.message : String(err)).slice(0, MAX_LOGGED_ERROR_MESSAGE_LENGTH);
// ---------------------------------------------------------------------------
// Cross-process init lock
//
// Prevents a TOCTOU race in orphan sidecar cleanup: between checking that
// the main DB file is missing and unlinking sidecars, another process could
// create a fresh DB. The lock file (`${dbPath}.init.lock`) is created with
// O_CREAT | O_EXCL (atomic create-or-fail) and contains the owning PID +
// timestamp so stale locks from crashed processes can be reclaimed.
// ---------------------------------------------------------------------------
/** Maximum age (ms) before an init lock is considered stale. */
const INIT_LOCK_STALE_MS = 30_000;
/** Maximum attempts to acquire the init lock before giving up. */
const INIT_LOCK_MAX_ATTEMPTS = 6;
/** Delay between lock-acquisition retries (ms). */
const INIT_LOCK_RETRY_DELAY_MS = 500;
const initLockPath = (dbPath) => `${dbPath}.init.lock`;
/**
 * Returns true when the process identified by `pid` is still running.
 * Uses `process.kill(pid, 0)` which sends signal 0 (a no-op probe) —
 * it throws ESRCH when the process does not exist.
 */
const isProcessAlive = (pid) => {
    try {
        process.kill(pid, 0);
        return true;
    }
    catch {
        return false;
    }
};
/**
 * Try to break a stale lock whose owning process has exited.
 * Returns `true` if the stale lock was removed (caller should retry acquire).
 * Returns `false` if the lock is still valid (another live process owns it).
 */
const tryBreakStaleLock = async (lockPath) => {
    try {
        const content = await fs.readFile(lockPath, 'utf-8');
        const parsed = JSON.parse(content);
        // If the owning process is still alive AND the lock is not stale, don't break.
        if (typeof parsed.pid === 'number' && isProcessAlive(parsed.pid)) {
            // Even a live process's lock can be stale if it's been held too long
            // (e.g. the process is hung). Check the timestamp.
            if (typeof parsed.ts === 'number' && Date.now() - parsed.ts < INIT_LOCK_STALE_MS) {
                return false;
            }
        }
        // PID is gone or lock exceeded INIT_LOCK_STALE_MS — reclaim it.
        await fs.unlink(lockPath);
        logger.warn(`GitNexus: removed stale init lock (pid=${parsed.pid ?? '?'}, age=${typeof parsed.ts === 'number' ? `${Date.now() - parsed.ts}ms` : '?'})`);
        return true;
    }
    catch (err) {
        // Lock file disappeared between our read and unlink, or is unreadable.
        // Either way, let the caller retry the acquire.
        if (isMissingFileError(err))
            return true;
        // Permission error or corrupt content — log and let caller retry.
        const code = extractErrnoCode(err);
        logger.warn(`GitNexus: unable to inspect init lock (${code ?? 'UNKNOWN'}): ${summarizeError(err)}`);
        return false;
    }
};
/**
 * Acquire a cross-process init lock for `dbPath`.
 * Uses `O_CREAT | O_EXCL` for atomic create-or-fail semantics.
 *
 * Returns a release function that removes the lock file. The release
 * function is idempotent and safe to call even if the lock was already
 * cleaned up externally.
 *
 * Throws if the lock cannot be acquired after `INIT_LOCK_MAX_ATTEMPTS`.
 */
export const acquireInitLock = async (dbPath) => {
    const lockPath = initLockPath(dbPath);
    const payload = JSON.stringify({ pid: process.pid, ts: Date.now() });
    // Ensure the parent directory exists before creating the lock file.
    // On a fresh repo the `.gitnexus/` directory may not exist yet, and
    // fs.open with O_CREAT | O_EXCL would fail with ENOENT.
    await fs.mkdir(path.dirname(lockPath), { recursive: true });
    for (let attempt = 1; attempt <= INIT_LOCK_MAX_ATTEMPTS; attempt++) {
        try {
            const handle = await fs.open(lockPath, fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY);
            await handle.writeFile(payload);
            await handle.close();
            // Return the idempotent release function
            return async () => {
                try {
                    await fs.unlink(lockPath);
                }
                catch (err) {
                    if (!isMissingFileError(err)) {
                        const code = extractErrnoCode(err);
                        logger.warn(`GitNexus: failed to release init lock (${code ?? 'UNKNOWN'}): ${summarizeError(err)}`);
                    }
                }
            };
        }
        catch (err) {
            if (err?.code !== 'EEXIST') {
                throw err; // Unexpected error — propagate immediately
            }
            // Lock file exists — check if it's stale
            const broken = await tryBreakStaleLock(lockPath);
            if (broken && attempt < INIT_LOCK_MAX_ATTEMPTS) {
                continue; // Stale lock removed — retry immediately
            }
            if (attempt === INIT_LOCK_MAX_ATTEMPTS) {
                throw new Error(`GitNexus: unable to acquire init lock after ${INIT_LOCK_MAX_ATTEMPTS} attempts — ` +
                    `another gitnexus process may be initializing the same database (${lockPath})`);
            }
            // Live process holds the lock — wait and retry
            await new Promise((resolve) => setTimeout(resolve, INIT_LOCK_RETRY_DELAY_MS));
        }
    }
    // Unreachable — loop always throws or returns
    throw new Error('GitNexus: init lock acquisition failed unexpectedly');
};
/** Exported for testing — returns the lock file path for a given dbPath. */
export const _initLockPathForTest = initLockPath;
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) => toNativeSafePath(filePath).replace(/\\/g, '/');
const closeQueryResult = async (result) => {
    try {
        await result.close();
    }
    catch {
        // Best-effort cleanup only.
    }
};
const drainQueryResult = async (queryResult) => {
    const results = Array.isArray(queryResult) ? queryResult : [queryResult];
    let firstError;
    let hasError = false;
    for (const result of results) {
        try {
            await result.getAll();
        }
        catch (err) {
            if (!hasError) {
                firstError = err;
                hasError = true;
            }
        }
        finally {
            await closeQueryResult(result);
        }
    }
    if (hasError)
        throw firstError;
};
const readQueryRows = async (queryResult) => {
    const results = Array.isArray(queryResult) ? queryResult : [queryResult];
    let rows = [];
    let firstError;
    let hasError = false;
    for (let i = 0; i < results.length; i++) {
        const result = results[i];
        try {
            const resultRows = await result.getAll();
            if (i === 0)
                rows = resultRows;
        }
        catch (err) {
            if (!hasError) {
                firstError = err;
                hasError = true;
            }
        }
        finally {
            await closeQueryResult(result);
        }
    }
    if (hasError)
        throw firstError;
    return rows;
};
const queryAndDrain = async (targetConn, cypher) => {
    const queryResult = await targetConn.query(cypher);
    await drainQueryResult(queryResult);
};
const READ_ONLY_SHADOW_REPLAY_PROBE = 'MATCH (n) RETURN n LIMIT 1';
/**
 * Reject the quarantine path when the orphan WAL is too large to safely
 * discard (>TINY_ORPHAN_WAL_BYTES). Mirrors the preflight policy at
 * sidecar-recovery.ts:153-160 ("warn, do not quarantine"). Symmetric across
 * read-only and writable recovery paths (PR #1747 review D2).
 *
 * Throws shadowSidecarRecoveryMessage immediately when the WAL is large,
 * preserving the uncheckpointed pages for explicit operator recovery.
 * Returns silently when the WAL is absent, tiny, or in any other state
 * where the existing recovery path is safe to proceed.
 */
const refuseLargeWalQuarantine = async (dbPath, mode, triggeringErr) => {
    const state = await inspectLbugSidecars(dbPath);
    if (state.kind === 'orphan-wal') {
        logger.warn(`GitNexus: refusing to quarantine large WAL (${state.walBytes} bytes) at ${dbPath}.wal during ${mode} recovery; ` +
            'manual recovery required — run `gitnexus analyze --force <repo-path> --index-only`.');
        throw new Error(shadowSidecarRecoveryMessage(dbPath, triggeringErr));
    }
};
const reopenReadOnlyAfterMissingShadow = async (dbPath, err) => {
    await refuseLargeWalQuarantine(dbPath, 'read-only', err);
    try {
        await quarantineWalForMissingShadow(dbPath, {
            logger,
            level: 'warn',
            reason: 'read-only recovery',
        });
    }
    catch (renameErr) {
        throw new Error(renameFailureMessage(dbPath, renameErr));
    }
    const reopened = await openLbugConnection(lbug, dbPath, { readOnly: true });
    try {
        await queryAndDrain(reopened.conn, READ_ONLY_SHADOW_REPLAY_PROBE);
        return reopened;
    }
    catch (retryErr) {
        await closeLbugConnection(reopened);
        if (isMissingShadowSidecarError(retryErr) || isReadOnlyShadowReplayError(retryErr)) {
            throw new Error(shadowSidecarRecoveryMessage(dbPath, retryErr));
        }
        throw retryErr;
    }
};
const reopenWritableAfterMissingShadow = async (dbPath, err) => {
    await refuseLargeWalQuarantine(dbPath, 'writable', err);
    try {
        await quarantineWalForMissingShadow(dbPath, {
            logger,
            level: 'warn',
            reason: 'writable recovery',
        });
    }
    catch (renameErr) {
        throw new Error(renameFailureMessage(dbPath, renameErr));
    }
    return await openLbugConnection(lbug, dbPath);
};
const ensureReadOnlyConnectionUsable = async (dbPath, handle) => {
    let shadowReplayErr;
    try {
        await queryAndDrain(handle.conn, READ_ONLY_SHADOW_REPLAY_PROBE);
        return handle;
    }
    catch (err) {
        if (isMissingShadowSidecarError(err)) {
            await closeLbugConnection(handle);
            return await reopenReadOnlyAfterMissingShadow(dbPath, err);
        }
        if (!isReadOnlyShadowReplayError(err)) {
            await closeLbugConnection(handle);
            throw err;
        }
        shadowReplayErr = err;
    }
    await closeLbugConnection(handle);
    let writable;
    try {
        writable = await openLbugConnection(lbug, dbPath);
    }
    catch (openErr) {
        const code = extractErrnoCode(openErr);
        if (code === 'EROFS' || code === 'EACCES' || code === 'EPERM') {
            throw new Error(shadowSidecarRecoveryMessage(dbPath, shadowReplayErr) +
                '\n  The workspace appears to be read-only — mount it read-write to perform shadow replay recovery,' +
                ' or re-run `gitnexus analyze` on a writable filesystem to rebuild the index.');
        }
        throw openErr;
    }
    let missingShadowError;
    try {
        await queryAndDrain(writable.conn, READ_ONLY_SHADOW_REPLAY_PROBE);
    }
    catch (err) {
        if (isMissingShadowSidecarError(err)) {
            missingShadowError = err;
        }
        else {
            throw err;
        }
    }
    finally {
        await closeLbugConnection(writable);
    }
    if (missingShadowError) {
        return await reopenReadOnlyAfterMissingShadow(dbPath, missingShadowError);
    }
    const reopened = await openLbugConnection(lbug, dbPath, { readOnly: true });
    try {
        await queryAndDrain(reopened.conn, READ_ONLY_SHADOW_REPLAY_PROBE);
        return reopened;
    }
    catch (err) {
        await closeLbugConnection(reopened);
        if (isMissingShadowSidecarError(err)) {
            throw new Error(shadowSidecarRecoveryMessage(dbPath, err));
        }
        throw err;
    }
};
const resetOpenConnectionState = () => {
    currentDbPath = null;
    ftsLoaded = false;
    vectorExtensionLoaded = false;
    ensuredFTSIndexes.clear();
};
const runSchemaCreationQueries = async (dbPath) => {
    for (const schemaQuery of SCHEMA_QUERIES) {
        try {
            await queryAndDrain(conn, schemaQuery);
        }
        catch (err) {
            if (isMissingShadowSidecarError(err)) {
                return err;
            }
            const msg = err instanceof Error ? err.message : String(err);
            // Suppression list:
            //   - "already exists": expected idempotent re-create on existing DBs
            //   - "could not set lock on file": LadybugDB v0.16.1 emits this on
            //     Windows when CREATE NODE TABLE runs against a path that was
            //     just opened (the WAL handle from a fresh Database briefly
            //     contests the table's first-write lock). The table is created
            //     anyway and any genuine cross-process lock contention surfaces
            //     on the next operation via withLbugDb's retry. Logging it here
            //     would just be noise in CI.
            //
            // WAL corruption: the first DDL write after DB open triggers WAL
            // replay — if the WAL file was left in a corrupt state by an
            // interrupted previous run, the native engine throws here. Rather
            // than logging a WARN and continuing in a broken state, close the
            // DB cleanly and surface an actionable error so the caller (serve,
            // MCP, analyze) can exit with a clear recovery message.
            if (isWalCorruptionError(err)) {
                await safeClose();
                resetOpenConnectionState();
                throw new Error(`LadybugDB WAL corruption detected at ${dbPath}. ${WAL_RECOVERY_SUGGESTION}\n` +
                    `  Original error: ${msg.slice(0, 200)}`);
            }
            if (!msg.includes('already exists') && !isDbBusyError(err) && !isReadOnlyDbError(err)) {
                logger.warn(`⚠️ Schema creation warning: ${msg.slice(0, 120)}`);
            }
        }
    }
    return null;
};
export const initLbug = async (dbPath) => {
    return runWithSessionLock(() => ensureLbugInitialized(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. `gitnexus analyze` holds the write lock).
 * Each retry waits DB_LOCK_RETRY_DELAY_MS * attempt milliseconds.
 */
export const withLbugDb = async (dbPath, operation, options = {}) => {
    let lastError;
    const readOnly = options.readOnly === true;
    for (let attempt = 1; attempt <= DB_LOCK_RETRY_ATTEMPTS; attempt++) {
        try {
            return await runWithSessionLock(async () => {
                await ensureLbugInitialized(dbPath, readOnly);
                return operation();
            });
        }
        catch (err) {
            lastError = err;
            // Skip outer retry when the inner open-retry already exhausted: the
            // ~1.5s open-time budget was just spent, repeating the full reset+
            // reopen cycle would only add 4-5s of tail latency without changing
            // the outcome (both layers consult the same isDbBusyError matcher).
            if (!isDbBusyError(err) || isOpenRetryExhausted(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 () => {
                await safeClose();
                currentDbPath = null;
                ftsLoaded = false;
                vectorExtensionLoaded = false;
                ensuredFTSIndexes.clear();
            });
            // 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 ensureLbugInitialized = async (dbPath, readOnly = false) => {
    if (conn && currentDbPath === dbPath && currentDbReadOnly === readOnly) {
        return { db, conn };
    }
    await doInitLbug(dbPath, readOnly);
    return { db, conn };
};
const doInitLbug = async (dbPath, readOnly = false) => {
    // Different database requested — close the old one first
    if (conn || db) {
        await safeClose();
        currentDbPath = null;
        ftsLoaded = false;
        vectorExtensionLoaded = false;
        ensuredFTSIndexes.clear();
    }
    // ---------------------------------------------------------------------------
    // Read-only fast path: skip all filesystem mutations (path cleanup, init
    // lock, orphan sidecar removal, mkdir) so the open succeeds on read-only
    // filesystems such as Docker `:ro` bind mounts. The init lock exists to
    // prevent a TOCTOU race during DB *creation* — read-only opens never
    // create databases and don't need the lock.
    // ---------------------------------------------------------------------------
    if (readOnly) {
        await preflightLbugSidecars(dbPath, {
            mode: 'read-only',
            logger,
            allowQuarantine: false,
        });
        const opened = await openLbugConnection(lbug, dbPath, { readOnly: true });
        const usable = await ensureReadOnlyConnectionUsable(dbPath, opened);
        db = usable.db;
        conn = usable.conn;
        currentDbReadOnly = true;
    }
    else {
        // 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 (err) {
            if (!isMissingFileError(err)) {
                throw err;
            }
            // Path doesn't exist, which is what LadybugDB wants for a new database
        }
        // -------------------------------------------------------------------------
        // Cross-process critical section: acquire init lock, clean orphan sidecars,
        // and open the database. The lock prevents a TOCTOU race where another
        // process could create a fresh DB between our access() check and the
        // unlink() of stale sidecars.
        // -------------------------------------------------------------------------
        const releaseInitLock = await acquireInitLock(dbPath);
        try {
            // Crash-recovery cleanup: if the main DB file is missing, stale sidecars
            // from an interrupted run can block fresh opens indefinitely.
            try {
                await fs.access(dbPath);
            }
            catch (err) {
                if (isMissingFileError(err)) {
                    // `.shadow` is documented by LadybugDB checkpointing and `.wal.checkpoint`
                    // was observed in the #1618 crash loop that motivated this recovery path.
                    const orphanSidecars = [`${dbPath}.shadow`, `${dbPath}.wal.checkpoint`];
                    for (const sidecar of orphanSidecars) {
                        try {
                            await fs.unlink(sidecar);
                            logger.warn(`GitNexus: removed orphan sidecar ${path.basename(sidecar)} (no main DB file present)`);
                        }
                        catch (err) {
                            if (isMissingFileError(err)) {
                                continue;
                            }
                            const code = extractErrnoCode(err);
                            logger.warn(`GitNexus: failed to remove orphan sidecar ${path.basename(sidecar)} (${code ?? 'UNKNOWN'}) while main DB file is missing; LadybugDB open may still fail: ${summarizeError(err)}`);
                        }
                    }
                }
                else {
                    const code = extractErrnoCode(err);
                    logger.warn(`GitNexus: unable to verify main DB file before orphan sidecar cleanup (${code ?? 'UNKNOWN'}); skipping cleanup: ${summarizeError(err)}`);
                }
            }
            // Ensure parent directory exists
            const parentDir = path.dirname(dbPath);
            await fs.mkdir(parentDir, { recursive: true });
            await preflightLbugSidecars(dbPath, {
                mode: 'write',
                logger,
                allowQuarantine: true,
            });
            const opened = await openLbugConnection(lbug, dbPath);
            db = opened.db;
            conn = opened.conn;
            currentDbReadOnly = false;
        }
        finally {
            await releaseInitLock();
        }
    }
    if (!readOnly) {
        const missingShadowError = await runSchemaCreationQueries(dbPath);
        if (missingShadowError) {
            await safeClose();
            resetOpenConnectionState();
            const reopened = await reopenWritableAfterMissingShadow(dbPath, missingShadowError);
            db = reopened.db;
            conn = reopened.conn;
            currentDbReadOnly = false;
            const retryMissingShadowError = await runSchemaCreationQueries(dbPath);
            if (retryMissingShadowError) {
                await safeClose();
                resetOpenConnectionState();
                throw new Error(shadowSidecarRecoveryMessage(dbPath, retryMissingShadowError));
            }
        }
    }
    // FTS powers baseline search, so initialize it with the core DB. Read-only
    // serve/MCP paths must never run DDL or trigger network INSTALL; analyze owns
    // schema/index creation and extension installation.
    await loadFTSExtension(undefined, readOnly ? { policy: 'load-only' } : {});
    currentDbPath = dbPath;
    return { db, conn };
};
export const loadGraphToLbug = async (graph, repoPath, storagePath, onProgress) => {
    if (!conn) {
        throw new Error('LadybugDB not initialized. Call initLbug first.');
    }
    const log = onProgress || (() => { });
    let csvDir;
    if (process.platform === 'win32' && /[^\x00-\x7F]/.test(storagePath)) {
        const hash = crypto.createHash('sha256').update(storagePath).digest('hex').slice(0, 16);
        csvDir = toNativeSafePath(path.join(os.tmpdir(), `gitnexus-csv-${hash}`));
    }
    else {
        csvDir = path.join(storagePath, 'csv');
    }
    log('Streaming CSVs to disk...');
    const csvResult = await streamAllCSVsToDisk(graph, repoPath, csvDir);
    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 queryAndDrain(conn, copyQuery);
        }
        catch (err) {
            try {
                const retryQuery = copyQuery.replace('auto_detect=false)', 'auto_detect=false, IGNORE_ERRORS=true)');
                await queryAndDrain(conn, 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 queryAndDrain(conn, copyQuery);
            }
            catch (err) {
                try {
                    const retryQuery = copyQuery.replace('auto_detect=false)', 'auto_detect=false, IGNORE_ERRORS=true)');
                    await queryAndDrain(conn, 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 };
};
// 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 queryAndDrain(conn, `
        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);
    if (table === 'File') {
        return `COPY ${t}(id, name, filePath, content) 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 === 'Section') {
        return `COPY ${t}(id, name, filePath, startLine, endLine, level, content, 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, description, parameterCount, returnType) FROM "${filePath}" ${COPY_CSV_OPTS}`;
    }
    if (table === 'Property') {
        return `COPY ${t}(id, name, filePath, startLine, endLine, content, description, declaredType) 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, description) FROM "${filePath}" ${COPY_CSV_OPTS}`;
    }
    // Multi-language tables (Struct, Impl, Trait, Macro, etc.)
    return `COPY ${t}(id, name, filePath, startLine, endLine, content, 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 insertNodeToLbug = 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 initLbug first.');
    }
    try {
        const escapeValue = (v) => {
            if (v === null || v === undefined)
                return 'NULL';
            if (typeof v === 'number')
                return String(v);
            // 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 (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 if (label === 'Property') {
            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}, declaredType: ${escapeValue(properties.declaredType || '')}})`;
        }
        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 tempHandle = await openLbugConnection(lbug, targetDbPath);
            try {
                await queryAndDrain(tempHandle.conn, query);
                return true;
            }
            finally {
                await closeLbugConnection(tempHandle);
            }
        }
        else if (conn) {
            // Use existing persistent connection (when called from analyze)
            await queryAndDrain(conn, query);
            return true;
        }
        return false;
    }
    catch (e) {
        // Node may already exist or other error
        logger.error({ err: e.message }, `Failed to insert ${label} node:`);
        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 batchInsertNodesToLbug = 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);
        // 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 tempHandle = await openLbugConnection(lbug, dbPath);
    const tempConn = tempHandle.conn;
    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 (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 if (label === 'Property') {
                    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}, n.declaredType = ${escapeValue(properties.declaredType || '')}`;
                }
                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 queryAndDrain(tempConn, query);
                inserted++;
            }
            catch (e) {
                // Don't console.error here - it corrupts MCP JSON-RPC on stderr
                failed++;
            }
        }
    }
    finally {
        await closeLbugConnection(tempHandle);
    }
    return { inserted, failed };
};
export const executeQuery = async (cypher) => {
    return await executePrepared(cypher, {});
};
export const streamQuery = async (cypher, onRow) => {
    if (!conn) {
        throw new Error('LadybugDB not initialized. Call initLbug first.');
    }
    const queryResult = await conn.query(cypher);
    const results = Array.isArray(queryResult) ? queryResult : [queryResult];
    const result = results[0];
    let rowCount = 0;
    let streamError;
    try {
        while (await result.hasNext()) {
            const row = await result.getNext();
            await onRow(row);
            rowCount++;
        }
        return rowCount;
    }
    catch (err) {
        streamError = err;
        throw err;
    }
    finally {
        try {
            await drainQueryResult(results);
        }
        catch (err) {
            if (streamError === undefined)
                throw err;
        }
    }
};
/**
 * 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 initLbug first.');
    }
    const stmt = await conn.prepare(cypher);
    if (!stmt.isSuccess()) {
        const errMsg = await stmt.getErrorMessage();
        throw new Error(`Prepare failed: ${errMsg}`);
    }
    const queryResult = await conn.execute(stmt, params);
    return await readQueryRows(queryResult);
};
export const executeWithReusedStatement = async (cypher, paramsList) => {
    if (!conn) {
        throw new Error('LadybugDB not initialized. Call initLbug 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();
            throw new Error(`Prepare failed: ${errMsg}`);
        }
        try {
            for (const params of subBatch) {
                await drainQueryResult(await conn.execute(stmt, params));
            }
        }
        catch (e) {
            const msg = e instanceof Error ? e.message : String(e);
            const queryPreview = cypher.replace(/\s+/g, ' ').slice(0, 120);
            throw new Error(`Batch execution failed for rows ${i + 1}-${i + subBatch.length}: ${msg} (${queryPreview})`);
        }
        // Note: LadybugDB PreparedStatement doesn't require explicit close()
    }
};
export const getLbugStats = async () => {
    if (!conn)
        return { nodes: 0, edges: 0 };
    let totalNodes = 0;
    for (const tableName of NODE_TABLES) {
        try {
            const queryResult = await conn.query(`MATCH (n:${escapeTableName(tableName)}) RETURN count(n) AS cnt`);
            const nodeRows = await readQueryRows(queryResult);
            if (nodeRows.length > 0) {
                totalNodes += Number(nodeRows[0]?.cnt ?? nodeRows[0]?.[0] ?? 0);
            }
        }
        catch {
            // ignore
        }
    }
    let totalEdges = 0;
    try {
        const queryResult = await conn.query(`MATCH ()-[r:${REL_TABLE_NAME}]->() RETURN count(r) AS cnt`);
        const edgeRows = await readQueryRows(queryResult);
        if (edgeRows.length > 0) {
            totalEdges = Number(edgeRows[0]?.cnt ?? edgeRows[0]?.[0] ?? 0);
        }
    }
    catch {
        // ignore
    }
    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`);
            await readQueryRows(check);
        }
        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;
            }
        }
        for (const row of await readQueryRows(rows)) {
            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);
                }
                logger.info(`[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)) {
                    logger.info(`[embed] CodeEmbedding table not yet present — full embedding run (${fallbackMsg})`);
                    return undefined;
                }
                throw fallbackErr;
            }
        }
        throw err;
    }
};
/**
 * Flush the WAL so all pending writes are visible to subsequent readers.
 *
 * Best-effort: swallows errors from older LadybugDB versions or schemaless
 * databases that do not support the CHECKPOINT command.  A no-op when there
 * is nothing pending, so safe (and cheap) to call unconditionally after any
 * write path.
 *
 * Use this instead of safeClose when the connection must stay open
 * (e.g. the /api/embed handler that keeps serving queries after flushing).
 *
 * @see safeClose — CHECKPOINT + connection/database close
 */
export const flushWAL = async () => {
    if (!conn)
        return;
    try {
        const checkpointResult = await conn.query('CHECKPOINT');
        await drainQueryResult(checkpointResult);
    }
    catch (err) {
        logger.debug(`GitNexus: LadybugDB CHECKPOINT skipped/failed during WAL flush: ${summarizeError(err)}`);
    }
};
/**
 * Issue a manual `CHECKPOINT` against the current connection and surface
 * any engine error to the caller. Unlike {@link flushWAL}, this variant
 * does NOT swallow Ladybug rename/remove IO failures — the manual
 * checkpoint driver (`wal-checkpoint-driver.ts`) relies on the rejection
 * to drive its bounded retry loop. Returns `false` when no connection is
 * open (the caller treats this as a no-op success — there is no WAL to
 * flush). Returns `true` after a successful CHECKPOINT + drain.
 *
 * The split from `flushWAL` is deliberate: every other CHECKPOINT site
 * (server flush, safeClose) is best-effort and prefers a silent skip;
 * the manual driver, by contrast, must observe failures to decide
 * whether to retry.
 */
export const tryFlushWAL = async () => {
    if (!conn)
        return false;
    const checkpointResult = await conn.query('CHECKPOINT');
    await drainQueryResult(checkpointResult);
    return true;
};
/**
 * Flush the WAL and close the connection and database handles.
 *
 * Consolidates the CHECKPOINT + close pattern into a single function so
 * callers never call conn.close() or db.close() directly (#1376).
 * An ESLint no-restricted-syntax rule enforces this — see eslint.config.mjs.
 *
 * @see flushWAL — CHECKPOINT-only (connection stays open)
 * @see closeLbug — safeClose + module state reset (full teardown)
 */
export const safeClose = async () => {
    await flushWAL();
    // Capture before close — currentDbPath stays set so the Windows post-close
    // probe below knows which file to wait on.
    const closingDbPath = currentDbPath;
    if (conn) {
        try {
            // eslint-disable-next-line no-restricted-syntax -- sole authorised close site
            await conn.close();
        }
        catch {
            /* best-effort */
        }
        conn = null;
    }
    if (db) {
        try {
            // eslint-disable-next-line no-restricted-syntax -- sole authorised close site
            await db.close();
        }
        catch {
            /* best-effort */
        }
        db = null;
    }
    // Windows: libuv reports `db.close()` resolved before the kernel has
    // released the file handle. A subsequent `new Database(samePath)` in
    // the same process can race the release. The probe (lbug-config.ts)
    // forces any residual lock to surface as EBUSY/EPERM/EACCES so the
    // open-time retry absorbs the lag.
    if (process.platform === 'win32' && closingDbPath) {
        const released = await waitForWindowsHandleRelease(closingDbPath);
        if (!released) {
            // Probe exhausted with a lock code still in flight. The next
            // openLbugConnection will absorb whatever residual lag remains, but
            // a chronic warning helps operators spot AV interference (Windows
            // Defender holding the file far past the 250ms budget).
            logger.warn({ dbPath: closingDbPath }, '⚠️ LadybugDB file handle still locked after close (Windows). If this repeats, check antivirus/Defender exclusions for the GitNexus storage directory.');
        }
    }
    if (closingDbPath) {
        await finalizeLbugSidecarsAfterClose(closingDbPath, { logger });
    }
};
export const closeLbug = async () => {
    await safeClose();
    currentDbPath = null;
    ftsLoaded = false;
    vectorExtensionLoaded = false;
    ensuredFTSIndexes.clear();
};
export const isLbugReady = () => 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 tempHandle = null;
    let tempConn = null;
    let targetConn = conn;
    if (usePerQuery) {
        tempHandle = await openLbugConnection(lbug, dbPath);
        tempConn = tempHandle.conn;
        targetConn = tempConn;
    }
    else if (!conn) {
        throw new Error('LadybugDB not initialized. Provide dbPath or call initLbug 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)
            if (tableName === 'Community' || tableName === 'Process')
                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 rows = await readQueryRows(countResult);
                const count = Number(rows[0]?.cnt ?? rows[0]?.[0] ?? 0);
                if (count > 0) {
                    // Delete nodes (and implicitly their relationships via DETACH)
                    await queryAndDrain(targetConn, `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 queryAndDrain(targetConn, `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 (tempHandle)
            await closeLbugConnection(tempHandle);
    }
};
export const getEmbeddingTableName = () => EMBEDDING_TABLE_NAME;
/**
 * Return the distinct repo-relative paths of files that import
 * `targetFilePath` according to the IMPORTS edges currently in the
 * DB. Used by the incremental writeback path to expand the
 * "files-to-rewrite" set so that files importing a changed file get
 * their edges (which may have been refined by cross-file resolution)
 * re-emitted, rather than left stale in the DB.
 *
 * The DB query reads the *previous* run's state — pre-pipeline, before
 * any nodes are deleted — so the returned importers are "files that
 * USED TO import the target". That's the right set to invalidate:
 * those are the files whose edges in the DB might no longer match
 * what cross-file resolution produces given the changed file's new
 * exports.
 */
export const queryImporters = async (targetFilePath) => {
    if (!conn) {
        throw new Error('LadybugDB not initialized. Call initLbug first.');
    }
    const escaped = targetFilePath.replace(/'/g, "''");
    const cypher = `
    MATCH (a)-[r:${REL_TABLE_NAME}]->(b)
    WHERE r.type = 'IMPORTS' AND b.filePath = '${escaped}'
    RETURN DISTINCT a.filePath AS importer
  `;
    try {
        const queryResult = await conn.query(cypher);
        const result = Array.isArray(queryResult) ? queryResult[0] : queryResult;
        const rows = await result.getAll();
        const out = [];
        for (const row of rows) {
            const v = row.importer;
            if (typeof v === 'string' && v.length > 0)
                out.push(v);
        }
        return out;
    }
    catch {
        return [];
    }
};
/**
 * Drop every Community and Process node (and their MEMBER_OF /
 * STEP_IN_PROCESS edges via DETACH DELETE). Used at the start of an
 * incremental run so the communities and processes phases regenerate
 * them from scratch on the merged graph — required for the
 * "Leiden runs on the FULL graph" correctness invariant.
 */
export const deleteAllCommunitiesAndProcesses = async () => {
    if (!conn) {
        throw new Error('LadybugDB not initialized. Call initLbug first.');
    }
    let nodesDeleted = 0;
    for (const label of ['Community', 'Process']) {
        try {
            const countResult = await conn.query(`MATCH (n:${label}) 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) {
                await conn.query(`MATCH (n:${label}) DETACH DELETE n`);
                nodesDeleted += count;
            }
        }
        catch {
            // Table may not exist yet on a freshly-initialized DB — fine.
        }
    }
    return { nodesDeleted };
};
// ============================================================================
// Full-Text Search (FTS) Functions
// ============================================================================
/**
 * Load the FTS extension on the supplied connection (or the singleton
 * writable connection when none is given).
 *
 * Delegates to the shared `ExtensionManager` so install policy (auto /
 * load-only / never), out-of-process bounded INSTALL, and capability
 * caching are owned in one place. The module-level `ftsLoaded` flag is
 * kept purely as a per-call short-circuit on the singleton writable
 * connection so repeated callers (e.g. createFTSIndex) avoid an extra
 * `LOAD` round-trip per invocation. Pool adapter callers pass
 * `{ policy: 'load-only' }` so query paths never block on a network install.
 */
export const loadFTSExtension = async (targetConn, opts = {}) => {
    const useModuleState = targetConn === undefined;
    if (useModuleState && ftsLoaded)
        return true;
    const c = targetConn ?? conn;
    if (!c) {
        throw new Error('LadybugDB not initialized. Call initLbug first.');
    }
    const loaded = await extensionManager.ensure((sql) => queryAndDrain(c, sql), 'fts', 'FTS', opts);
    if (loaded && useModuleState)
        ftsLoaded = true;
    return loaded;
};
/**
 * Load the VECTOR extension on the supplied connection (or the singleton
 * writable connection when none is given). Returns false when VECTOR is
 * unavailable so semantic search can fall back to exact scan.
 */
export const loadVectorExtension = async (targetConn, opts = {}) => {
    const useModuleState = targetConn === undefined;
    if (useModuleState && vectorExtensionLoaded)
        return true;
    // INSTALL VECTOR crashes with SIGSEGV on Windows: the KuzuDB native extension
    // installer has an unhandled error path on Windows that raises a fatal signal
    // that JS try/catch cannot intercept. Skip loading — vector/embedding search
    // is unavailable but all graph index queries still work. Do NOT set
    // vectorExtensionLoaded here: the flag means "successfully loaded", and a
    // subsequent call would otherwise short-circuit to `return true` at the top.
    if (process.platform === 'win32')
        return false;
    if (!isVectorExtensionSupportedByPlatform())
        return false;
    const c = targetConn ?? conn;
    if (!c) {
        throw new Error('LadybugDB not initialized. Call initLbug first.');
    }
    const loaded = await extensionManager.ensure((sql) => queryAndDrain(c, sql), 'VECTOR', 'VECTOR', opts);
    if (loaded && useModuleState)
        vectorExtensionLoaded = true;
    return loaded;
};
/**
 * 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 initLbug first.');
    }
    const key = ftsIndexKey(tableName, indexName);
    if (ensuredFTSIndexes.has(key))
        return;
    if (!(await loadFTSExtension())) {
        throw new Error(`FTS extension unavailable - cannot create FTS index ${tableName}.${indexName}. ` +
            'Run `gitnexus doctor` and ensure the LadybugDB FTS extension is installed and loadable on this machine.');
    }
    const propList = properties.map((p) => `'${p}'`).join(', ');
    const query = `CALL CREATE_FTS_INDEX('${tableName}', '${indexName}', [${propList}], stemmer := '${stemmer}')`;
    try {
        await queryAndDrain(conn, query);
        ensuredFTSIndexes.add(key);
    }
    catch (e) {
        if (e.message?.includes('already exists')) {
            ensuredFTSIndexes.add(key);
            return;
        }
        throw e;
    }
};
/**
 * Lazy-create an FTS index, caching the fact in-process.
 *
 * Kept for writable maintenance paths that need to lazily materialize an
 * index. Read-only query paths must not call this; production analysis owns
 * creating the configured search indexes before the database is served.
 *
 * Safe to call repeatedly — the in-process Set guarantees only the first
 * call hits LadybugDB. `closeLbug` clears the cache so re-init starts fresh.
 *
 * Defense in depth: if the active connection is read-only (e.g. the MCP
 * pool adapter), `CREATE_FTS_INDEX` will fail with "Cannot execute write
 * operations in a read-only database". Treat that as a no-op and cache
 * the key so callers don't loop on a path that can never succeed here —
 * the index is owned by `gitnexus analyze` (writable) and either already
 * exists or will be created on the next analyze.
 */
export const ensureFTSIndex = async (tableName, indexName, properties, stemmer = 'porter') => {
    const key = ftsIndexKey(tableName, indexName);
    if (ensuredFTSIndexes.has(key))
        return;
    try {
        await createFTSIndex(tableName, indexName, properties, stemmer);
    }
    catch (e) {
        // Read-only DB: writable analyze owns index creation; silently skip
        // and cache so callers don't loop on a path that can never succeed
        // here (the MCP query pool opens DBs read-only by design).
        if (isReadOnlyDbError(e)) {
            ensuredFTSIndexes.add(key);
            return;
        }
        throw e;
    }
};
/**
 * 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 initLbug first.');
    }
    const cypher = `
    CALL QUERY_FTS_INDEX('${tableName}', '${indexName}', $query, conjunctive := ${conjunctive})
    RETURN node, score
    ORDER BY score DESC
    LIMIT ${limit}
  `;
    try {
        const rows = await executePrepared(cypher, { query });
        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;
    }
};
/**
 * Drop an FTS index
 */
export const dropFTSIndex = async (tableName, indexName) => {
    if (!conn) {
        throw new Error('LadybugDB not initialized. Call initLbug first.');
    }
    try {
        await queryAndDrain(conn, `CALL DROP_FTS_INDEX('${tableName}', '${indexName}')`);
    }
    catch {
        // Index may not exist
    }
    finally {
        ensuredFTSIndexes.delete(ftsIndexKey(tableName, indexName));
    }
};
