/**
 * VectorService - sqlite-vec vector storage and similarity search
 *
 * Handles vector CRUD operations on vec_embeddings virtual table.
 * Search results are self-contained per CP-002 (original text always included).
 *
 * Uses vec_distance_cosine() for all similarity calculations to ensure
 * consistent cosine similarity scoring regardless of underlying index configuration.
 *
 * @module services/storage/vector
 */
import { createRequire } from 'module';
import { computeQualityMultiplier } from '../search/quality.js';
const require = createRequire(import.meta.url);
// ═══════════════════════════════════════════════════════════════════════════════
// ERROR HANDLING
// ═══════════════════════════════════════════════════════════════════════════════
/**
 * Error codes for vector operations
 */
var VectorErrorCode;
(function (VectorErrorCode) {
    VectorErrorCode["INVALID_VECTOR_DIMENSIONS"] = "INVALID_VECTOR_DIMENSIONS";
    VectorErrorCode["EMBEDDING_NOT_FOUND"] = "EMBEDDING_NOT_FOUND";
    VectorErrorCode["VEC_EXTENSION_NOT_LOADED"] = "VEC_EXTENSION_NOT_LOADED";
    VectorErrorCode["STORE_FAILED"] = "STORE_FAILED";
    VectorErrorCode["SEARCH_FAILED"] = "SEARCH_FAILED";
    VectorErrorCode["DELETE_FAILED"] = "DELETE_FAILED";
})(VectorErrorCode || (VectorErrorCode = {}));
/**
 * Custom error class for vector operations
 * Includes error code and optional details for debugging
 */
export class VectorError extends Error {
    code;
    details;
    constructor(message, code, details) {
        super(message);
        this.code = code;
        this.details = details;
        this.name = 'VectorError';
    }
}
// ═══════════════════════════════════════════════════════════════════════════════
// VECTOR SERVICE
// ═══════════════════════════════════════════════════════════════════════════════
/**
 * VectorService - handles vector storage and similarity search
 *
 * Wraps sqlite-vec operations for 768-dimensional vectors.
 * All search results are self-contained with original text per CP-002.
 */
