/**
 * SessionManager — Per-session state management for multi-agent concurrency
 *
 * Each MCP session gets independent state: its own database selection, operation
 * counter, generation counter, and cached services. Multiple sessions on the
 * same database share a single SQLite connection via ConnectionCache.
 *
 * DESIGN DECISIONS:
 * - SessionManager is a singleton accessed via getSessionManager()
 * - getSession() auto-creates sessions on first access (no explicit "create" step)
 * - Operation guards are per-session: Agent A processing does not block Agent B
 * - Connection sharing is safe: Node.js single-threaded + better-sqlite3 synchronous
 *
 * CRITICAL: NEVER use console.log() — stdout is JSON-RPC (IMM-02).
 * FAIL FAST: Throw on errors, never return null or default values (AP-03, AP-05, AP-07).
 *
 * @module server/session-state
 */
import { VectorService } from '../services/storage/vector.js';
import { DEFAULT_STORAGE_PATH } from '../services/storage/database/helpers.js';
import { databaseNotSelectedError } from './errors.js';
import { loadPersistedConfig } from '../utils/config-persistence.js';
import { ConnectionCache } from './connection-cache.js';
// ═══════════════════════════════════════════════════════════════════════════════
// CONSTANTS
// ═══════════════════════════════════════════════════════════════════════════════
/**
 * Maximum time (ms) an operation can be in-flight before it's considered stale.
 *
 * Active operations call touchOperation() every 15-60 seconds to refresh
 * the timestamp. If no heartbeat has been received for OPERATION_TIMEOUT_MS,
 * the operation is considered dead (crashed, disconnected, or abandoned).
 *
 * Same value as the previous global constant in state.ts.
 */
const OPERATION_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
// ═══════════════════════════════════════════════════════════════════════════════
// SESSION MANAGER
// ═══════════════════════════════════════════════════════════════════════════════
/**
 * SessionManager — the core of per-session state management.
 *
 * Holds a Map<string, SessionState> for all active sessions and a shared
 * ConnectionCache for database connections. Each session operates independently:
 * its own database selection, operation counter, and generation.
 */
