// Scoped vector search (Epic #189, Issue #199). Given a list of capsule ids and a
// pre-embedded query vector per capsule, returns the ranked top-K `RetrievalReference`
// across the scope. The "no global pool" invariant lives in the SQL: every SELECT
// filters by `capsule_id` and we never join across capsules — so a bug in caller
// composition can never silently leak rows from a capsule outside scope.
//
// Vector blob layout: each row's `embedding` is `vectorDimensions * 4` bytes encoded as
// a little-endian Float32 array (see `floatToBytes` in `../indexing/embedding-batcher.ts`).
// We decode to a `Float32Array` view and compute similarity in-process. This is a
// brute-force O(N·D) scan — that is intentional for the first cut, since capsules are
// expected to be small (≤ a few thousand vectors) and adding an ANN index pulls in a
// native dependency we have explicitly avoided in `@oscharko-dev/keiko-local-knowledge`.
import { getCapsule } from "../capsule-lifecycle.js";
import { RetrievalError } from "./types.js";
// ─── Compose a scope object from either `ComposedRetrievalScope` or a single capsule ────
export function toScopeInput(scope) {
    if ("capsuleId" in scope) {
        return { capsuleIds: [scope.capsuleId] };
    }
    return { capsuleIds: scope.capsuleIds };
}
const SELECT_VECTORS_FOR_CAPSULE_SQL = [
    "SELECT chunk_id, capsule_id, source_id, document_id, embedding,",
    "  vector_dimensions, vector_metric",
    "FROM vectors",
    "WHERE capsule_id = :c",
].join(" ");
function readVectorsForCapsule(store, capsuleId) {
    const rows = store._internal.db
        .prepare(SELECT_VECTORS_FOR_CAPSULE_SQL)
        .all({ c: String(capsuleId) });
    return rows;
}
function readCitationRows(store, capsuleId, chunkIds) {
    if (chunkIds.length === 0)
        return [];
    const placeholders = chunkIds.map((_, i) => `:c${String(i)}`).join(", ");
    const sql = [
        "SELECT c.id AS chunk_id, c.capsule_id, c.source_id, c.document_id,",
        "  d.safe_display_name AS safe_display_name,",
        "  pu.page_number, pu.page_label, pu.section_path_json,",
        "  pu.character_start, pu.character_end",
        "FROM chunks c",
        "LEFT JOIN documents d ON d.capsule_id = c.capsule_id AND d.id = c.document_id",
        "LEFT JOIN parsed_units pu",
        "  ON pu.capsule_id = c.capsule_id AND pu.id = c.parsed_unit_id",
        `WHERE c.capsule_id = :cap AND c.id IN (${placeholders})`,
    ].join(" ");
    const params = { cap: String(capsuleId) };
    for (let i = 0; i < chunkIds.length; i += 1) {
        params[`c${String(i)}`] = chunkIds[i] ?? "";
    }
    const rows = store._internal.db.prepare(sql).all(params);
    return rows;
}
// ─── Similarity primitives ───────────────────────────────────────────────────
// Float32 decode. The row blob is a fresh-copied Uint8Array; we wrap it in a Float32Array
// view backed by the same ArrayBuffer. The byteLength must be exactly `dims * 4` — a
// length mismatch indicates DB corruption and we surface a `RetrievalError`.
function decodeEmbedding(row) {
    if (row.embedding.byteLength !== row.vector_dimensions * 4) {
        throw new RetrievalError("STORE_READ_FAILED", "vector blob length does not match vector_dimensions");
    }
    const copy = new Uint8Array(row.embedding); // detach from sqlite row buffer
    return new Float32Array(copy.buffer, copy.byteOffset, row.vector_dimensions);
}
// `noUncheckedIndexedAccess` widens `Float32Array[i]` to `number | undefined`; the loop
// stays in-bounds by construction (`i < a.length`), so we narrow with `?? 0` rather than
// a `!` assertion (forbidden by the project's lint rule) — at this index the value is
// always a real Float32 lane, never absent.
function cosineSimilarity(a, b) {
    let dot = 0;
    let na = 0;
    let nb = 0;
    for (let i = 0; i < a.length; i += 1) {
        const av = a[i] ?? 0;
        const bv = b[i] ?? 0;
        dot += av * bv;
        na += av * av;
        nb += bv * bv;
    }
    if (na === 0 || nb === 0)
        return 0;
    return dot / (Math.sqrt(na) * Math.sqrt(nb));
}
function dotProduct(a, b) {
    let dot = 0;
    for (let i = 0; i < a.length; i += 1) {
        dot += (a[i] ?? 0) * (b[i] ?? 0);
    }
    return dot;
}
// Negated Euclidean distance so higher = closer (uniform "score-desc" sort with the
// other two metrics). Documented in the function name; consumers never see the raw
// distance — only the unified score.
function negativeEuclideanDistance(a, b) {
    let sum = 0;
    for (let i = 0; i < a.length; i += 1) {
        const d = (a[i] ?? 0) - (b[i] ?? 0);
        sum += d * d;
    }
    return -Math.sqrt(sum);
}
function scoreFor(metric, query, vector) {
    if (metric === "cosine")
        return cosineSimilarity(query, vector);
    if (metric === "dot")
        return dotProduct(query, vector);
    return negativeEuclideanDistance(query, vector);
}
function identityKey(identity) {
    // modelRevision intentionally excluded — two capsules sharing structural identity
    // tuple share an embedding even if one has been re-validated with a new revision.
    return [
        identity.provider,
        identity.modelId,
        String(identity.vectorDimensions),
        identity.vectorMetric,
    ].join("|");
}
async function embedQueryFor(adapter, identity, text, signal) {
    const outcome = await adapter.request({
        endpoint: adapter.endpoint,
        apiKey: adapter.apiKey,
        ...(adapter.apiKeyHeaderName !== undefined
            ? { apiKeyHeaderName: adapter.apiKeyHeaderName }
            : {}),
        modelId: identity.modelId,
        input: text,
        ...(signal !== undefined ? { signal } : {}),
    });
    if (!outcome.ok) {
        return new RetrievalError("EMBEDDING_ADAPTER_FAILED", `embedding adapter returned ${outcome.kind}`);
    }
    return { vector: outcome.value.vector, dimensions: outcome.value.vector.length };
}
// ─── Citation builder ────────────────────────────────────────────────────────
function parseSectionPath(json) {
    if (json === null)
        return undefined;
    try {
        const parsed = JSON.parse(json);
        if (!Array.isArray(parsed))
            return undefined;
        const out = [];
        for (const item of parsed) {
            if (typeof item !== "string")
                return undefined;
            out.push(item);
        }
        return out;
    }
    catch {
        return undefined;
    }
}
function rowToCitation(row) {
    const sectionPath = parseSectionPath(row.section_path_json);
    // Build the citation without `undefined` literals to keep `exactOptionalPropertyTypes`
    // happy. The contract permits omission of each optional field but rejects the explicit
    // `undefined` value.
    return {
        documentId: row.document_id,
        capsuleId: row.capsule_id,
        sourceId: row.source_id,
        chunkId: row.chunk_id,
        safeDisplayName: row.safe_display_name ?? row.document_id,
        ...(row.page_number !== null ? { pageNumber: row.page_number } : {}),
        ...(row.page_label !== null ? { pageLabel: row.page_label } : {}),
        ...(sectionPath !== undefined ? { sectionPath } : {}),
        ...(row.character_start !== null ? { characterStart: row.character_start } : {}),
        ...(row.character_end !== null ? { characterEnd: row.character_end } : {}),
    };
}
function scoreCapsuleVectors(rows, capsule, queryVector, topK, minScore) {
    const metric = capsule.embeddingModelIdentity.vectorMetric;
    const scored = [];
    for (const row of rows) {
        // Belt-and-braces: the SQL filter already restricts to `capsule_id = capsule.id`, but
        // we re-assert at decode time so an arbitrary store-bypass cannot leak a row.
        if (row.capsule_id !== String(capsule.id))
            continue;
        if (row.vector_dimensions !== queryVector.length)
            continue;
        const vector = decodeEmbedding(row);
        const score = scoreFor(metric, queryVector, vector);
        if (minScore !== undefined && score < minScore)
            continue;
        scored.push({ chunkId: row.chunk_id, capsuleId: capsule.id, score });
    }
    scored.sort(scoreDesc);
    return scored.slice(0, oversampleTopK(topK));
}
function oversampleTopK(topK) {
    return Math.max(topK, Math.min(topK * 3, topK + 12));
}
function scoreDesc(a, b) {
    if (b.score !== a.score)
        return b.score - a.score;
    // Stable tiebreak by chunkId so reordering of equal-score rows is deterministic across
    // platforms — important for the snapshot tests in #200.
    return a.chunkId.localeCompare(b.chunkId);
}
function emptyState() {
    return {
        candidates: [],
        anyVectorSeen: false,
        anyDimensionCompatible: false,
        embeddingFailed: false,
    };
}
async function processCapsule(store, embeddingAdapter, capsule, query, options, cache, state) {
    const rows = readVectorsForCapsule(store, capsule.id);
    if (rows.length === 0)
        return;
    state.anyVectorSeen = true;
    const embedded = await ensureQueryEmbedded(embeddingAdapter, capsule.embeddingModelIdentity, query, options.signal, cache);
    if (embedded === undefined) {
        state.embeddingFailed = true;
        return;
    }
    if (embedded.dimensions !== capsule.embeddingModelIdentity.vectorDimensions) {
        // Adapter returned a dim that doesn't match the capsule's pinned identity — same
        // failure surface as #192's `INCOMPATIBLE_EMBEDDING_IDENTITY`. Skip this capsule.
        return;
    }
    state.anyDimensionCompatible = true;
    const candidates = scoreCapsuleVectors(rows, capsule, embedded.vector, options.topK, options.minScore);
    state.candidates.push(...candidates);
}
async function ensureQueryEmbedded(adapter, identity, query, signal, cache) {
    const key = identityKey(identity);
    const cached = cache.get(key);
    if (cached !== undefined)
        return cached;
    const result = await embedQueryFor(adapter, identity, query, signal);
    if (result instanceof RetrievalError)
        return undefined;
    cache.set(key, result);
    return result;
}
function selectTopCandidates(state, options) {
    if (!state.anyVectorSeen)
        return { ok: false, reason: "no-vectors" };
    if (state.embeddingFailed && state.candidates.length === 0) {
        return { ok: false, reason: "embedding-failed" };
    }
    if (!state.anyDimensionCompatible) {
        return { ok: false, reason: "incompatible-embedding-identity" };
    }
    state.candidates.sort(scoreDesc);
    const top = state.candidates.slice(0, oversampleTopK(options.topK));
    if (top.length === 0)
        return { ok: false, reason: "below-min-score" };
    return { ok: true, top };
}
export async function searchVectorsForScope(store, embeddingAdapter, scope, query, options) {
    const capsules = loadCapsules(store, scope.capsuleIds);
    if (capsules.length === 0)
        return { references: [], noEvidenceReason: "no-vectors" };
    const cache = new Map();
    const state = emptyState();
    for (const capsule of capsules) {
        await processCapsule(store, embeddingAdapter, capsule, query, options, cache, state);
    }
    const selection = selectTopCandidates(state, options);
    if (!selection.ok)
        return { references: [], noEvidenceReason: selection.reason };
    return { references: buildReferences(store, selection.top, query, options.topK) };
}
function loadCapsules(store, ids) {
    const out = [];
    for (const id of ids) {
        const capsule = getCapsule(store, id);
        if (capsule !== undefined)
            out.push(capsule);
    }
    return out;
}
function buildReferences(store, candidates, query, limit) {
    // Group surviving candidates by capsule so we can issue one citation-read per capsule.
    const byCapsule = new Map();
    for (const candidate of candidates) {
        const key = String(candidate.capsuleId);
        const bucket = byCapsule.get(key);
        if (bucket === undefined) {
            byCapsule.set(key, [candidate]);
        }
        else {
            bucket.push(candidate);
        }
    }
    const citationByChunk = new Map();
    for (const [capsuleKey, bucket] of byCapsule.entries()) {
        const rows = readCitationRows(store, capsuleKey, bucket.map((c) => c.chunkId));
        for (const row of rows) {
            // Composite scoping key — chunk ids ARE globally unique by construction (chunks
            // table PK on `id`), but we still namespace the map by `capsule|chunk` so any
            // future schema change cannot let a citation row for one capsule become the
            // citation for another with the same chunkId by coincidence.
            citationByChunk.set(`${row.capsule_id}|${row.chunk_id}`, rowToCitation(row));
        }
    }
    const refs = [];
    for (const candidate of candidates) {
        const key = `${String(candidate.capsuleId)}|${candidate.chunkId}`;
        const citation = citationByChunk.get(key);
        if (citation === undefined)
            continue; // Defensive: a missing citation means the chunk
        // row was deleted between the vectors read and the citations read. Drop the
        // candidate rather than fabricate.
        refs.push({
            chunkId: citation.chunkId,
            capsuleId: candidate.capsuleId,
            score: candidate.score + lexicalMetadataBonus(query, citation),
            citation,
        });
    }
    refs.sort(referenceScoreDesc);
    return refs.slice(0, limit);
}
function referenceScoreDesc(a, b) {
    if (b.score !== a.score)
        return b.score - a.score;
    return String(a.chunkId).localeCompare(String(b.chunkId));
}
function lexicalMetadataBonus(query, citation) {
    const queryTokens = tokenise(query);
    if (queryTokens.length === 0)
        return 0;
    const haystack = tokenise([
        citation.safeDisplayName,
        citation.pageLabel,
        ...(citation.sectionPath ?? []),
        String(citation.pageNumber ?? ""),
    ]
        .filter((value) => typeof value === "string" && value.length > 0)
        .join(" "));
    if (haystack.length === 0)
        return 0;
    let hits = 0;
    for (const token of queryTokens) {
        if (haystack.includes(token)) {
            hits += 1;
        }
    }
    if (hits === 0)
        return 0;
    return hits / (queryTokens.length * 10);
}
function tokenise(value) {
    return value
        .toLowerCase()
        .split(/[^a-z0-9]+/i)
        .filter((token) => token.length >= 2);
}
