import * as path from 'node:path';
/** RRF constant — standard value from the original RRF paper. */
const RRF_K = 60;
/**
 * Number of extra candidates to fetch per search leg during hybrid search.
 * We fetch more than `maxResults` per leg so that after RRF fusion we
 * still have enough results even when the two legs return different sets.
 */
const HYBRID_OVERFETCH_FACTOR = 3;
/** Pure vector search (original behavior). */
export async function runVectorSearch(table, embedder, query, typeFilter, maxResults, sourceContext, boundary) {
    const [queryVector] = await embedder.embed([query]);
    let q = table.vectorSearch(queryVector).limit(maxResults);
    const whereClause = buildWhereClause(typeFilter, boundary);
    if (whereClause)
        q = q.where(whereClause);
    const results = await q.toArray();
    return results.map((row) => rowToSearchResult(row, sourceContext));
}
/**
 * Hybrid search: runs vector + FTS in parallel, merges with RRF.
 * Each leg fetches `maxResults * HYBRID_OVERFETCH_FACTOR` candidates
 * to give RRF enough diversity to produce `maxResults` fused results.
 */
export async function runHybridSearch(table, embedder, onWarn, query, typeFilter, maxResults, sourceContext, boundary) {
    const fetchCount = maxResults * HYBRID_OVERFETCH_FACTOR;
    const whereClause = buildWhereClause(typeFilter, boundary);
    const [queryVector] = await embedder.embed([query]);
    // Run both legs in parallel
    const [vectorResults, ftsResults] = await Promise.all([
        runVectorLeg(table, queryVector, whereClause, fetchCount),
        runFtsLeg(table, onWarn, query, whereClause, fetchCount),
    ]);
    // Merge with RRF
    return rrfMerge(vectorResults, ftsResults, maxResults, sourceContext);
}
async function runVectorLeg(table, queryVector, whereClause, limit) {
    let q = table.vectorSearch(queryVector).limit(limit).withRowId();
    if (whereClause)
        q = q.where(whereClause);
    const rows = await q.toArray();
    return rows.map((row, rank) => ({
        row,
        rank: rank + 1,
        id: String(row['_rowid'] ?? row['id']),
    }));
}
/** FTS-only search — no embedder required. For use when embedding is unavailable (offline, no API key). */
export async function runFtsSearch(table, onWarn, query, typeFilter, maxResults, sourceContext, boundary) {
    const whereClause = buildWhereClause(typeFilter, boundary);
    const rows = await runFtsLeg(table, onWarn, query, whereClause, maxResults);
    return rows.map(({ row, rank }) => {
        const result = rowToSearchResult(row, sourceContext);
        // If FTS didn't provide _score, use rank-based scoring (1.0 → 0.0)
        if (row['_score'] == null) {
            result.score = 1 / rank;
        }
        return result;
    });
}
async function runFtsLeg(table, onWarn, query, whereClause, limit) {
    try {
        let q = table.search(query, 'fts', 'content').withRowId();
        if (whereClause)
            q = q.where(whereClause);
        q = q.limit(limit);
        const rows = await q.toArray();
        return rows.map((row, rank) => ({
            row,
            rank: rank + 1,
            id: String(row['_rowid'] ?? row['id']),
        }));
    }
    catch (err) {
        // FTS leg failed — degrade gracefully
        const msg = err instanceof Error ? err.message : String(err);
        onWarn(`FTS search failed: ${msg}`);
        return [];
    }
}
// ─── Internal helpers ──────────────────────────────────
/** Escape a single boundary prefix for use in a SQL LIKE clause. */
function escapeBoundaryPrefix(raw) {
    // Normalize Windows backslashes to forward slashes
    const normalized = raw.replace(/\\/g, '/');
    // Escape SQL LIKE wildcards (%, _) and single quotes
    return normalized.replace(/%/g, '\\%').replace(/_/g, '\\_').replace(/'/g, "''");
}
/** Build a SQL WHERE clause from optional type and boundary filters. */
function buildWhereClause(typeFilter, boundary) {
    const conditions = [];
    if (typeFilter) {
        const safeType = typeFilter.replace(/'/g, "''");
        conditions.push(`\`type\` = '${safeType}'`);
    }
    // Normalize boundary to array
    const prefixes = boundary
        ? (Array.isArray(boundary) ? boundary : [boundary]).filter((b) => b.length > 0)
        : [];
    if (prefixes.length > 0) {
        const orClauses = prefixes
            .map((p) => `\`filePath\` LIKE '${escapeBoundaryPrefix(p)}%'`)
            .join(' OR ');
        conditions.push(prefixes.length > 1 ? `(${orClauses})` : orClauses);
    }
    return conditions.length > 0 ? conditions.join(' AND ') : undefined;
}
/**
 * Convert a raw LanceDB row to a SearchResult.
 *
 * `sourceContext` is **required** (mmnto/totem#1295 — CR outside-diff catch).
 * An optional context with a silent `filePath` fallback for `absoluteFilePath`
 * sent legacy callers down the wrong repo root instead of failing fast.
 * Making the parameter required is the type-level fix for that drift.
 */
function rowToSearchResult(row, sourceContext) {
    // Vector search returns _distance (lower = better); FTS returns _score (higher = better)
    let score = 0;
    if (row['_distance'] != null) {
        score = 1 / (1 + row['_distance']);
    }
    else if (row['_score'] != null) {
        score = row['_score'];
    }
    const filePath = row['filePath'];
    const absoluteFilePath = path.join(sourceContext.absolutePathRoot, filePath);
    const result = {
        content: row['content'],
        contextPrefix: row['contextPrefix'],
        filePath,
        absoluteFilePath,
        type: row['type'],
        label: row['label'],
        score,
        metadata: JSON.parse(row['metadata'] || '{}'),
    };
    if (sourceContext.sourceRepo) {
        result.sourceRepo = sourceContext.sourceRepo;
    }
    return result;
}
/**
 * Reciprocal Rank Fusion — merges two ranked result lists.
 * score(d) = Σ 1 / (k + rank_in_list) for each list containing d.
 */
function rrfMerge(listA, listB, limit, sourceContext) {
    const scores = new Map();
    for (const list of [listA, listB]) {
        for (const { row, rank, id } of list) {
            const entry = scores.get(id) ?? { score: 0, row };
            entry.score += 1 / (RRF_K + rank);
            scores.set(id, entry);
        }
    }
    return [...scores.values()]
        .sort((a, b) => b.score - a.score)
        .slice(0, limit)
        .map(({ score, row }) => ({ ...rowToSearchResult(row, sourceContext), score }));
}
//# sourceMappingURL=lance-search.js.map