class SessionManager {
    sessions = new Map();
    connectionCache = new ConnectionCache();
    /** Cumulative counters for metrics */
    totalCreated = 0;
    totalDestroyed = 0;
    // ═══════════════════════════════════════════════════════════════════════════
    // SESSION LIFECYCLE
    // ═══════════════════════════════════════════════════════════════════════════
    /**
     * Get or create a SessionState for the given session ID.
     *
     * Auto-creates on first access — no explicit "create session" step required.
     * This keeps the API simple: tool handlers just call getSession() and it works.
     *
     * @param sessionId - The MCP session identifier
     * @returns The SessionState for this session
     */
    getSession(sessionId) {
        const existing = this.sessions.get(sessionId);
        if (existing) {
            existing.lastActivity = Date.now();
            return existing;
        }
        // Create a new session
        const now = Date.now();
        const session = {
            sessionId,
            currentDatabaseName: null,
            cachedDb: null,
            activeOperations: 0,
            lastOperationStartTime: 0,
            dbGeneration: 0,
            cachedVectorService: null,
            requestScopedNotify: null,
            createdAt: now,
            lastActivity: now,
        };
        this.sessions.set(sessionId, session);
        this.totalCreated++;
        console.error(`[session-state] Created session "${sessionId}" (total active: ${this.sessions.size})`);
        return session;
    }
    /**
     * Destroy a session — releases connection references and removes from map.
     *
     * Safe to call on a session that has already been destroyed (no-ops).
     *
     * @param sessionId - The MCP session identifier
     */
    destroySession(sessionId) {
        const session = this.sessions.get(sessionId);
        if (!session)
            return;
        // Release database connection if one is selected
        if (session.currentDatabaseName) {
            try {
                this.connectionCache.release(session.currentDatabaseName);
            }
            catch (error) {
                console.error(`[session-state] Error releasing connection for session "${sessionId}": ` +
                    `${error instanceof Error ? error.message : String(error)}`);
            }
        }
        // Clear cached services
        session.cachedDb = null;
        session.cachedVectorService = null;
        session.requestScopedNotify = null;
        this.sessions.delete(sessionId);
        this.totalDestroyed++;
        console.error(`[session-state] Destroyed session "${sessionId}" (total active: ${this.sessions.size})`);
    }
    // ═══════════════════════════════════════════════════════════════════════════
    // DATABASE SELECTION
    // ═══════════════════════════════════════════════════════════════════════════
    /**
     * Select a database for a session.
     *
     * 1. Expire stale operations for this session
     * 2. If force=true, clear stuck operation counter
     * 3. If session has active operations, throw
     * 4. If session already has a DB, release old connection from cache
     * 5. Acquire new connection from cache
     * 6. Clear VectorService cache and increment generation
     * 7. Reset stuck 'processing' documents
     * 8. Load persisted config
     *
     * @param sessionId - The MCP session identifier
     * @param name - Database name to select
     * @param storagePath - Optional storage path override
     * @param force - Force-clear stuck in-flight operations before switching
     * @throws Error if operations are in-flight and force is false
     * @throws MCPError with DATABASE_NOT_FOUND if database does not exist
     */
    selectDatabase(sessionId, name, storagePath, force) {
        const session = this.getSession(sessionId);
        const path = storagePath ?? DEFAULT_STORAGE_PATH;
        // Step 1: Auto-expire stale operations for THIS session
        this.expireStaleOperationsForSession(session);
        // Step 2: Force-clear stuck operations if requested
        if (force && session.activeOperations > 0) {
            console.error(`[WARN] Force-clearing ${session.activeOperations} stuck operation(s) for session "${sessionId}" ` +
                `(last heartbeat: ${session.lastOperationStartTime > 0 ? Math.round((Date.now() - session.lastOperationStartTime) / 1000) + 's ago' : 'unknown'}). ` +
                `User requested force switch to database "${name}".`);
            session.activeOperations = 0;
            session.lastOperationStartTime = 0;
        }
        // Step 3: Refuse to switch while THIS session has in-flight operations
        if (session.activeOperations > 0) {
            throw new Error(`Cannot switch databases while ${session.activeOperations} operation(s) are in-flight ` +
                `for session "${sessionId}". Wait for active operations to complete before switching databases. ` +
                `If the operation is stuck (e.g., after a crash or disconnect), retry with force: true to clear the lock.`);
        }
        // Step 4: Handle connection acquisition.
        //
        // SAME-DB RE-SELECT (WAL/mmap safety):
        //   When re-opening the SAME database file, the old connection's memory-mapped
        //   SHM region must be released BEFORE opening a new connection. This ensures
        //   schema migrations run on a fresh connection. Release FIRST, then acquire.
        //
        // DIFFERENT-DB (M-9 atomic swap):
        //   Acquire new BEFORE releasing old so state is never null on failure.
        const oldDbName = session.currentDatabaseName;
        const isSameDb = oldDbName === name;
        let db;
        if (isSameDb && oldDbName) {
            // Same DB: release old FIRST (may close connection if refCount drops to 0),
            // then acquire fresh (reopens with migration if needed)
            session.currentDatabaseName = null;
            session.cachedDb = null;
            session.cachedVectorService = null;
            this.connectionCache.release(oldDbName);
            db = this.connectionCache.acquire(name, path);
        }
        else {
            // Different DB: acquire new BEFORE releasing old (M-9 atomic swap)
            db = this.connectionCache.acquire(name, path);
            if (oldDbName) {
                try {
                    this.connectionCache.release(oldDbName);
                }
                catch (error) {
                    console.error(`[session-state] Error releasing old connection "${oldDbName}" for session "${sessionId}": ` +
                        `${error instanceof Error ? error.message : String(error)}`);
                }
            }
        }
        // Step 5: Update session state
        session.currentDatabaseName = name;
        session.cachedDb = db;
        session.cachedVectorService = null;
        session.dbGeneration++;
        session.lastActivity = Date.now();
        // Step 6: Reset stuck 'processing' documents (clean derived data first to prevent duplicates)
        try {
            const conn = db.getConnection();
            const stuckDocs = conn
                .prepare(`SELECT id FROM documents WHERE status = 'processing'`)
                .all();
            if (stuckDocs.length > 0) {
                for (const { id } of stuckDocs) {
                    try {
                        db.cleanDocumentDerivedData(id);
                    }
                    catch {
                        // Best-effort cleanup — table might not exist or doc might have no derived data
                    }
                }
                conn
                    .prepare(`UPDATE documents SET status = 'pending', modified_at = ? WHERE status = 'processing'`)
                    .run(new Date().toISOString());
                console.error(`[session-state] Reset ${stuckDocs.length} stuck 'processing' document(s) back to 'pending' (derived data cleaned) ` +
                    `(session: "${sessionId}", database: "${name}")`);
            }
        }
        catch {
            // Non-fatal — table might not exist in empty databases
        }
        // Step 7: Load persisted config
        this.applyPersistedConfig(db, sessionId);
        console.error(`[session-state] Session "${sessionId}" selected database "${name}" ` +
            `(generation: ${session.dbGeneration})`);
    }
    // ═══════════════════════════════════════════════════════════════════════════
    // DATABASE ACCESS
    // ═══════════════════════════════════════════════════════════════════════════
    /**
     * Get DatabaseService + VectorService for a session. FAIL FAST if no DB selected.
     *
     * @param sessionId - The MCP session identifier
     * @returns DatabaseServices with db, vector, and generation
     * @throws MCPError with DATABASE_NOT_SELECTED if no database is selected
     */
    requireDatabase(sessionId) {
        const session = this.getSession(sessionId);
        if (!session.currentDatabaseName) {
            throw databaseNotSelectedError();
        }
        // Get the shared connection from cache
        // The connection was acquired in selectDatabase() and is still alive
        // as long as refCount > 0. We do NOT call acquire() here — that would
        // increment the refCount again. Instead, we trust that the connection
        // is still valid because the session holds a reference.
        const db = this.getConnectionForSession(session);
        if (!session.cachedVectorService) {
            session.cachedVectorService = new VectorService(db.getConnection());
        }
        return {
            db,
            vector: session.cachedVectorService,
            generation: session.dbGeneration,
        };
    }
    // ═══════════════════════════════════════════════════════════════════════════
    // OPERATION TRACKING (per-session)
    // ═══════════════════════════════════════════════════════════════════════════
    /**
     * Begin tracking an in-flight operation for a session.
     *
     * Increments the session's active operation counter. While any operations
     * are active for THIS session, selectDatabase() for THIS session will throw.
     * Other sessions are NOT affected.
     *
     * @param sessionId - The MCP session identifier
     * @returns Current database generation for later validation
     * @throws MCPError if no database is selected for this session
     */
    beginOperation(sessionId) {
        const session = this.getSession(sessionId);
        if (!session.currentDatabaseName) {
            throw databaseNotSelectedError();
        }
        session.activeOperations++;
        session.lastOperationStartTime = Date.now();
        session.lastActivity = Date.now();
        return session.dbGeneration;
    }
    /**
     * End tracking an in-flight operation for a session.
     *
     * Decrements the session's active operation counter. Counter never goes below 0.
     *
     * @param sessionId - The MCP session identifier
     */
    endOperation(sessionId) {
        const session = this.sessions.get(sessionId);
        if (!session)
            return;
        if (session.activeOperations > 0) {
            session.activeOperations--;
            if (session.activeOperations === 0) {
                session.lastOperationStartTime = 0;
            }
        }
        session.lastActivity = Date.now();
    }
    /**
     * 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
     * expireStaleOperationsForSession() check would consider the operation
     * abandoned after OPERATION_TIMEOUT_MS and reset the guard.
     *
     * @param sessionId - The MCP session identifier
     */
    touchOperation(sessionId) {
        const session = this.sessions.get(sessionId);
        if (!session)
            return;
        if (session.activeOperations > 0) {
            session.lastOperationStartTime = Date.now();
            session.lastActivity = Date.now();
        }
    }
    /**
     * Execute an async function within a tracked database operation scope.
     *
     * Calls beginOperation() before the function and endOperation() in a finally
     * block, guaranteeing the counter is decremented even on error. Also validates
     * the generation after the function completes to detect mid-operation DB switches.
     *
     * Automatically refreshes the operation timestamp every 60 seconds to prevent
     * stale expiration during long-running work (e.g., 30+ minute CPU OCR).
     *
     * @param sessionId - The MCP session identifier
     * @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
     */
    async withDatabaseOperation(sessionId, fn) {
        const generation = this.beginOperation(sessionId);
        // Auto-refresh the operation timestamp every 60s to prevent stale expiration
        const heartbeat = setInterval(() => {
            this.touchOperation(sessionId);
        }, 60_000);
        try {
            const services = this.requireDatabase(sessionId);
            const result = await fn(services);
            // Validate generation — detect if database was switched mid-operation
            const session = this.sessions.get(sessionId);
            if (session && session.dbGeneration !== generation) {
                throw new Error(`Database generation mismatch for session "${sessionId}": ` +
                    `expected ${generation}, current ${session.dbGeneration}. ` +
                    `The database was switched during this operation. Retry with the current database.`);
            }
            return result;
        }
        finally {
            clearInterval(heartbeat);
            this.endOperation(sessionId);
        }
    }
    // ═══════════════════════════════════════════════════════════════════════════
    // DATABASE STATE QUERIES
    // ═══════════════════════════════════════════════════════════════════════════
    /**
     * Clear the database selection for a session — releases connection.
     *
     * @param sessionId - The MCP session identifier
     * @param forceClose - Skip operation guard (internal use: tests, shutdown)
     * @throws Error if operations are in-flight and forceClose is false
     */
    clearDatabase(sessionId, forceClose) {
        const session = this.sessions.get(sessionId);
        if (!session)
            return;
        // Auto-expire stale operations before checking the guard
        this.expireStaleOperationsForSession(session);
        // Refuse to clear while async operations are in-flight (unless forced)
        if (!forceClose && session.activeOperations > 0) {
            throw new Error(`Cannot clear database for session "${sessionId}" while ${session.activeOperations} ` +
                `operation(s) are in-flight. Wait for active operations to complete.`);
        }
        if (session.currentDatabaseName) {
            try {
                this.connectionCache.release(session.currentDatabaseName);
            }
            catch (error) {
                console.error(`[session-state] Error releasing connection during clearDatabase for session "${sessionId}": ` +
                    `${error instanceof Error ? error.message : String(error)}`);
            }
            session.currentDatabaseName = null;
            session.cachedDb = null;
            session.cachedVectorService = null;
            session.dbGeneration++;
        }
    }
    /**
     * Check if a session has a database selected.
     *
     * @param sessionId - The MCP session identifier
     * @returns true if a database is selected
     */
    hasDatabase(sessionId) {
        const session = this.sessions.get(sessionId);
        if (!session)
            return false;
        return session.currentDatabaseName !== null;
    }
    /**
     * Get the currently selected database name for a session, or null.
     *
     * @param sessionId - The MCP session identifier
     * @returns Database name or null
     */
    getCurrentDatabaseName(sessionId) {
        const session = this.sessions.get(sessionId);
        if (!session)
            return null;
        return session.currentDatabaseName;
    }
    /**
     * Get the number of active operations for a session (diagnostics/testing).
     *
     * @param sessionId - The MCP session identifier
     * @returns Number of active operations, or 0 if session doesn't exist
     */
    getActiveOperationCount(sessionId) {
        const session = this.sessions.get(sessionId);
        if (!session)
            return 0;
        return session.activeOperations;
    }
    /**
     * 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 stale reference.
     *
     * @param sessionId - The MCP session identifier
     * @param expectedGeneration - The generation value obtained from requireDatabase()
     * @throws Error if the current generation does not match
     */
    validateGeneration(sessionId, expectedGeneration) {
        const session = this.getSession(sessionId);
        if (session.dbGeneration !== expectedGeneration) {
            throw new Error(`Database generation mismatch for session "${sessionId}": ` +
                `expected ${expectedGeneration}, current ${session.dbGeneration}. ` +
                `The database was switched during this operation. Retry with the current database.`);
        }
    }
    /**
     * Set the request-scoped notification sender for a session's keepalives.
     *
     * @param sessionId - The MCP session identifier
     * @param fn - Notification function, or null to clear
     */
    setRequestScopedNotify(sessionId, fn) {
        const session = this.getSession(sessionId);
        session.requestScopedNotify = fn;
    }
    /**
     * Get the request-scoped notification sender for a session.
     *
     * @param sessionId - The MCP session identifier
     * @returns The notification function, or null if not set
     */
    getRequestScopedNotify(sessionId) {
        const session = this.sessions.get(sessionId);
        if (!session)
            return null;
        return session.requestScopedNotify;
    }
    /**
     * Get the DatabaseService for a session from the ConnectionCache.
     * Public accessor for the bridge layer.
     *
     * @param sessionId - The MCP session identifier
     * @returns The DatabaseService for the session's current database
     * @throws MCPError if no database is selected for this session
     */
    getConnectionForSessionById(sessionId) {
        const session = this.getSession(sessionId);
        return this.getConnectionForSession(session);
    }
    // ═══════════════════════════════════════════════════════════════════════════
    // SESSION CLEANUP
    // ═══════════════════════════════════════════════════════════════════════════
    /**
     * Expire sessions that have been idle longer than ttlMs.
     *
     * Destroys sessions whose lastActivity is older than (now - ttlMs).
     * Sessions with active operations are NOT expired (they are still working).
     *
     * @param ttlMs - Maximum idle time in milliseconds
     * @returns Number of sessions expired
     */
    expireSessions(ttlMs) {
        const now = Date.now();
        const cutoff = now - ttlMs;
        let expired = 0;
        for (const [sessionId, session] of this.sessions) {
            // Do not expire sessions with active operations
            if (session.activeOperations > 0)
                continue;
            if (session.lastActivity < cutoff) {
                console.error(`[session-state] Expiring idle session "${sessionId}" ` +
                    `(idle for ${Math.round((now - session.lastActivity) / 1000)}s, TTL: ${Math.round(ttlMs / 1000)}s)`);
                this.destroySession(sessionId);
                expired++;
            }
        }
        return expired;
    }
    // ═══════════════════════════════════════════════════════════════════════════
    // DIAGNOSTICS
    // ═══════════════════════════════════════════════════════════════════════════
    /**
     * Get a snapshot of session metrics for monitoring and health checks.
     */
    getMetrics() {
        const sessions = [];
        for (const session of this.sessions.values()) {
            sessions.push({
                sessionId: session.sessionId,
                currentDatabase: session.currentDatabaseName,
                activeOperations: session.activeOperations,
                dbGeneration: session.dbGeneration,
                createdAt: session.createdAt,
                lastActivity: session.lastActivity,
            });
        }
        return {
            activeSessions: this.sessions.size,
            totalCreated: this.totalCreated,
            totalDestroyed: this.totalDestroyed,
            sessions,
            connectionCache: this.connectionCache.getMetrics(),
        };
    }
    // ═══════════════════════════════════════════════════════════════════════════
    // SHUTDOWN
    // ═══════════════════════════════════════════════════════════════════════════
    /**
     * Shut down the SessionManager — destroy all sessions and close all connections.
     * Used during process exit.
     */
    shutdown() {
        console.error(`[session-state] Shutting down SessionManager (${this.sessions.size} active session(s))`);
        // Destroy all sessions (releases their connection refs)
        for (const sessionId of [...this.sessions.keys()]) {
            this.destroySession(sessionId);
        }
        // Force-close any remaining connections (safety net)
        this.connectionCache.closeAll();
    }
    /**
     * Reset all state — ONLY USE IN TESTS.
     *
     * Destroys all sessions, closes all connections, and resets counters.
     */
    resetForTesting() {
        for (const sessionId of [...this.sessions.keys()]) {
            this.destroySession(sessionId);
        }
        this.connectionCache.closeAll();
        this.totalCreated = 0;
        this.totalDestroyed = 0;
    }
    // ═══════════════════════════════════════════════════════════════════════════
    // INTERNAL HELPERS
    // ═══════════════════════════════════════════════════════════════════════════
    /**
     * Check if a session's active operations have expired and auto-reset.
     *
     * Called before any guard check to prevent permanent locks from crashed operations.
     */
    expireStaleOperationsForSession(session) {
        if (session.activeOperations > 0 && session.lastOperationStartTime > 0) {
            const elapsed = Date.now() - session.lastOperationStartTime;
            if (elapsed > OPERATION_TIMEOUT_MS) {
                console.error(`[WARN] Auto-releasing ${session.activeOperations} stale operation(s) for session ` +
                    `"${session.sessionId}" after ${Math.round(elapsed / 1000)}s ` +
                    `(timeout: ${OPERATION_TIMEOUT_MS / 1000}s). ` +
                    `This usually means a previous process_pending call was interrupted.`);
                session.activeOperations = 0;
                session.lastOperationStartTime = 0;
            }
        }
    }
    /**
     * Get the DatabaseService for a session.
     *
     * Returns the cached reference stored during selectDatabase(). The connection
     * is kept alive by the session's refCount in the ConnectionCache.
     *
     * @throws MCPError if no database is selected for this session
     */
    getConnectionForSession(session) {
        if (!session.currentDatabaseName || !session.cachedDb) {
            throw databaseNotSelectedError();
        }
        return session.cachedDb;
    }
    /**
     * Apply persisted config from a database's config_json column.
     *
     * Called after database selection to restore config values that were
     * saved in a previous session. Config is global (shared across sessions)
     * because it configures processing parameters, not per-session state.
     *
     * Returns the raw persisted config map so the bridge layer (state.ts)
     * can apply it to the global ServerConfig. Does NOT throw on failure —
     * config load is best-effort (matches original state.ts behavior).
     *
     * @param db - The newly opened DatabaseService
     * @param sessionId - For logging context
     * @returns Persisted config key-value map, or empty object on failure
     */
    applyPersistedConfig(db, sessionId) {
        try {
            const persisted = loadPersistedConfig(db.getConnection());
            if (Object.keys(persisted).length > 0) {
                console.error(`[session-state] Loaded ${Object.keys(persisted).length} persisted config value(s) ` +
                    `from database (session: "${sessionId}")`);
            }
            return persisted;
        }
        catch (error) {
            console.error(`[session-state] Failed to load persisted config for session "${sessionId}": ` +
                `${error instanceof Error ? error.message : String(error)}`);
            return {};
        }
    }
}
// ═══════════════════════════════════════════════════════════════════════════════
// SINGLETON ACCESS
// ═══════════════════════════════════════════════════════════════════════════════
/**
 * Singleton instance — lazily created to avoid circular import issues.
 *
 * Modules that import from session-state.ts are evaluated before the module
 * body finishes executing. A top-level `export const sessionManager = new SessionManager()`
 * would fail if any dependency has a circular import. Lazy creation avoids this.
 */
let _instance = null;
/**
 * Get the singleton SessionManager instance.
 *
 * Creates the instance on first call. All subsequent calls return the same instance.
 * This is the ONLY way to access the SessionManager — do NOT construct directly.
 *
 * @returns The singleton SessionManager
 */
export function getSessionManager() {
    if (!_instance) {
        _instance = new SessionManager();
        console.error('[session-state] SessionManager initialized');
    }
    return _instance;
}
/**
 * Reset the singleton for testing. Shuts down the existing instance and
 * creates a fresh one on next getSessionManager() call.
 *
 * ONLY USE IN TESTS.
 */
export function resetSessionManagerForTesting() {
    if (_instance) {
        _instance.resetForTesting();
        _instance = null;
    }
}
export { SessionManager, OPERATION_TIMEOUT_MS };
//# sourceMappingURL=session-state.js.map