export class VectorService {
    db;
    vecLoaded = false;
    /**
     * Create a new VectorService
     *
     * @param db - Better-sqlite3 database connection with schema already initialized
     * @throws VectorError if sqlite-vec extension cannot be loaded
     */
    constructor(db) {
        this.db = db;
        this.ensureVecExtension();
    }
    /**
     * Ensure sqlite-vec extension is loaded
     * FAIL FAST if not available - no workarounds
     */
    ensureVecExtension() {
        if (this.vecLoaded)
            return;
        try {
            const sqliteVec = require('sqlite-vec');
            sqliteVec.load(this.db);
            this.vecLoaded = true;
        }
        catch (error) {
            throw new VectorError('sqlite-vec extension failed to load. Install: npm install sqlite-vec', VectorErrorCode.VEC_EXTENSION_NOT_LOADED, { error: String(error) });
        }
    }
    // ═══════════════════════════════════════════════════════════════════════════
    // STORE OPERATIONS
    // ═══════════════════════════════════════════════════════════════════════════
    /**
     * Store a vector in vec_embeddings
     *
     * PREREQUISITE: Embedding record must already exist in embeddings table
     * (inserted via DatabaseService.insertEmbedding)
     *
     * @param embeddingId - Must match an existing embeddings.id
     * @param vector - 768-dimensional Float32Array
     * @throws VectorError if dimensions != 768 or embedding doesn't exist
     */
    storeVector(embeddingId, vector) {
        // Validate dimensions - FAIL FAST
        if (vector.length !== 768) {
            throw new VectorError(`Vector must be 768 dimensions, got ${vector.length}`, VectorErrorCode.INVALID_VECTOR_DIMENSIONS, { embeddingId, actualDimensions: vector.length, expectedDimensions: 768 });
        }
        // Verify embedding exists - FAIL FAST, no silent failures
        const exists = this.db.prepare('SELECT 1 FROM embeddings WHERE id = ?').get(embeddingId);
        if (!exists) {
            throw new VectorError(`Embedding ${embeddingId} not found. Insert into embeddings table first.`, VectorErrorCode.EMBEDDING_NOT_FOUND, { embeddingId });
        }
        try {
            const stmt = this.db.prepare(`
        INSERT INTO vec_embeddings (embedding_id, vector)
        VALUES (?, ?)
      `);
            stmt.run(embeddingId, Buffer.from(vector.buffer));
        }
        catch (error) {
            throw new VectorError(`Failed to store vector for ${embeddingId}`, VectorErrorCode.STORE_FAILED, { embeddingId, error: String(error) });
        }
    }
    /**
     * Store multiple vectors in a single transaction
     *
     * @param items - Array of {embeddingId, vector} pairs
     * @returns Count of vectors stored
     * @throws VectorError on any failure (transaction rolls back)
     */
    batchStoreVectors(items) {
        if (items.length === 0)
            return 0;
        // Validate ALL vectors first - FAIL FAST before any writes
        for (const { embeddingId, vector } of items) {
            if (vector.length !== 768) {
                throw new VectorError(`Vector for ${embeddingId} must be 768 dimensions, got ${vector.length}`, VectorErrorCode.INVALID_VECTOR_DIMENSIONS, { embeddingId, actualDimensions: vector.length });
            }
        }
        const stmt = this.db.prepare(`
      INSERT INTO vec_embeddings (embedding_id, vector)
      VALUES (?, ?)
    `);
        const insertAll = this.db.transaction((batch) => {
            let count = 0;
            for (const { embeddingId, vector } of batch) {
                stmt.run(embeddingId, Buffer.from(vector.buffer));
                count++;
            }
            return count;
        });
        try {
            return insertAll(items);
        }
        catch (error) {
            throw new VectorError('Batch vector store failed', VectorErrorCode.STORE_FAILED, {
                count: items.length,
                error: String(error),
            });
        }
    }
    // ═══════════════════════════════════════════════════════════════════════════
    // SEARCH OPERATIONS
    // ═══════════════════════════════════════════════════════════════════════════
    /**
     * Search for similar vectors using cosine distance
     *
     * CRITICAL: Results ALWAYS include original_text per CP-002.
     * No follow-up queries needed - results are self-contained.
     *
     * Uses vec_distance_cosine() for consistent cosine similarity.
     *
     * @param queryVector - 768-dim query vector
     * @param options - Search options (limit, threshold, filters)
     * @returns Array of VectorSearchResult with original text and source info
     */
    searchSimilar(queryVector, options = {}) {
        // Validate query vector - FAIL FAST
        if (queryVector.length !== 768) {
            throw new VectorError(`Query vector must be 768 dimensions, got ${queryVector.length}`, VectorErrorCode.INVALID_VECTOR_DIMENSIONS, { actualDimensions: queryVector.length });
        }
        const limit = Math.min(Math.max(1, options.limit ?? 10), 100);
        const threshold = Math.max(0, Math.min(1, options.threshold ?? 0.0));
        // Cosine distance = 1 - cosine_similarity
        // similarity=1.0 -> distance=0.0 (identical)
        // similarity=0.7 -> distance=0.3
        // similarity=0.0 -> distance=1.0 (perpendicular)
        const maxDistance = 1 - threshold;
        // Convert Float32Array to Buffer for sqlite-vec
        const queryBuffer = Buffer.from(queryVector.buffer);
        if (options.documentFilter?.length) {
            return this.searchWithFilter(queryBuffer, options.documentFilter, maxDistance, limit, options);
        }
        return this.searchAll(queryBuffer, maxDistance, limit, options);
    }
    /**
     * Build chunk filter SQL fragment for vector search.
     * Translates chunk filter conditions from 'c.' alias to 'ch.' alias used in vector queries.
     */
    buildChunkFilterSQL(options) {
        let sql = '';
        const params = [];
        if (options.chunkFilter?.conditions.length) {
            for (const condition of options.chunkFilter.conditions) {
                // Replace c. with ch. since vector.ts uses 'ch' alias for chunks table
                const translated = condition.replace(/\bc\./g, 'ch.');
                // Wrap each condition to permit VLM/extraction results through.
                // VLM results have e.chunk_id IS NULL so all ch.* columns are NULL,
                // which would cause any chunk filter to exclude them. The post-query
                // pageRangeFilter in mapAndFilterResults handles VLM page filtering.
                sql += ` AND (${translated} OR e.chunk_id IS NULL)`;
            }
            params.push(...options.chunkFilter.params);
        }
        // Page range filter for VLM/extraction results (which use e.page_number, not ch.page_number)
        // is applied post-query in mapAndFilterResults, since VLM and chunk results are mixed
        // and chunk results are already filtered by the chunkFilter SQL conditions above.
        return { sql, params };
    }
    /**
     * Search with document filter
     */
    searchWithFilter(queryBuffer, documentFilter, maxDistance, limit, options = {}) {
        const placeholders = documentFilter.map(() => '?').join(', ');
        const chunkFilterSQL = this.buildChunkFilterSQL(options);
        let sql = `
      SELECT
        e.id as embedding_id,
        e.chunk_id,
        e.image_id,
        e.extraction_id,
        e.document_id,
        e.original_text,
        e.original_text_length,
        e.source_file_path,
        e.source_file_name,
        e.source_file_hash,
        e.page_number,
        e.page_range,
        e.character_start,
        e.character_end,
        e.chunk_index,
        e.total_chunks,
        e.model_name,
        e.model_version,
        e.provenance_id,
        e.content_hash,
        ch.heading_context,
        ch.section_path,
        ch.content_types,
        ch.is_atomic,
        ch.page_range AS chunk_page_range,
        ch.heading_level,
        doc.doc_title,
        doc.doc_author,
        doc.doc_subject,
        ch.overlap_previous,
        ch.overlap_next,
        ch.chunking_strategy,
        ch.embedding_status,
        doc.page_count AS doc_page_count,
        (SELECT o.datalab_mode FROM ocr_results o WHERE o.document_id = e.document_id ORDER BY o.processing_completed_at DESC LIMIT 1) AS datalab_mode,
        (SELECT o.parse_quality_score FROM ocr_results o WHERE o.document_id = e.document_id ORDER BY o.processing_completed_at DESC LIMIT 1) AS ocr_quality_score,
        vec_distance_cosine(v.vector, ?) as distance
      FROM vec_embeddings v
      JOIN embeddings e ON e.id = v.embedding_id
      LEFT JOIN chunks ch ON e.chunk_id = ch.id
      LEFT JOIN documents doc ON e.document_id = doc.id
      WHERE e.document_id IN (${placeholders})
    `;
        // Build params array: queryBuffer for vec_distance_cosine, then document IDs
        const params = [queryBuffer];
        for (const docId of documentFilter) {
            params.push(docId);
        }
        // Add chunk filter conditions
        sql += chunkFilterSQL.sql;
        params.push(...chunkFilterSQL.params);
        sql += `
      ORDER BY distance ASC
      LIMIT ?
    `;
        params.push(limit * 3);
        try {
            const stmt = this.db.prepare(sql);
            const rows = stmt.all(...params);
            return this.mapAndFilterResults(rows, maxDistance, limit, options);
        }
        catch (error) {
            throw new VectorError('Vector search failed', VectorErrorCode.SEARCH_FAILED, {
                error: String(error),
                hasFilter: true,
            });
        }
    }
    /**
     * Search all vectors without filter
     */
    searchAll(queryBuffer, maxDistance, limit, options = {}) {
        try {
            const chunkFilterSQL = this.buildChunkFilterSQL(options);
            let sql = `
        SELECT
          e.id as embedding_id,
          e.chunk_id,
          e.image_id,
          e.extraction_id,
          e.document_id,
          e.original_text,
          e.original_text_length,
          e.source_file_path,
          e.source_file_name,
          e.source_file_hash,
          e.page_number,
          e.page_range,
          e.character_start,
          e.character_end,
          e.chunk_index,
          e.total_chunks,
          e.model_name,
          e.model_version,
          e.provenance_id,
          e.content_hash,
          ch.heading_context,
          ch.section_path,
          ch.content_types,
          ch.is_atomic,
          ch.page_range AS chunk_page_range,
          ch.heading_level,
          doc.doc_title,
          doc.doc_author,
          doc.doc_subject,
          ch.overlap_previous,
          ch.overlap_next,
          ch.chunking_strategy,
          ch.embedding_status,
          doc.page_count AS doc_page_count,
          (SELECT o.datalab_mode FROM ocr_results o WHERE o.document_id = e.document_id ORDER BY o.processing_completed_at DESC LIMIT 1) AS datalab_mode,
          (SELECT o.parse_quality_score FROM ocr_results o WHERE o.document_id = e.document_id ORDER BY o.processing_completed_at DESC LIMIT 1) AS ocr_quality_score,
          vec_distance_cosine(v.vector, ?) as distance
        FROM vec_embeddings v
        JOIN embeddings e ON e.id = v.embedding_id
        LEFT JOIN chunks ch ON e.chunk_id = ch.id
        LEFT JOIN documents doc ON e.document_id = doc.id
      `;
            const params = [queryBuffer];
            // Add chunk filter conditions (need WHERE clause if any)
            if (chunkFilterSQL.sql) {
                sql += ` WHERE 1=1 ${chunkFilterSQL.sql}`;
                params.push(...chunkFilterSQL.params);
            }
            sql += `
        ORDER BY distance ASC
        LIMIT ?
      `;
            params.push(limit * 3);
            const rows = this.db.prepare(sql).all(...params);
            return this.mapAndFilterResults(rows, maxDistance, limit, options);
        }
        catch (error) {
            throw new VectorError('Vector search failed', VectorErrorCode.SEARCH_FAILED, {
                error: String(error),
                hasFilter: false,
            });
        }
    }
    /**
     * Determine result type from row identity fields.
     */
    static getResultType(row) {
        if (row.chunk_id !== null)
            return 'chunk';
        if (row.extraction_id !== null)
            return 'extraction';
        return 'vlm';
    }
    /**
     * Map database rows to VectorSearchResult and filter by threshold.
     * Applies quality-weighted scoring to all results.
     */
    mapAndFilterResults(rows, maxDistance, limit, options = {}) {
        let results = rows
            .filter((row) => row.distance <= maxDistance)
            .map((row) => ({
            embedding_id: row.embedding_id,
            chunk_id: row.chunk_id,
            image_id: row.image_id,
            extraction_id: row.extraction_id,
            document_id: row.document_id,
            result_type: VectorService.getResultType(row),
            similarity_score: 1 - row.distance, // Convert distance to similarity
            distance: row.distance,
            original_text: row.original_text,
            original_text_length: row.original_text_length,
            source_file_path: row.source_file_path,
            source_file_name: row.source_file_name,
            source_file_hash: row.source_file_hash,
            page_number: row.page_number,
            page_range: row.page_range,
            character_start: row.character_start,
            character_end: row.character_end,
            chunk_index: row.chunk_index,
            total_chunks: row.total_chunks,
            model_name: row.model_name,
            model_version: row.model_version,
            provenance_id: row.provenance_id,
            content_hash: row.content_hash,
            heading_context: row.heading_context ?? null,
            section_path: row.section_path ?? null,
            content_types: row.content_types ?? null,
            is_atomic: !!row.is_atomic,
            chunk_page_range: row.chunk_page_range ?? null,
            heading_level: row.heading_level ?? null,
            ocr_quality_score: row.ocr_quality_score ?? null,
            doc_title: row.doc_title ?? null,
            doc_author: row.doc_author ?? null,
            doc_subject: row.doc_subject ?? null,
            overlap_previous: row.overlap_previous ?? 0,
            overlap_next: row.overlap_next ?? 0,
            chunking_strategy: row.chunking_strategy ?? null,
            embedding_status: row.embedding_status ?? 'pending',
            doc_page_count: row.doc_page_count ?? null,
            datalab_mode: row.datalab_mode ?? null,
        }));
        // Apply pageRangeFilter to VLM/extraction results (chunk results already filtered by SQL)
        if (options.pageRangeFilter) {
            const { min_page, max_page } = options.pageRangeFilter;
            results = results.filter((r) => {
                // Chunk results already filtered by chunkFilter SQL conditions
                if (r.chunk_id !== null)
                    return true;
                // VLM/extraction results: filter by page_number
                if (r.page_number === null)
                    return false; // No page info = exclude when filtering by page
                if (min_page !== undefined && r.page_number < min_page)
                    return false;
                if (max_page !== undefined && r.page_number > max_page)
                    return false;
                return true;
            });
        }
        // Apply quality-weighted scoring BEFORE limit slice so high-quality results
        // beyond the initial limit can be promoted into the final result set
        for (const r of results) {
            r.similarity_score *= computeQualityMultiplier(r.ocr_quality_score);
        }
        // Re-sort by quality-adjusted similarity score
        results.sort((a, b) => b.similarity_score - a.similarity_score);
        // Apply limit after quality reranking
        results = results.slice(0, limit);
        return results;
    }
    // ═══════════════════════════════════════════════════════════════════════════
    // DELETE OPERATIONS
    // ═══════════════════════════════════════════════════════════════════════════
    /**
     * Delete a vector from vec_embeddings
     *
     * @param embeddingId - The embedding ID
     * @returns true if deleted, false if not found
     */
    deleteVector(embeddingId) {
        try {
            const result = this.db
                .prepare('DELETE FROM vec_embeddings WHERE embedding_id = ?')
                .run(embeddingId);
            return result.changes > 0;
        }
        catch (error) {
            throw new VectorError(`Failed to delete vector ${embeddingId}`, VectorErrorCode.DELETE_FAILED, { embeddingId, error: String(error) });
        }
    }
    /**
     * Delete all vectors for a document
     *
     * @param documentId - The document ID
     * @returns Count of vectors deleted
     */
    deleteVectorsByDocumentId(documentId) {
        try {
            // Get embedding IDs for this document
            const embeddingIds = this.db
                .prepare('SELECT id FROM embeddings WHERE document_id = ?')
                .all(documentId);
            if (embeddingIds.length === 0)
                return 0;
            const stmt = this.db.prepare('DELETE FROM vec_embeddings WHERE embedding_id = ?');
            const deleteAll = this.db.transaction((ids) => {
                let count = 0;
                for (const id of ids) {
                    const result = stmt.run(id);
                    count += result.changes;
                }
                return count;
            });
            return deleteAll(embeddingIds.map((e) => e.id));
        }
        catch (error) {
            throw new VectorError(`Failed to delete vectors for document ${documentId}`, VectorErrorCode.DELETE_FAILED, { documentId, error: String(error) });
        }
    }
    // ═══════════════════════════════════════════════════════════════════════════
    // UTILITY METHODS
    // ═══════════════════════════════════════════════════════════════════════════
    /**
     * Get count of vectors stored
     */
    getVectorCount() {
        const result = this.db.prepare('SELECT COUNT(*) as count FROM vec_embeddings').get();
        return result.count;
    }
    /**
     * Check if vector exists
     */
    vectorExists(embeddingId) {
        const result = this.db
            .prepare('SELECT 1 FROM vec_embeddings WHERE embedding_id = ?')
            .get(embeddingId);
        return !!result;
    }
    /**
     * Get raw vector by embedding ID
     * @returns Float32Array or null if not found
     */
    getVector(embeddingId) {
        const result = this.db
            .prepare('SELECT vector FROM vec_embeddings WHERE embedding_id = ?')
            .get(embeddingId);
        if (!result)
            return null;
        return new Float32Array(result.vector.buffer, result.vector.byteOffset, result.vector.byteLength / 4);
    }
}
//# sourceMappingURL=vector.js.map