/**
 * MCP Server State Management — Bridge Layer
 *
 * Delegates ALL per-session state to SessionManager. This file preserves
 * the same exported function names and signatures that 30+ tool files depend on.
 * Internally, each function uses getActiveSessionId() to determine the current
 * session and delegates to the SessionManager singleton.
 *
 * GLOBAL STATE: Only `config` and `mcpServer` remain global.
 * PER-SESSION STATE: currentDatabase, currentDatabaseName, operations,
 *   generation, vectorService cache, requestScopedNotify — all via SessionManager.
 *
 * CRITICAL: NEVER use console.log() — stdout is JSON-RPC (IMM-02).
 *
 * @module server/state
 */
import { DatabaseService } from '../services/storage/database/index.js';
import { DEFAULT_STORAGE_PATH } from '../services/storage/database/helpers.js';
import { databaseNotFoundError, databaseAlreadyExistsError, } from './errors.js';
import { RegistryService } from '../services/storage/registry/index.js';
import { killAllOCRWorkers, resetMarkerClient } from '../services/ocr/marker.js';
import { killAllVLMWorkers, resetChandraClient } from '../services/vlm/chandra.js';
import { getActiveSessionId } from './session-context.js';
import { getSessionManager, resetSessionManagerForTesting } from './session-state.js';
// ═══════════════════════════════════════════════════════════════════════════════
// DEFAULT CONFIGURATION
// ═══════════════════════════════════════════════════════════════════════════════
/**
 * Default server configuration
 */
const defaultConfig = {
    defaultStoragePath: DEFAULT_STORAGE_PATH,
    defaultOCRMode: 'balanced',
    maxConcurrent: 3,
    embeddingBatchSize: 32,
    embeddingDevice: 'auto',
    chunkSize: 2000,
    chunkOverlapPercent: 10,
    maxChunkSize: 8000,
    autoClusterEnabled: false,
    autoClusterThreshold: 10,
    autoClusterAlgorithm: 'hdbscan',
    imageOptimization: {
        enabled: true,
        ocrMaxWidth: 4800,
        vlmMaxDimension: 2048,
        vlmSkipBelowSize: 50,
        vlmMinRelevance: 0.3,
        vlmSkipLogosIcons: true,
    },
};
// ═══════════════════════════════════════════════════════════════════════════════
// CONFIG KEY MAPPING (for persisted config application)
// ═══════════════════════════════════════════════════════════════════════════════
const CONFIG_KEY_TO_STATE = {
    ocr_default_mode: 'defaultOCRMode',
    ocr_max_concurrent: 'maxConcurrent',
    embedding_batch_size: 'embeddingBatchSize',
    embedding_device: 'embeddingDevice',
    chunk_size: 'chunkSize',
    chunk_overlap_percent: 'chunkOverlapPercent',
    max_chunk_size: 'maxChunkSize',
    auto_cluster_enabled: 'autoClusterEnabled',
    auto_cluster_threshold: 'autoClusterThreshold',
    auto_cluster_algorithm: 'autoClusterAlgorithm',
};
// ═══════════════════════════════════════════════════════════════════════════════
// GLOBAL STATE
// ═══════════════════════════════════════════════════════════════════════════════
/**
 * Global server state.
 *
 * `config` and `mcpServer` are truly global (shared across all sessions).
 * `currentDatabase` and `currentDatabaseName` are per-session — the getters
 * delegate to SessionManager using the active session from AsyncLocalStorage.
 *
 * Tool files that read `state.currentDatabaseName` or `state.config` continue
 * to work without changes.
 */
export const state = Object.create(null);
// config and mcpServer are plain mutable properties
Object.defineProperty(state, 'config', {
    value: { ...defaultConfig },
    writable: true,
    enumerable: true,
    configurable: true,
});
Object.defineProperty(state, 'mcpServer', {
    value: null,
    writable: true,
    enumerable: true,
    configurable: true,
});
// currentDatabaseName delegates to the session manager
Object.defineProperty(state, 'currentDatabaseName', {
    get() {
        return getCurrentDatabaseName();
    },
    set(_v) {
        // No-op — managed by SessionManager. Direct writes are a bug.
        // Log to help developers find and fix call sites.
        console.error('[state] WARNING: Direct write to state.currentDatabaseName is a no-op. ' +
            'Use selectDatabase() or clearDatabase() instead.');
    },
    enumerable: true,
    configurable: true,
});
// currentDatabase delegates to the session manager
Object.defineProperty(state, 'currentDatabase', {
    get() {
        const mgr = getSessionManager();
        const sessionId = getActiveSessionId();
        if (!mgr.hasDatabase(sessionId))
            return null;
        try {
            return mgr.getConnectionForSessionById(sessionId);
        }
        catch {
            return null;
        }
    },
    set(_v) {
        // No-op — managed by SessionManager. Direct writes are a bug.
        console.error('[state] WARNING: Direct write to state.currentDatabase is a no-op. ' +
            'Use selectDatabase() or clearDatabase() instead.');
    },
    enumerable: true,
    configurable: true,
});
// ═══════════════════════════════════════════════════════════════════════════════
// KEEPALIVE / NOTIFICATION
// ═══════════════════════════════════════════════════════════════════════════════
/**
 * Set the request-scoped notification sender for the current session.
 *
 * When set, keepalives are sent via the SDK's extra.sendNotification
 * (which includes relatedRequestId), routing them to the POST response
 * SSE stream that the client IS reading.
 *
 * Set by tool handlers that do long-running work (e.g., ocr_process_pending).
 * Cleared when the handler completes.
 */
