/**
 * Optional embedding pipeline for hybrid memory (#26 / #28).
 * - **sqlite:** float32 BLOBs on `vault_chunk` (sql.js; in-process KNN).
 * - **postgres:** `CLAWQL_VECTOR_DATABASE_URL` + pgvector (`<=>` cosine in SQL).
 */
import { getObsidianVaultPath } from "./vault-config.js";
const DEFAULT_BASE = "https://api.openai.com/v1";
const DEFAULT_MODEL = "text-embedding-3-small";
const EMBED_BATCH = 64;
export function vectorBackend() {
    const v = process.env.CLAWQL_VECTOR_BACKEND?.trim().toLowerCase();
    if (v === "sqlite" || v === "sql")
        return "sqlite";
    if (v === "postgres" || v === "postgresql" || v === "pg" || v === "pgvector")
        return "postgres";
    return "off";
}
let warnedPostgresFallbackToSqlite = false;
/**
 * **Runtime** vector store: same as {@link vectorBackend} except when env requests **postgres**
 * but **`CLAWQL_VECTOR_DATABASE_URL`** is unset — then we use **sqlite** vectors in **`memory.db`**
 * (dual-write BLOBs, in-process recall). Prefer setting the URL or **`CLAWQL_VECTOR_BACKEND=sqlite`**
 * explicitly to avoid ambiguity.
 */
export function effectiveVectorBackend() {
    const b = vectorBackend();
    if (b === "off")
        return "off";
    if (b === "postgres" && !process.env.CLAWQL_VECTOR_DATABASE_URL?.trim()) {
        if (!warnedPostgresFallbackToSqlite) {
            warnedPostgresFallbackToSqlite = true;
            console.warn("[clawql-mcp] CLAWQL_VECTOR_BACKEND=postgres but CLAWQL_VECTOR_DATABASE_URL is unset; using SQLite (memory.db) for vectors. Set the URL for pgvector.");
        }
        return "sqlite";
    }
    return b;
}
/**
 * True when embeddings API + vector backend are configured (sqlite or postgres).
 */
export function vectorRecallEnabled() {
    return resolveEmbeddingConfig() !== null;
}
/** @deprecated Prefer {@link vectorBackend} === `"sqlite"`. */
export function vectorSqliteBackendEnabled() {
    return vectorBackend() === "sqlite" && resolveEmbeddingConfig() !== null;
}
export function resolveEmbeddingConfig() {
    const b = vectorBackend();
    if (b === "off")
        return null;
    if (process.env.CLAWQL_MEMORY_DB === "0")
        return null;
    if (getObsidianVaultPath() === null)
        return null;
    const apiKey = process.env.CLAWQL_EMBEDDING_API_KEY?.trim() || process.env.OPENAI_API_KEY?.trim() || "";
    if (!apiKey)
        return null;
    const baseUrl = (process.env.CLAWQL_EMBEDDING_BASE_URL?.trim() || DEFAULT_BASE).replace(/\/$/, "");
    const model = process.env.CLAWQL_EMBEDDING_MODEL?.trim() || DEFAULT_MODEL;
    return { baseUrl, model, apiKey };
}
/** Vector width for pgvector `vector(dim)` and API validation (default OpenAI `text-embedding-3-small`). */
export function embeddingVectorDimension() {
    const v = process.env.CLAWQL_EMBEDDING_DIMENSION?.trim();
    if (!v)
        return 1536;
    const n = Number.parseInt(v, 10);
    return Number.isFinite(n) && n > 0 ? n : 1536;
}
/**
 * When the **effective** backend is **postgres** (URL provided), set **`CLAWQL_MEMORY_VECTOR_DUAL_WRITE=0`**
 * to skip float32 vectors in **`vault_chunk.embedding`** (vectors only in Postgres). Default **on** (dual-write).
 * When effective backend is **sqlite** (including postgres env without URL), vectors always go to **`memory.db`**.
 */
