/**
 * Database Portability MCP Tools
 *
 * Backup, restore, and clone databases for portability and sharing.
 * Uses VACUUM INTO for atomic, consistent backups (no WAL/SHM issues).
 *
 * CRITICAL: NEVER use console.log() - stdout is reserved for JSON-RPC protocol.
 * Use console.error() for all logging.
 *
 * @module tools/portability
 */
import { z } from 'zod';
import { randomUUID } from 'crypto';
import Database from 'better-sqlite3';
import { existsSync, statSync, mkdirSync, writeFileSync, readFileSync, copyFileSync, readdirSync, unlinkSync, createWriteStream } from 'fs';
import { dirname, basename, join } from 'path';
import { state, selectDatabase, clearDatabase, getDefaultStoragePath, requireDatabase } from '../server/state.js';
import { successResult } from '../server/types.js';
import { validateInput, sanitizePath, ensureWritablePath } from '../utils/validation.js';
import { formatResponse, handleError } from './shared.js';
import { getDatabasePath, validateName, queryDatabaseCounts } from '../services/storage/database/helpers.js';
import { deleteDerivedRecords } from '../services/storage/database/document-operations.js';
import { DatabaseError, DatabaseErrorCode } from '../services/storage/database/types.js';
import { RegistryService } from '../services/storage/registry/index.js';
import { computeHash } from '../utils/hash.js';
// ═══════════════════════════════════════════════════════════════════════════════
// VALIDATION SCHEMAS
// ═══════════════════════════════════════════════════════════════════════════════
const DbBackupInput = z.object({
    database_name: z.string().min(1).max(64).regex(/^[a-zA-Z0-9_-]+$/).describe('Name of database to backup (uses current if not specified)').optional(),
    output_path: z.string().min(1).describe('Output path for backup file (.db extension)'),
    include_manifest: z.boolean().default(true).describe('Include JSON manifest with stats and tags for verification'),
});
const DbRestoreInput = z.object({
    input_path: z.string().min(1).describe('Path to backup .db file'),
    new_name: z.string().min(1).max(64).regex(/^[a-zA-Z0-9_-]+$/).optional().describe('Name for the restored database (derives from backup if not specified)'),
    verify_manifest: z.boolean().default(true).describe('Verify backup against manifest if available'),
    auto_select: z.boolean().default(false).describe('Automatically select the restored database after restore'),
});
const DbCloneInput = z.object({
    source_name: z.string().min(1).describe('Source database name to clone (uses current if not specified)').optional(),
    destination_name: z.string().min(1).max(64).regex(/^[a-zA-Z0-9_-]+$/).describe('Name for the cloned database'),
    copy_tags: z.boolean().default(true).describe('Copy tags from source to clone'),
    copy_metadata: z.boolean().default(true).describe('Copy metadata from source to clone'),
    auto_select: z.boolean().default(false).describe('Automatically select the cloned database'),
});
// ═══════════════════════════════════════════════════════════════════════════════
// HANDLER: ocr_db_backup
// ═══════════════════════════════════════════════════════════════════════════════
async function handleDbBackup(params) {
    try {
        const input = validateInput(DbBackupInput, params);
        // Resolve database name
        const dbName = input.database_name ?? state.currentDatabaseName;
        if (!dbName) {
            throw new DatabaseError('No database specified and no database currently selected. Provide database_name or select a database first.', DatabaseErrorCode.DATABASE_NOT_FOUND);
        }
        // Get source path and verify it exists
        const storagePath = getDefaultStoragePath();
        const sourcePath = getDatabasePath(dbName, storagePath);
        if (!existsSync(sourcePath)) {
            throw new DatabaseError(`Database file not found on disk: ${sourcePath}`, DatabaseErrorCode.DATABASE_NOT_FOUND);
        }
        // Sanitize and verify output path is writable (not on read-only /host mount)
        const safePath = ensureWritablePath(sanitizePath(input.output_path));
        // Check if output file already exists before attempting VACUUM INTO
        if (existsSync(safePath)) {
            throw new DatabaseError(`Backup output file already exists: ${safePath}. Choose a different filename or delete the existing file.`, DatabaseErrorCode.DATABASE_ALREADY_EXISTS);
        }
        // Create output directory
        mkdirSync(dirname(safePath), { recursive: true });
        // VACUUM INTO for atomic, consistent backup
        const sourceDb = new Database(sourcePath, { readonly: true });
        try {
            sourceDb.exec(`VACUUM INTO '${safePath.replace(/'/g, "''")}'`);
        }
        finally {
            sourceDb.close();
        }
        // Get backup file size
        const backupStat = statSync(safePath);
        const backupSizeBytes = backupStat.size;
        // Build manifest if requested
        let manifestPath = null;
        if (input.include_manifest) {
            const backupDb = new Database(safePath, { readonly: true });
            try {
                const counts = queryDatabaseCounts(backupDb);
                // Get registry info
                const registry = RegistryService.getInstance();
                const entry = registry.getDatabase(dbName);
                const manifest = {
                    version: 1,
                    created_at: new Date().toISOString(),
                    source_database: dbName,
                    document_count: counts.document_count,
                    chunk_count: counts.chunk_count,
                    embedding_count: counts.embedding_count,
                    backup_size_bytes: backupSizeBytes,
                    tags: entry?.tags ?? [],
                    metadata: entry?.metadata ?? {},
                    description: entry?.description ?? null,
                };
                manifestPath = safePath + '.manifest.json';
                writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
            }
            finally {
                backupDb.close();
            }
        }
        console.error(`[portability] Backup complete: ${dbName} -> ${safePath} (${backupSizeBytes} bytes)`);
        return formatResponse(successResult({
            database_name: dbName,
            backup_path: safePath,
            backup_size_bytes: backupSizeBytes,
            manifest_path: manifestPath,
            next_steps: [
                { tool: 'ocr_db_restore', description: 'Restore this backup to a new database' },
                { tool: 'ocr_db_clone', description: 'Clone a database locally' },
            ],
        }));
    }
    catch (error) {
        return handleError(error);
    }
}
// ═══════════════════════════════════════════════════════════════════════════════
// HANDLER: ocr_db_restore
// ═══════════════════════════════════════════════════════════════════════════════
async function handleDbRestore(params) {
    try {
        const input = validateInput(DbRestoreInput, params);
        // Sanitize and verify input path
        const safePath = sanitizePath(input.input_path);
        if (!existsSync(safePath)) {
            throw new DatabaseError(`Backup file not found: ${safePath}`, DatabaseErrorCode.DATABASE_NOT_FOUND);
        }
        // Integrity check
        const checkDb = new Database(safePath, { readonly: true });
        let counts;
        try {
            const integrityResult = checkDb.pragma('integrity_check');
            const integrityStatus = integrityResult[0]?.integrity_check ?? 'unknown';
            if (integrityStatus !== 'ok') {
                throw new DatabaseError(`Backup file failed integrity check: ${integrityStatus}`, DatabaseErrorCode.SCHEMA_MISMATCH);
            }
            // Query counts from backup
            counts = queryDatabaseCounts(checkDb);
        }
        finally {
            checkDb.close();
        }
        // Verify manifest if requested
        const manifestPath = safePath + '.manifest.json';
        let manifest = null;
        if (input.verify_manifest && existsSync(manifestPath)) {
            const manifestContent = readFileSync(manifestPath, 'utf-8');
            manifest = JSON.parse(manifestContent);
            // Compare counts - FAIL FAST on mismatch
            if (manifest.document_count !== counts.document_count) {
                throw new DatabaseError(`Manifest verification failed: document count mismatch. ` +
                    `Manifest: ${manifest.document_count}, Actual: ${counts.document_count}`, DatabaseErrorCode.SCHEMA_MISMATCH);
            }
            if (manifest.chunk_count !== counts.chunk_count) {
                throw new DatabaseError(`Manifest verification failed: chunk count mismatch. ` +
                    `Manifest: ${manifest.chunk_count}, Actual: ${counts.chunk_count}`, DatabaseErrorCode.SCHEMA_MISMATCH);
            }
            if (manifest.embedding_count !== counts.embedding_count) {
                throw new DatabaseError(`Manifest verification failed: embedding count mismatch. ` +
                    `Manifest: ${manifest.embedding_count}, Actual: ${counts.embedding_count}`, DatabaseErrorCode.SCHEMA_MISMATCH);
            }
        }
        // Determine target name
        let targetName;
        if (input.new_name) {
            targetName = input.new_name;
        }
        else if (manifest?.source_database) {
            targetName = manifest.source_database;
        }
        else {
            // Derive from filename: remove .db extension
            const base = basename(safePath);
            targetName = base.endsWith('.db') ? base.slice(0, -3) : base;
        }
        // Validate target name
        validateName(targetName);
        // Check registry AND filesystem for conflicts
        const storagePath = getDefaultStoragePath();
        const targetPath = getDatabasePath(targetName, storagePath);
        const registry = RegistryService.getInstance();
        if (registry.getDatabase(targetName)) {
            throw new DatabaseError(`Database '${targetName}' already exists in registry`, DatabaseErrorCode.DATABASE_ALREADY_EXISTS);
        }
        if (existsSync(targetPath)) {
            throw new DatabaseError(`Database file already exists on disk: ${targetPath}`, DatabaseErrorCode.DATABASE_ALREADY_EXISTS);
        }
        // Copy backup to target location
        mkdirSync(dirname(targetPath), { recursive: true });
        copyFileSync(safePath, targetPath);
        // Register in registry with manifest info
        const tags = manifest?.tags ?? [];
        const metadata = manifest?.metadata ?? {};
        const description = manifest?.description ?? null;
        registry.registerDatabase(targetName, targetPath, description ?? undefined, tags, metadata);
        // Sync stats
        const targetStat = statSync(targetPath);
        registry.syncStats(targetName, {
            document_count: counts.document_count,
            chunk_count: counts.chunk_count,
            embedding_count: counts.embedding_count,
            size_bytes: targetStat.size,
        });
        // Auto-select if requested
        if (input.auto_select) {
            selectDatabase(targetName);
        }
        console.error(`[portability] Restore complete: ${safePath} -> ${targetName} (${counts.document_count} docs, ${counts.chunk_count} chunks, ${counts.embedding_count} embeddings)`);
        return formatResponse(successResult({
            restored_name: targetName,
            restored_path: targetPath,
            document_count: counts.document_count,
            chunk_count: counts.chunk_count,
            embedding_count: counts.embedding_count,
            manifest_verified: manifest !== null,
            auto_selected: input.auto_select,
            tags,
            metadata,
            next_steps: [
                { tool: 'ocr_db_select', description: `Select the restored database "${targetName}"` },
                { tool: 'ocr_db_stats', description: 'Verify restored database statistics' },
                { tool: 'ocr_health_check', description: 'Run health check on the restored database' },
            ],
        }));
    }
    catch (error) {
        return handleError(error);
    }
}
// ═══════════════════════════════════════════════════════════════════════════════
// HANDLER: ocr_db_clone
// ═══════════════════════════════════════════════════════════════════════════════
async function handleDbClone(params) {
    try {
        const input = validateInput(DbCloneInput, params);
        // Resolve source name
        const sourceName = input.source_name ?? state.currentDatabaseName;
        if (!sourceName) {
            throw new DatabaseError('No source database specified and no database currently selected. Provide source_name or select a database first.', DatabaseErrorCode.DATABASE_NOT_FOUND);
        }
        // Validate destination name
        validateName(input.destination_name);
        // Check destination doesn't exist in BOTH registry AND filesystem
        const storagePath = getDefaultStoragePath();
        const registry = RegistryService.getInstance();
        if (registry.getDatabase(input.destination_name)) {
            throw new DatabaseError(`Database '${input.destination_name}' already exists in registry`, DatabaseErrorCode.DATABASE_ALREADY_EXISTS);
        }
        const destPath = getDatabasePath(input.destination_name, storagePath);
        if (existsSync(destPath)) {
            throw new DatabaseError(`Database file already exists on disk: ${destPath}`, DatabaseErrorCode.DATABASE_ALREADY_EXISTS);
        }
        // Get source path and verify it exists
        const sourcePath = getDatabasePath(sourceName, storagePath);
        if (!existsSync(sourcePath)) {
            throw new DatabaseError(`Source database file not found on disk: ${sourcePath}`, DatabaseErrorCode.DATABASE_NOT_FOUND);
        }
        // VACUUM INTO destination for atomic clone
        mkdirSync(dirname(destPath), { recursive: true });
        const sourceDb = new Database(sourcePath, { readonly: true });
        try {
            sourceDb.exec(`VACUUM INTO '${destPath.replace(/'/g, "''")}'`);
        }
        finally {
            sourceDb.close();
        }
        // Get registry info for source
        const sourceEntry = registry.getDatabase(sourceName);
        const tags = input.copy_tags ? (sourceEntry?.tags ?? []) : [];
        const metadata = input.copy_metadata ? (sourceEntry?.metadata ?? {}) : {};
        const description = sourceEntry?.description
            ? `${sourceEntry.description} (clone)`
            : `Clone of ${sourceName}`;
        // Register clone in registry
        registry.registerDatabase(input.destination_name, destPath, description, tags, metadata);
        // Query cloned DB for counts
        const clonedDb = new Database(destPath, { readonly: true });
        let counts;
        try {
            counts = queryDatabaseCounts(clonedDb);
        }
        finally {
            clonedDb.close();
        }
        // Sync stats
        const destStat = statSync(destPath);
        registry.syncStats(input.destination_name, {
            document_count: counts.document_count,
            chunk_count: counts.chunk_count,
            embedding_count: counts.embedding_count,
            size_bytes: destStat.size,
        });
        // Auto-select if requested
        if (input.auto_select) {
            selectDatabase(input.destination_name);
        }
        console.error(`[portability] Clone complete: ${sourceName} -> ${input.destination_name} (${counts.document_count} docs, ${counts.chunk_count} chunks, ${counts.embedding_count} embeddings)`);
        return formatResponse(successResult({
            source_name: sourceName,
            clone_name: input.destination_name,
            clone_path: destPath,
            document_count: counts.document_count,
            chunk_count: counts.chunk_count,
            embedding_count: counts.embedding_count,
            tags_copied: input.copy_tags,
            metadata_copied: input.copy_metadata,
            auto_selected: input.auto_select,
            next_steps: [
                { tool: 'ocr_db_select', description: `Select the cloned database "${input.destination_name}"` },
                { tool: 'ocr_db_stats', description: 'Verify cloned database statistics' },
                { tool: 'ocr_db_tag', description: 'Update tags on the clone' },
            ],
        }));
    }
    catch (error) {
        return handleError(error);
    }
}
// ═══════════════════════════════════════════════════════════════════════════════
// VALIDATION SCHEMAS (Phase 2 - Data Exchange)
// ═══════════════════════════════════════════════════════════════════════════════
const DbImportInput = z.object({
    input_path: z.string().min(1).describe('Path to exported JSON file (from ocr_export corpus format)'),
    skip_duplicates: z.boolean().default(true).describe('Skip documents with matching file_hash'),
    import_source: z.string().max(200).optional().describe('Label for import source (e.g., "usb-backup", "team-share")'),
});
const DbMergeInput = z.object({
    source_name: z.string().min(1).describe('Source database to merge FROM'),
    on_duplicate: z.enum(['skip', 'replace', 'keep_both']).default('skip').describe('How to handle documents with matching file_hash'),
    dry_run: z.boolean().default(false).describe('Preview merge without making changes'),
});
const DbSnapshotInput = z.object({
    action: z.enum(['create', 'list', 'restore', 'delete']).describe('Snapshot operation'),
    snapshot_name: z.string().min(1).max(64).regex(/^[a-zA-Z0-9_-]+$/).optional().describe('Snapshot name (required for create/restore/delete)'),
    database_name: z.string().min(1).max(64).regex(/^[a-zA-Z0-9_-]+$/).optional().describe('Database name (uses current if not specified)'),
});
// ═══════════════════════════════════════════════════════════════════════════════
// HANDLER: ocr_db_import
// ═══════════════════════════════════════════════════════════════════════════════
async function handleDbImport(params) {
    try {
        const input = validateInput(DbImportInput, params);
        const { db } = requireDatabase();
        // Sanitize and verify input path
        const safePath = sanitizePath(input.input_path);
        if (!existsSync(safePath)) {
            throw new DatabaseError(`Import file not found: ${safePath}`, DatabaseErrorCode.DATABASE_NOT_FOUND);
        }
        // Read and parse JSON export file
        const rawContent = readFileSync(safePath, 'utf-8');
        let exportData;
        try {
            exportData = JSON.parse(rawContent);
        }
        catch {
            throw new DatabaseError(`Failed to parse import file as JSON: ${safePath}`, DatabaseErrorCode.SCHEMA_MISMATCH);
        }
        // Validate: must be an array
        if (!Array.isArray(exportData)) {
            throw new DatabaseError('Import file must contain a JSON array of documents. Got: ' + typeof exportData, DatabaseErrorCode.SCHEMA_MISMATCH);
        }
        // Validate each item
        for (let i = 0; i < exportData.length; i++) {
            const item = exportData[i];
            if (!item || typeof item !== 'object') {
                throw new DatabaseError(`Import item at index ${i} is not an object`, DatabaseErrorCode.SCHEMA_MISMATCH);
            }
            if (typeof item.file_name !== 'string' || !item.file_name) {
                throw new DatabaseError(`Import item at index ${i} missing required string field "file_name"`, DatabaseErrorCode.SCHEMA_MISMATCH);
            }
            if (typeof item.file_hash !== 'string' || !item.file_hash) {
                throw new DatabaseError(`Import item at index ${i} missing required string field "file_hash"`, DatabaseErrorCode.SCHEMA_MISMATCH);
            }
            // Normalize missing chunks to empty array (ocr_export defaults include_chunks=false)
            if (!Array.isArray(item.chunks)) {
                item.chunks = [];
            }
        }
        const conn = db.getConnection();
        const now = new Date().toISOString();
        let documentsImported = 0;
        let documentsSkipped = 0;
        let chunksImported = 0;
        const insertDocs = conn.transaction(() => {
            for (const doc of exportData) {
                // Check for duplicate by file_hash
                if (input.skip_duplicates) {
                    const existing = conn.prepare('SELECT id FROM documents WHERE file_hash = ?').get(doc.file_hash);
                    if (existing) {
                        documentsSkipped++;
                        continue;
                    }
                }
                const docId = randomUUID();
                const provId = randomUUID();
                // Create provenance record for import traceability
                conn.prepare(`INSERT INTO provenance (
          id, type, created_at, processed_at, source_type, source_path, source_id,
          root_document_id, content_hash, file_hash, processor, processor_version,
          processing_params, parent_ids, chain_depth
        ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(provId, 'DOCUMENT', now, now, 'FILE', safePath, null, docId, computeHash(String(doc.file_hash)), doc.file_hash, 'import', '1.0.0', JSON.stringify({ import_source: input.import_source ?? null, skip_duplicates: input.skip_duplicates }), '[]', 0);
                // Insert document
                conn.prepare(`INSERT INTO documents (
          id, file_path, file_name, file_hash, file_size, file_type, status,
          page_count, provenance_id, created_at, doc_title, doc_author, doc_subject
        ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(docId, doc.file_path ?? '', doc.file_name, doc.file_hash, doc.file_size ?? 0, doc.file_type ?? 'unknown', 'complete', doc.page_count ?? null, provId, doc.created_at ?? now, doc.doc_title ?? null, doc.doc_author ?? null, doc.doc_subject ?? null);
                // We need an ocr_result_id for chunks FK. Create a minimal OCR result record.
                const ocrResultId = randomUUID();
                const ocrProvId = randomUUID();
                conn.prepare(`INSERT INTO provenance (
          id, type, created_at, processed_at, source_type, source_id,
          root_document_id, content_hash, processor, processor_version,
          processing_params, parent_id, parent_ids, chain_depth
        ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(ocrProvId, 'OCR_RESULT', now, now, 'OCR', provId, docId, computeHash('import-ocr-' + docId), 'import', '1.0.0', '{}', provId, JSON.stringify([provId]), 1);
                conn.prepare(`INSERT INTO ocr_results (
          id, provenance_id, document_id, datalab_mode, parse_quality_score,
          page_count, text_length, extracted_text, cost_cents, processing_duration_ms,
          datalab_request_id, content_hash, processing_started_at, processing_completed_at
        ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(ocrResultId, ocrProvId, docId, 'balanced', 0, doc.page_count ?? 0, 0, '', 0, 0, 'import-' + ocrResultId, computeHash('import-ocr-' + docId), now, now);
                // Insert chunks
                const chunks = doc.chunks;
                for (let ci = 0; ci < chunks.length; ci++) {
                    const chunk = chunks[ci];
                    const chunkId = randomUUID();
                    const chunkProvId = randomUUID();
                    const chunkText = chunk.text ?? '';
                    const chunkIndex = chunk.chunk_index ?? ci;
                    conn.prepare(`INSERT INTO provenance (
            id, type, created_at, processed_at, source_type, source_id,
            root_document_id, content_hash, processor, processor_version,
            processing_params, parent_id, parent_ids, chain_depth
          ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(chunkProvId, 'CHUNK', now, now, 'CHUNKING', ocrProvId, docId, computeHash(chunkText), 'import', '1.0.0', '{}', ocrProvId, JSON.stringify([provId, ocrProvId]), 2);
                    conn.prepare(`INSERT INTO chunks (
            id, document_id, ocr_result_id, text, text_hash, chunk_index,
            character_start, character_end, page_number, page_range,
            overlap_previous, overlap_next, provenance_id, created_at,
            embedding_status, heading_context, section_path, content_types
          ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(chunkId, docId, ocrResultId, chunkText, computeHash(chunkText), chunkIndex, chunk.character_start ?? 0, chunk.character_end ?? chunkText.length, chunk.page_number ?? null, null, 0, 0, chunkProvId, now, 'pending', chunk.heading_context ?? null, chunk.section_path ?? null, chunk.content_types ?? null);
                    chunksImported++;
                }
                documentsImported++;
            }
            // Rebuild FTS5 index inside transaction for atomicity
            conn.exec("INSERT INTO chunks_fts(chunks_fts) VALUES('rebuild')");
        });
        insertDocs();
        console.error(`[portability] Import complete: ${safePath} -> ${documentsImported} docs, ${chunksImported} chunks (${documentsSkipped} skipped)`);
        return formatResponse(successResult({
            documents_imported: documentsImported,
            documents_skipped: documentsSkipped,
            chunks_imported: chunksImported,
            total_in_export: exportData.length,
            import_source: input.import_source ?? null,
            next_steps: [
                { tool: 'ocr_embedding_rebuild', description: 'Generate embeddings for imported documents' },
                { tool: 'ocr_health_check', description: 'Run health check on the database' },
                { tool: 'ocr_db_stats', description: 'View updated database statistics' },
            ],
        }));
    }
    catch (error) {
        return handleError(error);
    }
}
// ═══════════════════════════════════════════════════════════════════════════════
// HANDLER: ocr_db_merge
// ═══════════════════════════════════════════════════════════════════════════════
async function handleDbMerge(params) {
    try {
        const input = validateInput(DbMergeInput, params);
        const { db } = requireDatabase();
        // Resolve source path and verify it exists
        const storagePath = getDefaultStoragePath();
        const sourcePath = getDatabasePath(input.source_name, storagePath);
        if (!existsSync(sourcePath)) {
            throw new DatabaseError(`Source database file not found: ${sourcePath}`, DatabaseErrorCode.DATABASE_NOT_FOUND);
        }
        // Open source database read-only
        const sourceDb = new Database(sourcePath, { readonly: true });
        let documentsMerged = 0;
        let documentsSkipped = 0;
        let documentsReplaced = 0;
        let chunksMerged = 0;
        let sourceDocumentCount = 0;
        try {
            // Query source for all documents
            const sourceDocs = sourceDb.prepare('SELECT * FROM documents').all();
            sourceDocumentCount = sourceDocs.length;
            // Query source for all chunks indexed by document_id
            const sourceChunksAll = sourceDb.prepare('SELECT * FROM chunks').all();
            const sourceChunksByDocId = new Map();
            for (const chunk of sourceChunksAll) {
                const docId = chunk.document_id;
                if (!sourceChunksByDocId.has(docId)) {
                    sourceChunksByDocId.set(docId, []);
                }
                sourceChunksByDocId.get(docId).push(chunk);
            }
            const conn = db.getConnection();
            const now = new Date().toISOString();
            const mergeTransaction = conn.transaction(() => {
                for (const srcDoc of sourceDocs) {
                    const fileHash = srcDoc.file_hash;
                    // Check target for matching file_hash
                    const existing = conn.prepare('SELECT id FROM documents WHERE file_hash = ?').get(fileHash);
                    if (existing) {
                        if (input.on_duplicate === 'skip') {
                            documentsSkipped++;
                            continue;
                        }
                        else if (input.on_duplicate === 'replace') {
                            // Delete derived records (entity_tags, document_clusters, embeddings, images, chunks, etc.)
                            // Note: deleteDerivedRecords does NOT delete clusters themselves or provenance
                            deleteDerivedRecords(conn, existing.id, 'merge-replace');
                            // Delete document itself
                            conn.prepare('DELETE FROM documents WHERE id = ?').run(existing.id);
                            // Clean up clusters/comparisons whose provenance_id is in this document's provenance chain
                            conn.prepare('DELETE FROM document_clusters WHERE cluster_id IN (SELECT id FROM clusters WHERE provenance_id IN (SELECT id FROM provenance WHERE root_document_id = ?))').run(existing.id);
                            conn.prepare('DELETE FROM clusters WHERE provenance_id IN (SELECT id FROM provenance WHERE root_document_id = ?)').run(existing.id);
                            conn.prepare('DELETE FROM comparisons WHERE provenance_id IN (SELECT id FROM provenance WHERE root_document_id = ?)').run(existing.id);
                            // Break provenance self-referencing FKs (parent_id + source_id), then delete
                            conn.prepare('UPDATE provenance SET parent_id = NULL, source_id = NULL WHERE root_document_id = ?').run(existing.id);
                            conn.prepare('DELETE FROM provenance WHERE root_document_id = ?').run(existing.id);
                            documentsReplaced++;
                        }
                        // 'keep_both': fall through to INSERT with new UUID
                    }
                    const newDocId = randomUUID();
                    const provId = randomUUID();
                    // Create provenance for merge
                    conn.prepare(`INSERT INTO provenance (
            id, type, created_at, processed_at, source_type, source_path,
            root_document_id, content_hash, file_hash, processor, processor_version,
            processing_params, parent_ids, chain_depth
          ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(provId, 'DOCUMENT', now, now, 'FILE', sourcePath, newDocId, computeHash(String(fileHash)), fileHash, 'merge', '1.0.0', JSON.stringify({ source_name: input.source_name, on_duplicate: input.on_duplicate }), '[]', 0);
                    // Insert document with new ID
                    conn.prepare(`INSERT INTO documents (
            id, file_path, file_name, file_hash, file_size, file_type, status,
            page_count, provenance_id, created_at, modified_at, ocr_completed_at,
            doc_title, doc_author, doc_subject
          ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(newDocId, srcDoc.file_path ?? '', srcDoc.file_name ?? '', fileHash, srcDoc.file_size ?? 0, srcDoc.file_type ?? 'unknown', srcDoc.status ?? 'complete', srcDoc.page_count ?? null, provId, srcDoc.created_at ?? now, srcDoc.modified_at ?? null, srcDoc.ocr_completed_at ?? null, srcDoc.doc_title ?? null, srcDoc.doc_author ?? null, srcDoc.doc_subject ?? null);
                    // Create a minimal ocr_result for chunk FK
                    const ocrResultId = randomUUID();
                    const ocrProvId = randomUUID();
                    conn.prepare(`INSERT INTO provenance (
            id, type, created_at, processed_at, source_type, source_id,
            root_document_id, content_hash, processor, processor_version,
            processing_params, parent_id, parent_ids, chain_depth
          ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(ocrProvId, 'OCR_RESULT', now, now, 'OCR', provId, newDocId, computeHash('merge-ocr-' + newDocId), 'merge', '1.0.0', '{}', provId, JSON.stringify([provId]), 1);
                    conn.prepare(`INSERT INTO ocr_results (
            id, provenance_id, document_id, datalab_mode, parse_quality_score,
            page_count, text_length, extracted_text, cost_cents, processing_duration_ms,
            datalab_request_id, content_hash, processing_started_at, processing_completed_at
          ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(ocrResultId, ocrProvId, newDocId, 'balanced', 0, srcDoc.page_count ?? 0, 0, '', 0, 0, 'merge-' + ocrResultId, computeHash('merge-ocr-' + newDocId), now, now);
                    // Insert chunks belonging to this doc
                    const srcChunks = sourceChunksByDocId.get(srcDoc.id) ?? [];
                    for (const srcChunk of srcChunks) {
                        const newChunkId = randomUUID();
                        const chunkProvId = randomUUID();
                        const chunkText = srcChunk.text ?? '';
                        conn.prepare(`INSERT INTO provenance (
              id, type, created_at, processed_at, source_type, source_id,
              root_document_id, content_hash, processor, processor_version,
              processing_params, parent_id, parent_ids, chain_depth
            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(chunkProvId, 'CHUNK', now, now, 'CHUNKING', ocrProvId, newDocId, computeHash(chunkText), 'merge', '1.0.0', '{}', ocrProvId, JSON.stringify([provId, ocrProvId]), 2);
                        conn.prepare(`INSERT INTO chunks (
              id, document_id, ocr_result_id, text, text_hash, chunk_index,
              character_start, character_end, page_number, page_range,
              overlap_previous, overlap_next, provenance_id, created_at,
              embedding_status, heading_context, section_path, content_types,
              ocr_quality_score, heading_level, is_atomic, chunking_strategy
            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(newChunkId, newDocId, ocrResultId, chunkText, srcChunk.text_hash ?? computeHash(chunkText), srcChunk.chunk_index ?? 0, srcChunk.character_start ?? 0, srcChunk.character_end ?? chunkText.length, srcChunk.page_number ?? null, srcChunk.page_range ?? null, srcChunk.overlap_previous ?? 0, srcChunk.overlap_next ?? 0, chunkProvId, srcChunk.created_at ?? now, 'pending', srcChunk.heading_context ?? null, srcChunk.section_path ?? null, srcChunk.content_types ?? null, srcChunk.ocr_quality_score ?? null, srcChunk.heading_level ?? null, srcChunk.is_atomic ?? 0, srcChunk.chunking_strategy ?? 'hybrid_section');
                        chunksMerged++;
                    }
                    documentsMerged++;
                }
                // Rebuild FTS5 index inside transaction for atomicity
                conn.exec("INSERT INTO chunks_fts(chunks_fts) VALUES('rebuild')");
            });
            if (input.dry_run) {
                conn.exec('BEGIN');
                try {
                    mergeTransaction();
                }
                finally {
                    conn.exec('ROLLBACK');
                }
            }
            else {
                mergeTransaction();
            }
        }
        finally {
            sourceDb.close();
        }
        console.error(`[portability] Merge ${input.dry_run ? '(dry run) ' : ''}complete: ${input.source_name} -> ${documentsMerged} merged, ${documentsSkipped} skipped, ${documentsReplaced} replaced, ${chunksMerged} chunks`);
        return formatResponse(successResult({
            dry_run: input.dry_run,
            source_name: input.source_name,
            documents_merged: documentsMerged,
            documents_skipped: documentsSkipped,
            documents_replaced: documentsReplaced,
            chunks_merged: chunksMerged,
            source_document_count: sourceDocumentCount,
            next_steps: input.dry_run
                ? [{ tool: 'ocr_db_merge', description: 'Run merge without dry_run to apply changes' }]
                : [
                    { tool: 'ocr_embedding_rebuild', description: 'Generate embeddings for merged documents' },
                    { tool: 'ocr_health_check', description: 'Run health check after merge' },
                    { tool: 'ocr_db_stats', description: 'View updated database statistics' },
                ],
        }));
    }
    catch (error) {
        return handleError(error);
    }
}
// ═══════════════════════════════════════════════════════════════════════════════
// HANDLER: ocr_db_snapshot
// ═══════════════════════════════════════════════════════════════════════════════
async function handleDbSnapshot(params) {
    try {
        const input = validateInput(DbSnapshotInput, params);
        const storagePath = getDefaultStoragePath();
        if (input.action === 'create') {
            // Resolve database name
            const dbName = input.database_name ?? state.currentDatabaseName;
            if (!dbName) {
                throw new DatabaseError('No database specified and no database currently selected.', DatabaseErrorCode.DATABASE_NOT_FOUND);
            }
            if (!input.snapshot_name) {
                throw new DatabaseError('snapshot_name is required for create action', DatabaseErrorCode.SCHEMA_MISMATCH);
            }
            const sourcePath = getDatabasePath(dbName, storagePath);
            if (!existsSync(sourcePath)) {
                throw new DatabaseError(`Database file not found: ${sourcePath}`, DatabaseErrorCode.DATABASE_NOT_FOUND);
            }
            // Create snapshots directory
            const snapshotsDir = join(storagePath, '.snapshots', dbName);
            mkdirSync(snapshotsDir, { recursive: true });
            const snapshotPath = join(snapshotsDir, `${input.snapshot_name}.db`);
            const metaPath = join(snapshotsDir, `${input.snapshot_name}.meta.json`);
            // Check snapshot doesn't already exist
            if (existsSync(snapshotPath)) {
                throw new DatabaseError(`Snapshot "${input.snapshot_name}" already exists for database "${dbName}"`, DatabaseErrorCode.DATABASE_ALREADY_EXISTS);
            }
            // VACUUM INTO snapshot path
            const sourceDb = new Database(sourcePath, { readonly: true });
            try {
                sourceDb.exec(`VACUUM INTO '${snapshotPath.replace(/'/g, "''")}'`);
            }
            finally {
                sourceDb.close();
            }
            // Query snapshot for counts
            const snapDb = new Database(snapshotPath, { readonly: true });
            let counts;
            try {
                counts = queryDatabaseCounts(snapDb);
            }
            finally {
                snapDb.close();
            }
            const snapStat = statSync(snapshotPath);
            // Write metadata JSON
            const metadata = {
                snapshot_name: input.snapshot_name,
                database_name: dbName,
                created_at: new Date().toISOString(),
                document_count: counts.document_count,
                chunk_count: counts.chunk_count,
                embedding_count: counts.embedding_count,
                size_bytes: snapStat.size,
            };
            writeFileSync(metaPath, JSON.stringify(metadata, null, 2));
            console.error(`[portability] Snapshot created: ${dbName}/${input.snapshot_name} (${counts.document_count} docs, ${counts.chunk_count} chunks)`);
            return formatResponse(successResult({
                action: 'create',
                snapshot_name: input.snapshot_name,
                database_name: dbName,
                snapshot_path: snapshotPath,
                ...counts,
                size_bytes: snapStat.size,
                next_steps: [
                    { tool: 'ocr_db_snapshot', description: 'List all snapshots with action="list"' },
                    { tool: 'ocr_db_snapshot', description: 'Restore this snapshot with action="restore"' },
                ],
            }));
        }
        else if (input.action === 'list') {
            const dbName = input.database_name ?? state.currentDatabaseName;
            if (!dbName) {
                throw new DatabaseError('No database specified and no database currently selected.', DatabaseErrorCode.DATABASE_NOT_FOUND);
            }
            const snapshotsDir = join(storagePath, '.snapshots', dbName);
            const snapshots = [];
            if (existsSync(snapshotsDir)) {
                const files = readdirSync(snapshotsDir).filter((f) => f.endsWith('.meta.json'));
                for (const file of files) {
                    const metaContent = readFileSync(join(snapshotsDir, file), 'utf-8');
                    try {
                        const meta = JSON.parse(metaContent);
                        snapshots.push(meta);
                    }
                    catch {
                        console.error(`[portability] Failed to parse snapshot metadata: ${file}`);
                    }
                }
            }
            // Sort by created_at descending
            snapshots.sort((a, b) => {
                const aDate = a.created_at ?? '';
                const bDate = b.created_at ?? '';
                return bDate.localeCompare(aDate);
            });
            return formatResponse(successResult({
                action: 'list',
                database_name: dbName,
                snapshot_count: snapshots.length,
                snapshots,
                next_steps: snapshots.length > 0
                    ? [
                        { tool: 'ocr_db_snapshot', description: 'Create a new snapshot with action="create"' },
                        { tool: 'ocr_db_snapshot', description: 'Restore a snapshot with action="restore"' },
                    ]
                    : [{ tool: 'ocr_db_snapshot', description: 'Create a snapshot with action="create"' }],
            }));
        }
        else if (input.action === 'restore') {
            const dbName = input.database_name ?? state.currentDatabaseName;
            if (!dbName) {
                throw new DatabaseError('No database specified and no database currently selected.', DatabaseErrorCode.DATABASE_NOT_FOUND);
            }
            if (!input.snapshot_name) {
                throw new DatabaseError('snapshot_name is required for restore action', DatabaseErrorCode.SCHEMA_MISMATCH);
            }
            const snapshotsDir = join(storagePath, '.snapshots', dbName);
            const snapshotPath = join(snapshotsDir, `${input.snapshot_name}.db`);
            if (!existsSync(snapshotPath)) {
                throw new DatabaseError(`Snapshot "${input.snapshot_name}" not found for database "${dbName}"`, DatabaseErrorCode.DATABASE_NOT_FOUND);
            }
            const dbPath = getDatabasePath(dbName, storagePath);
            // Create safety backup before restore
            const timestamp = new Date().toISOString().replace(/:/g, '-');
            const preRestorePath = join(snapshotsDir, `_pre_restore_${timestamp}.db`);
            if (existsSync(dbPath)) {
                const backupDb = new Database(dbPath, { readonly: true });
                try {
                    backupDb.exec(`VACUUM INTO '${preRestorePath.replace(/'/g, "''")}'`);
                }
                finally {
                    backupDb.close();
                }
            }
            // If this is the currently selected database, close the connection
            const wasSelected = state.currentDatabaseName === dbName;
            if (wasSelected) {
                clearDatabase();
            }
            // Copy snapshot file over current database file
            copyFileSync(snapshotPath, dbPath);
            // Re-select if it was the active database
            if (wasSelected) {
                selectDatabase(dbName);
            }
            // Query restored database for counts
            const restoredDb = new Database(dbPath, { readonly: true });
            let counts;
            try {
                counts = queryDatabaseCounts(restoredDb);
            }
            finally {
                restoredDb.close();
            }
            console.error(`[portability] Snapshot restored: ${dbName}/${input.snapshot_name} (${counts.document_count} docs)`);
            return formatResponse(successResult({
                action: 'restore',
                snapshot_name: input.snapshot_name,
                database_name: dbName,
                pre_restore_backup: preRestorePath,
                ...counts,
                next_steps: [
                    { tool: 'ocr_db_stats', description: 'Verify restored database statistics' },
                    { tool: 'ocr_health_check', description: 'Run health check after restore' },
                ],
            }));
        }
        else if (input.action === 'delete') {
            const dbName = input.database_name ?? state.currentDatabaseName;
            if (!dbName) {
                throw new DatabaseError('No database specified and no database currently selected.', DatabaseErrorCode.DATABASE_NOT_FOUND);
            }
            if (!input.snapshot_name) {
                throw new DatabaseError('snapshot_name is required for delete action', DatabaseErrorCode.SCHEMA_MISMATCH);
            }
            const snapshotsDir = join(storagePath, '.snapshots', dbName);
            const snapshotPath = join(snapshotsDir, `${input.snapshot_name}.db`);
            const metaPath = join(snapshotsDir, `${input.snapshot_name}.meta.json`);
            if (!existsSync(snapshotPath)) {
                throw new DatabaseError(`Snapshot "${input.snapshot_name}" not found for database "${dbName}"`, DatabaseErrorCode.DATABASE_NOT_FOUND);
            }
            unlinkSync(snapshotPath);
            if (existsSync(metaPath)) {
                unlinkSync(metaPath);
            }
            console.error(`[portability] Snapshot deleted: ${dbName}/${input.snapshot_name}`);
            return formatResponse(successResult({
                action: 'delete',
                snapshot_name: input.snapshot_name,
                database_name: dbName,
                next_steps: [
                    { tool: 'ocr_db_snapshot', description: 'List remaining snapshots with action="list"' },
                ],
            }));
        }
        // Should never reach here due to zod enum validation, but satisfy TypeScript
        throw new DatabaseError('Invalid snapshot action', DatabaseErrorCode.SCHEMA_MISMATCH);
    }
    catch (error) {
        return handleError(error);
    }
}
// ═══════════════════════════════════════════════════════════════════════════════
// STREAMING EXPORT
// ═══════════════════════════════════════════════════════════════════════════════
const DbExportStreamInput = z.object({
    output_path: z.string().min(1).describe('Output file path for JSON-Lines export (.jsonl extension)'),
    include_chunks: z.boolean().default(true).describe('Include chunks in each document record'),
    batch_size: z.number().int().min(10).max(1000).default(100).describe('Documents per batch query'),
    since: z.string().datetime().optional().describe('Only export documents created after this timestamp'),
});
/**
 * Handle ocr_export_stream - Export documents in JSON-Lines format for large databases.
 * Uses batched queries to handle databases with 100K+ documents.
 */
async function handleExportStream(params) {
    try {
        const input = validateInput(DbExportStreamInput, params);
        const { db } = requireDatabase();
        const conn = db.getConnection();
        const batchSize = input.batch_size ?? 100;
        const safePath = ensureWritablePath(sanitizePath(input.output_path));
        // Create output directory if needed
        const dir = dirname(safePath);
        if (!existsSync(dir)) {
            mkdirSync(dir, { recursive: true });
        }
        // Count total documents
        const countSql = input.since
            ? 'SELECT COUNT(*) as count FROM documents WHERE created_at > ?'
            : 'SELECT COUNT(*) as count FROM documents';
        const countRow = input.since
            ? conn.prepare(countSql).get(input.since)
            : conn.prepare(countSql).get();
        const totalDocuments = countRow.count;
        if (totalDocuments === 0) {
            return formatResponse(successResult({
                output_path: safePath,
                total_documents: 0,
                total_chunks: 0,
                file_size_bytes: 0,
                batches_processed: 0,
                ...(input.since ? { since: input.since } : {}),
            }));
        }
        // Prepare batch query
        const docSql = input.since
            ? 'SELECT * FROM documents WHERE created_at > ? ORDER BY rowid LIMIT ? OFFSET ?'
            : 'SELECT * FROM documents ORDER BY rowid LIMIT ? OFFSET ?';
        const docStmt = conn.prepare(docSql);
        const chunkStmt = input.include_chunks
            ? conn.prepare('SELECT id, chunk_index, text, page_number, character_start, character_end, heading_context, section_path, content_types FROM chunks WHERE document_id = ? ORDER BY chunk_index')
            : null;
        const stream = createWriteStream(safePath, { encoding: 'utf-8' });
        let streamError = null;
        stream.on('error', (err) => { streamError = err; });
        let totalChunks = 0;
        let batchesProcessed = 0;
        let offset = 0;
        try {
            while (offset < totalDocuments) {
                const rows = input.since
                    ? docStmt.all(input.since, batchSize, offset)
                    : docStmt.all(batchSize, offset);
                if (rows.length === 0)
                    break;
                for (const row of rows) {
                    const docObject = {
                        id: row.id,
                        file_path: row.file_path,
                        file_name: row.file_name,
                        file_type: row.file_type,
                        file_size: row.file_size,
                        file_hash: row.file_hash,
                        status: row.status,
                        page_count: row.page_count,
                        doc_title: row.doc_title ?? null,
                        doc_author: row.doc_author ?? null,
                        doc_subject: row.doc_subject ?? null,
                        created_at: row.created_at,
                    };
                    if (chunkStmt) {
                        const chunks = chunkStmt.all(row.id);
                        totalChunks += chunks.length;
                        docObject.chunks = chunks.map((c) => ({
                            id: c.id,
                            chunk_index: c.chunk_index,
                            text: c.text,
                            page_number: c.page_number,
                            character_start: c.character_start ?? 0,
                            character_end: c.character_end ?? (c.text?.length ?? 0),
                            heading_context: c.heading_context ?? null,
                            section_path: c.section_path ?? null,
                            content_types: c.content_types ?? null,
                        }));
                    }
                    stream.write(JSON.stringify(docObject) + '\n');
                }
                batchesProcessed++;
                offset += batchSize;
            }
            if (streamError)
                throw streamError;
            await new Promise((resolve, reject) => {
                stream.end(() => {
                    if (streamError)
                        reject(streamError);
                    else
                        resolve();
                });
            });
        }
        catch (err) {
            stream.destroy();
            throw err;
        }
        const fileStat = statSync(safePath);
        const result = {
            output_path: safePath,
            total_documents: totalDocuments,
            total_chunks: totalChunks,
            file_size_bytes: fileStat.size,
            batches_processed: batchesProcessed,
        };
        if (input.since) {
            result.since = input.since;
        }
        return formatResponse(successResult(result));
    }
    catch (error) {
        return handleError(error);
    }
}
// ═══════════════════════════════════════════════════════════════════════════════
// TOOL DEFINITIONS FOR MCP REGISTRATION
// ═══════════════════════════════════════════════════════════════════════════════
export const portabilityTools = {
    ocr_db_backup: {
        description: '[PORTABILITY] Create a portable backup of a database. Uses VACUUM INTO for atomic, consistent copies. Optionally generates a JSON manifest for verification.',
        inputSchema: DbBackupInput.shape,
        handler: handleDbBackup,
    },
    ocr_db_restore: {
        description: '[PORTABILITY] Restore a database from a backup file. Runs integrity check, verifies manifest if available, and registers in the database registry.',
        inputSchema: DbRestoreInput.shape,
        handler: handleDbRestore,
    },
    ocr_db_clone: {
        description: '[PORTABILITY] Clone a database to a new name. Uses VACUUM INTO for atomic copy. Optionally copies tags and metadata from the source.',
        inputSchema: DbCloneInput.shape,
        handler: handleDbClone,
    },
    ocr_db_import: {
        description: '[PORTABILITY] Import documents from a JSON corpus export into the current database. Creates provenance records for import traceability.',
        inputSchema: DbImportInput.shape,
        handler: handleDbImport,
    },
    ocr_db_merge: {
        description: '[PORTABILITY] Merge all documents from a source database into the current database. Deduplicates by file_hash.',
        inputSchema: DbMergeInput.shape,
        handler: handleDbMerge,
    },
    ocr_db_snapshot: {
        description: '[PORTABILITY] Create, list, restore, or delete named point-in-time snapshots of a database.',
        inputSchema: DbSnapshotInput.shape,
        handler: handleDbSnapshot,
    },
    ocr_export_stream: {
        description: '[PORTABILITY] Export documents in JSON-Lines format (one JSON object per line) for large databases. Uses batched queries to handle databases with 100K+ documents.',
        inputSchema: DbExportStreamInput.shape,
        handler: handleExportStream,
    },
};
//# sourceMappingURL=portability.js.map