export function setRequestScopedNotify(fn) {
    const mgr = getSessionManager();
    mgr.setRequestScopedNotify(getActiveSessionId(), fn);
}
/**
 * Send a keepalive logging notification to prevent MCP client timeout.
 *
 * Uses request-scoped sendNotification (with relatedRequestId) when available,
 * so keepalives go on the same POST SSE stream the client is reading.
 * Falls back to sendLoggingMessage for the standalone stream.
 *
 * Safe to call even if mcpServer is not set — silently no-ops.
 * Fully defensive: catches both sync throws and async rejections.
 */
export function sendKeepalive(message) {
    const mgr = getSessionManager();
    const notify = mgr.getRequestScopedNotify(getActiveSessionId());
    // Prefer request-scoped notification — routes to POST response stream
    if (notify) {
        try {
            notify({
                method: 'notifications/message',
                params: { level: 'info', data: message },
            }).catch(() => { });
        }
        catch { /* best-effort */ }
        return;
    }
    // Fallback: standalone SSE stream (works for short requests, may not reach wrapper bridges)
    if (state.mcpServer) {
        try {
            state.mcpServer.sendLoggingMessage({
                level: 'info',
                data: message,
            }).catch(() => { });
        }
        catch { /* best-effort */ }
    }
}
// ═══════════════════════════════════════════════════════════════════════════════
// DATABASE ACCESS
// ═══════════════════════════════════════════════════════════════════════════════
/**
 * Require database to be selected - FAIL FAST if not
 *
 * @returns Database service, vector service, and generation counter
 * @throws MCPError with DATABASE_NOT_SELECTED if no database is selected
 */
export function requireDatabase() {
    const mgr = getSessionManager();
    return mgr.requireDatabase(getActiveSessionId());
}
/**
 * Validate that the database generation matches the expected value.
 *
 * The generation counter increments on every database switch/clear. A mismatch
 * means the database was switched between the time a caller obtained the
 * generation and the time it validates -- indicating a race condition where
 * the caller's database reference is stale.
 *
 * @param expectedGeneration - The generation value obtained from requireDatabase()
 * @throws Error if the current generation does not match
 */
export function validateGeneration(expectedGeneration) {
    const mgr = getSessionManager();
    mgr.validateGeneration(getActiveSessionId(), expectedGeneration);
}
// ═══════════════════════════════════════════════════════════════════════════════
// OPERATION TRACKING (H-1, H-2, M-9)
// ═══════════════════════════════════════════════════════════════════════════════
/**
 * Begin tracking an in-flight database operation.
 *
 * Increments the active operation counter and returns the current generation.
 * While any operations are active, selectDatabase() and clearDatabase() will
 * throw to prevent database switches during async work.
 *
 * @returns Current database generation for later validation
 * @throws MCPError if no database is selected
 */
export function beginDatabaseOperation() {
    const mgr = getSessionManager();
    return mgr.beginOperation(getActiveSessionId());
}
/**
 * End tracking an in-flight database operation.
 *
 * Decrements the active operation counter. Counter never goes below 0.
 */
export function endDatabaseOperation() {
    const mgr = getSessionManager();
    mgr.endOperation(getActiveSessionId());
}
/**
 * Refresh the operation timestamp to prevent stale-operation expiration.
 *
 * Long-running operations (e.g., CPU OCR processing 30+ minutes) must call
 * this periodically to signal they are still alive. Without this, the
 * expireStaleOperations() check would consider the operation abandoned after
 * OPERATION_TIMEOUT_MS and reset the guard -- allowing a concurrent
 * selectDatabase() call to close the connection mid-operation.
 *
 * Typically called from keepalive timers that already run every 15 seconds.
 * Safe to call when no operations are active (no-ops silently).
 */
export function touchDatabaseOperation() {
    const mgr = getSessionManager();
    mgr.touchOperation(getActiveSessionId());
}
/**
 * Get the number of active database operations (for diagnostics/testing).
 */