export function vectorDualWriteToMemoryDb() {
    if (effectiveVectorBackend() !== "postgres")
        return true;
    return process.env.CLAWQL_MEMORY_VECTOR_DUAL_WRITE !== "0";
}
export function float32ArrayToBlob(vec) {
    return new Uint8Array(vec.buffer, vec.byteOffset, vec.byteLength);
}
export function blobToFloat32Array(blob) {
    if (blob.byteLength % 4 !== 0) {
        return new Float32Array(0);
    }
    const copy = new ArrayBuffer(blob.byteLength);
    new Uint8Array(copy).set(blob);
    return new Float32Array(copy);
}
export function cosineSimilarity(a, b) {
    if (a.length === 0 || b.length === 0 || a.length !== b.length)
        return 0;
    let dot = 0;
    let na = 0;
    let nb = 0;
    for (let i = 0; i < a.length; i++) {
        dot += a[i] * b[i];
        na += a[i] * a[i];
        nb += b[i] * b[i];
    }
    const denom = Math.sqrt(na) * Math.sqrt(nb);
    if (denom === 0)
        return 0;
    return dot / denom;
}
/**
 * OpenAI-compatible `/embeddings` (batched `input` array).
 */
export async function embedTexts(texts, config) {
    if (texts.length === 0) {
        return { vectors: [], model: config.model, dimension: 0 };
    }
    const url = `${config.baseUrl}/embeddings`;
    const all = [];
    let dimension = 0;
    for (let offset = 0; offset < texts.length; offset += EMBED_BATCH) {
        const batch = texts.slice(offset, offset + EMBED_BATCH);
        const res = await fetch(url, {
            method: "POST",
            headers: {
                "Content-Type": "application/json",
                Authorization: `Bearer ${config.apiKey}`,
            },
            body: JSON.stringify({ model: config.model, input: batch }),
        });
        if (!res.ok) {
            const errText = await res.text().catch(() => "");
            throw new Error(`embeddings HTTP ${res.status}: ${errText.slice(0, 400)}`);
        }
        const json = (await res.json());
        const rows = json.data ?? [];
        rows.sort((x, y) => (x.index ?? 0) - (y.index ?? 0));
        for (const row of rows) {
            const emb = row.embedding;
            if (!emb?.length)
                continue;
            const f32 = Float32Array.from(emb);
            if (dimension === 0)
                dimension = f32.length;
            all.push(f32);
        }
        if (all.length !== offset + batch.length) {
            throw new Error("embeddings response size mismatch");
        }
    }
    return { vectors: all, model: config.model, dimension };
}
export async function embedQuery(text, config) {
    const { vectors } = await embedTexts([text], config);
    return vectors[0] ?? new Float32Array(0);
}
/** Collapse chunk scores to one row per document (best chunk wins). */
export function aggregateScoresToDocumentBest(rows, maxDocs) {
    const bestByPath = new Map();
    for (const r of rows) {
        const prev = bestByPath.get(r.documentPath);
        if (!prev || r.score > prev.score) {
            bestByPath.set(r.documentPath, { score: r.score, chunkId: r.chunkId });
        }
    }
    const out = [...bestByPath.entries()].map(([path, v]) => ({
        path,
        score: v.score,
        chunkId: v.chunkId,
    }));
    out.sort((a, b) => b.score - a.score);
    return out.slice(0, maxDocs);
}
/**
 * Per-document max cosine(query, chunk); returns paths sorted by score descending.
 */
export function rankDocumentsByChunkSimilarity(query, chunks, opts) {
    const topChunks = opts?.topChunks ?? 80;
    const maxDocs = opts?.maxDocs ?? 12;
    const scored = chunks.map((c) => ({
        documentPath: c.documentPath,
        chunkId: c.chunkId,
        score: cosineSimilarity(query, c.embedding),
    }));
    scored.sort((a, b) => b.score - a.score);
    const slice = scored.slice(0, topChunks);
    return aggregateScoresToDocumentBest(slice, maxDocs);
}