export function getActiveOperationCount() {
    const mgr = getSessionManager();
    return mgr.getActiveOperationCount(getActiveSessionId());
}
/**
 * Execute an async function within a tracked database operation scope.
 *
 * Calls beginDatabaseOperation() before the function and endDatabaseOperation()
 * in a finally block, guaranteeing the counter is decremented even on error.
 * Also validates the generation after the function completes to detect any
 * mid-operation database switch.
 *
 * Automatically refreshes the operation timestamp every 60 seconds to prevent
 * expireStaleOperations() from declaring long-running operations (e.g., 30+
 * minute CPU OCR) as stale. Without this heartbeat, a concurrent
 * selectDatabase() call could close the connection mid-operation.
 *
 * @param fn - Async function receiving DatabaseServices
 * @returns The result of fn
 * @throws MCPError if no database is selected
 * @throws Error if the database was switched during the operation
 */
export async function withDatabaseOperation(fn) {
    const mgr = getSessionManager();
    return mgr.withDatabaseOperation(getActiveSessionId(), fn);
}
/**
 * Check if a database is currently selected
 */
export function hasDatabase() {
    const mgr = getSessionManager();
    return mgr.hasDatabase(getActiveSessionId());
}
/**
 * Get current database name or null
 */
export function getCurrentDatabaseName() {
    const mgr = getSessionManager();
    return mgr.getCurrentDatabaseName(getActiveSessionId());
}
// ═══════════════════════════════════════════════════════════════════════════════
// DATABASE MANAGEMENT
// ═══════════════════════════════════════════════════════════════════════════════
/**
 * Apply persisted config from a database to the global state.config.
 *
 * Called after database selection. Reads the raw persisted config from
 * the SessionManager and maps it to ServerConfig keys.
 *
 * @param db - The DatabaseService to load config from
 */
function applyPersistedConfigToGlobal(db) {
    const mgr = getSessionManager();
    const sessionId = getActiveSessionId();
    const persisted = mgr.applyPersistedConfig(db, sessionId);
    if (Object.keys(persisted).length > 0) {
        const updates = {};
        for (const [key, value] of Object.entries(persisted)) {
            const stateKey = CONFIG_KEY_TO_STATE[key];
            if (stateKey) {
                updates[stateKey] = value;
            }
        }
        if (Object.keys(updates).length > 0) {
            updateConfig(updates);
            console.error(`[state] Applied ${Object.keys(updates).length} persisted config value(s) from database`);
        }
    }
}
/**
 * Select a database by name - opens connection and sets as current
 *
 * FAIL FAST: Throws immediately if database doesn't exist or if operations
 * are in-flight (H-2 guard). Uses atomic swap (M-9 fix): opens new DB first,
 * then swaps state, then closes old DB -- no null window.
 *
 * @param name - Database name to select
 * @param storagePath - Optional storage path override
 * @param force - Force-clear stuck in-flight operations before switching.
 *   Use when a previous operation crashed and left the counter stuck.
 * @throws MCPError with DATABASE_NOT_FOUND if database doesn't exist
 * @throws Error if database operations are in-flight and force is false
 */
export function selectDatabase(name, storagePath, force) {
    const mgr = getSessionManager();
    const sessionId = getActiveSessionId();
    const path = storagePath ?? state.config.defaultStoragePath;
    mgr.selectDatabase(sessionId, name, path, force);
    // Apply persisted config to the global state.config
    try {
        const db = mgr.getConnectionForSessionById(sessionId);
        applyPersistedConfigToGlobal(db);
    }
    catch (error) {
        console.error(`[state] Failed to apply persisted config after selecting "${name}": ` +
            `${error instanceof Error ? error.message : String(error)}`);
    }
}
/**
 * Create a new database and optionally select it
 *
 * FAIL FAST: Throws immediately if database already exists
 *
 * @param name - Database name to create
 * @param description - Optional description
 * @param storagePath - Optional storage path override
 * @param autoSelect - Whether to select the database after creation (default: true)
 * @returns The created database service
 * @throws MCPError with DATABASE_ALREADY_EXISTS if database exists
 */
export function createDatabase(name, description, storagePath, autoSelect = true) {
    const mgr = getSessionManager();
    const sessionId = getActiveSessionId();
    const path = storagePath ?? state.config.defaultStoragePath;
    // Check if database already exists - FAIL FAST
    if (DatabaseService.exists(name, path)) {
        throw databaseAlreadyExistsError(name);
    }
    // Create the database
    const db = DatabaseService.create(name, description, path);
    if (autoSelect) {
        // H-3: Refuse to switch while async operations are in-flight
        const opCount = mgr.getActiveOperationCount(sessionId);
        if (opCount > 0) {
            // Close the newly created DB since we can't switch to it
            db.close();
            throw new Error(`Cannot auto-select newly created database "${name}" while ${opCount} operation(s) are in-flight. ` +
                `Wait for active operations to complete, then select the database manually.`);
        }
        // Select the newly created database for this session.
        // Note: selectDatabase() uses ConnectionCache.acquire() which calls
        // DatabaseService.open(). The db we just created via DatabaseService.create()
        // is separate — close it since selectDatabase will open its own connection.
        db.close();
        mgr.selectDatabase(sessionId, name, path);
        // Re-open via the session manager's connection to return to caller
        return mgr.getConnectionForSessionById(sessionId);
    }
    // When autoSelect=false, return the open DB -- caller manages lifecycle.
    return db;
}
/**
 * Delete a database
 *
 * FAIL FAST: Throws if database doesn't exist
 *
 * @param name - Database name to delete
 * @param storagePath - Optional storage path override
 * @throws MCPError with DATABASE_NOT_FOUND if database doesn't exist
 */
export function deleteDatabase(name, storagePath) {
    const mgr = getSessionManager();
    const sessionId = getActiveSessionId();
    const path = storagePath ?? state.config.defaultStoragePath;
    // Verify database exists - FAIL FAST
    if (!DatabaseService.exists(name, path)) {
        throw databaseNotFoundError(name, path);
    }
    // If this session has this DB selected, clear it
    if (mgr.getCurrentDatabaseName(sessionId) === name) {
        mgr.clearDatabase(sessionId, /* forceClose */ true);
    }
    // Delete the database
    DatabaseService.delete(name, path);
    // Unregister from registry so it doesn't reappear in dashboard/recommendations
    try {
        RegistryService.getInstance().unregisterDatabase(name);
    }
    catch {
        // Registry entry may not exist (e.g., test databases created outside registry)
    }
}
/**
 * Clear current database selection - closes connection.
 *
 * FAIL FAST: Throws if async operations are in-flight (H-2 guard).
 * The forceClose parameter bypasses the guard for internal use only
 * (resetState in tests, process exit cleanup).
 *
 * @param forceClose - Skip operation guard (internal use only)
 * @throws Error if database operations are in-flight and forceClose is false
 */
export function clearDatabase(forceClose = false) {
    const mgr = getSessionManager();
    mgr.clearDatabase(getActiveSessionId(), forceClose);
}
// ═══════════════════════════════════════════════════════════════════════════════
// CONFIGURATION
// ═══════════════════════════════════════════════════════════════════════════════
/**
 * Get current server configuration
 */
export function getConfig() {
    return { ...state.config };
}
/**
 * Update server configuration (deep merges nested objects like imageOptimization)
 */
export function updateConfig(updates) {
    // Deep merge imageOptimization if both existing and update have it
    if (updates.imageOptimization && state.config.imageOptimization) {
        updates = {
            ...updates,
            imageOptimization: {
                ...state.config.imageOptimization,
                ...updates.imageOptimization,
            },
        };
    }
    state.config = { ...state.config, ...updates };
}
/**
 * Reset configuration to defaults
 */
export function resetConfig() {
    state.config = { ...defaultConfig };
}
/**
 * Get default storage path
 */
export function getDefaultStoragePath() {
    return state.config.defaultStoragePath;
}
// ═══════════════════════════════════════════════════════════════════════════════
// STATE RESET (FOR TESTING)
// ═══════════════════════════════════════════════════════════════════════════════
/**
 * Reset all server state - ONLY USE IN TESTS
 *
 * Kills all daemon processes to prevent GPU memory accumulation.
 * Shuts down the SessionManager (closes all connections, destroys all sessions).
 * Resets config to defaults.
 */
export function resetState() {
    // Kill daemon processes FIRST to free GPU memory before next test
    try {
        resetMarkerClient();
    }
    catch { /* ignore cleanup errors */ }
    try {
        resetChandraClient();
    }
    catch { /* ignore cleanup errors */ }
    try {
        killAllOCRWorkers();
    }
    catch { /* ignore cleanup errors */ }
    try {
        killAllVLMWorkers();
    }
    catch { /* ignore cleanup errors */ }
    // Shutdown the session manager (closes all connections, destroys all sessions)
    resetSessionManagerForTesting();
    // Reset config
    state.config = { ...defaultConfig };
}
// ═══════════════════════════════════════════════════════════════════════════════
// PROCESS EXIT CLEANUP
// ═══════════════════════════════════════════════════════════════════════════════
/**
 * L-5: Clean up database connections on process exit.
 * Ensures WAL/SHM files are properly checkpointed.
 */
process.on('exit', () => {
    try {
        getSessionManager().shutdown();
    }
    catch (error) {
        console.error('[state] shutdown on exit failed:', error instanceof Error ? error.message : String(error));
    }
});
//# sourceMappingURL=state.js.map