import { int } from "neo4j-driver";
import { getSession } from "../lib/neo4j.js";
import { embed } from "../lib/embeddings.js";
// ---------------------------------------------------------------------------
// Hybrid search pipeline:
//
// QUERY --> EMBED --> VECTOR SEARCH (per index) --> ┐
//      │                                              ├--> MERGE --> EXPAND --> RESULTS
//      └────────--> BM25 FULL-TEXT SEARCH ────────--> ┘
//
// Merge: normalise BM25 scores to [0,1] via min-max, then combine:
//   combined = 0.7 * vector_score + 0.3 * normalised_bm25_score
// Deduplicate by nodeId, taking the higher combined score.
// Fall back to vector-only if the full-text index doesn't exist.
//
// Soft-deleted nodes (deletedAt IS NOT NULL) are excluded from ALL query paths:
// vector search, BM25 search, property-based keyword search, and graph expand.
// The filter is unconditional — no parameter controls it.
// ---------------------------------------------------------------------------
const VECTOR_WEIGHT = 0.7;
const BM25_WEIGHT = 0.3;
const FULLTEXT_INDEX_NAME = "knowledge_fulltext";
// Cached vector index map — discovered from Neo4j at first query.
let indexCache = null;
/**
 * Discover all vector indexes from Neo4j.
 * Returns a map of label → index name.
 */
async function discoverIndexes() {
    if (indexCache)
        return indexCache;
    const session = getSession();
    try {
        const result = await session.run(`SHOW INDEXES YIELD name, labelsOrTypes, type WHERE type = 'VECTOR' RETURN name, labelsOrTypes`);
        indexCache = new Map();
        for (const record of result.records) {
            const name = record.get("name");
            const labels = record.get("labelsOrTypes");
            for (const label of labels) {
                indexCache.set(label, name);
            }
        }
        return indexCache;
    }
    finally {
        await session.close();
    }
}
/** Clear the index cache — call after schema changes. */
export function clearIndexCache() {
    indexCache = null;
}
/**
 * Run BM25 full-text search against the knowledge_fulltext index.
 * Returns empty array if the index doesn't exist (graceful fallback).
 */
async function bm25Search(queryText, accountId, limit, allowedScopes, keywordFilter, agentSlug) {
    const session = getSession();
    try {
        const scopeClause = allowedScopes
            ? "AND (node.scope IS NULL OR node.scope IN $allowedScopes)"
            : "";
        const agentClause = agentSlug
            ? "AND node.agents IS NOT NULL AND $agentSlug IN node.agents"
            : "";
        const deletedAtClause = "AND node.deletedAt IS NULL";
        const kwClause = keywordFilter?.clause ?? "";
        // Escape Lucene special characters in the query
        const escaped = queryText.replace(/[+\-&|!(){}[\]^"~*?:\\/]/g, "\\$&");
        const result = await session.run(`CALL db.index.fulltext.queryNodes($indexName, $query)
       YIELD node, score
       WHERE node.accountId = $accountId
       ${scopeClause}
       ${agentClause}
       ${deletedAtClause}
       ${kwClause}
       RETURN node, score, labels(node) AS nodeLabels, elementId(node) AS nodeId
       ORDER BY score DESC
       LIMIT $limit`, {
            indexName: FULLTEXT_INDEX_NAME,
            query: escaped,
            accountId,
            limit: int(limit),
            ...(allowedScopes ? { allowedScopes } : {}),
            ...(agentSlug ? { agentSlug } : {}),
            ...(keywordFilter?.params ?? {}),
        });
        return result.records.map((r) => ({
            nodeId: r.get("nodeId"),
            labels: r.get("nodeLabels"),
            properties: nodeToPlain(r.get("node").properties),
            score: typeof r.get("score") === "number" ? r.get("score") : Number(r.get("score")),
        }));
    }
    catch (err) {
        // Full-text index may not exist yet — fall back to vector-only
        const msg = err instanceof Error ? err.message : String(err);
        if (msg.includes("index") || msg.includes("fulltext") || msg.includes("not found")) {
            return [];
        }
        throw err;
    }
    finally {
        await session.close();
    }
}
/**
 * Normalise BM25 scores to [0, 1] using min-max within the result set.
 * If all scores are equal (or single result), returns 1.0 for all.
 */
function normaliseBm25Scores(scores) {
    if (scores.length === 0)
        return [];
    const min = Math.min(...scores);
    const max = Math.max(...scores);
    const range = max - min;
    if (range === 0)
        return scores.map(() => 1.0);
    return scores.map((s) => (s - min) / range);
}
export async function memorySearch(params) {
    const { query: queryText, labels, accountId, limit = 10, expandHops = 1, allowedScopes, keywords, keywordMatch = "any", agentSlug, keywordSubscriptions, } = params;
    const queryEmbedding = await embed(queryText);
    const labelToIndex = await discoverIndexes();
    const session = getSession();
    // Build the keyword filter clause (applied inside vector + BM25 queries).
    // When keywords are provided, nodes WITH a `keywords` property must match;
    // nodes WITHOUT the property (e.g. Email) pass through unfiltered.
    const keywordFn = keywordMatch === "all" ? "ALL" : "ANY";
    const keywordClause = keywords && keywords.length > 0
        ? `AND (node.keywords IS NULL OR ${keywordFn}(kw IN $keywords WHERE kw IN node.keywords))`
        : "";
    const keywordParams = keywords && keywords.length > 0
        ? { keywords: keywords.map((k) => k.toLowerCase().trim()) }
        : {};
    try {
        // --- Vector search ---
        let indexesToQuery;
        if (labels && labels.length > 0) {
            indexesToQuery = labels
                .map((l) => labelToIndex.get(l))
                .filter((idx) => idx !== undefined);
            if (indexesToQuery.length === 0) {
                return [];
            }
        }
        else {
            indexesToQuery = [...new Set(labelToIndex.values())];
        }
        const scoredMap = new Map();
        // Build scope clause — omit for admin (unrestricted), filter for scoped agents.
        // IS NULL handles legacy nodes that predate the scope migration.
        const scopeClause = allowedScopes
            ? "AND (node.scope IS NULL OR node.scope IN $allowedScopes)"
            : "";
        const scopeParams = allowedScopes ? { allowedScopes } : {};
        // Build per-agent tag clause — when set, only nodes explicitly tagged for this agent.
        // Both agentSlug AND allowedScopes must pass (defense in depth).
        const agentClause = agentSlug
            ? "AND node.agents IS NOT NULL AND $agentSlug IN node.agents"
            : "";
        const agentParams = agentSlug ? { agentSlug } : {};
        // Unconditional soft-delete filter — nodes with deletedAt set are never returned.
        const deletedAtClause = "AND node.deletedAt IS NULL";
        for (const indexName of indexesToQuery) {
            const vectorQuery = `
        CALL db.index.vector.queryNodes($indexName, $limit, $embedding)
        YIELD node, score
        WHERE node.accountId = $accountId
        ${scopeClause}
        ${agentClause}
        ${deletedAtClause}
        ${keywordClause}
        RETURN node, score, labels(node) AS nodeLabels, elementId(node) AS nodeId
        ORDER BY score DESC
        LIMIT $limit
      `;
            const vectorResult = await session.run(vectorQuery, {
                indexName,
                embedding: queryEmbedding,
                limit: int(limit),
                accountId,
                ...scopeParams,
                ...agentParams,
                ...keywordParams,
            });
            for (const record of vectorResult.records) {
                const nodeId = record.get("nodeId");
                const score = typeof record.get("score") === "number"
                    ? record.get("score")
                    : Number(record.get("score"));
                const existing = scoredMap.get(nodeId);
                if (existing) {
                    existing.vectorScore = Math.max(existing.vectorScore, score);
                }
                else {
                    scoredMap.set(nodeId, {
                        nodeId,
                        labels: record.get("nodeLabels"),
                        properties: nodeToPlain(record.get("node").properties),
                        vectorScore: score,
                        bm25Score: 0,
                    });
                }
            }
        }
        // --- BM25 search (knowledge documents only, graceful fallback) ---
        const bm25KeywordFilter = keywordClause
            ? { clause: keywordClause, params: keywordParams }
            : undefined;
        const bm25Results = await bm25Search(queryText, accountId, limit, allowedScopes, bm25KeywordFilter, agentSlug);
        if (bm25Results.length > 0) {
            const rawScores = bm25Results.map((r) => r.score);
            const normalised = normaliseBm25Scores(rawScores);
            for (let i = 0; i < bm25Results.length; i++) {
                const r = bm25Results[i];
                const existing = scoredMap.get(r.nodeId);
                if (existing) {
                    existing.bm25Score = Math.max(existing.bm25Score, normalised[i]);
                }
                else {
                    scoredMap.set(r.nodeId, {
                        nodeId: r.nodeId,
                        labels: r.labels,
                        properties: r.properties,
                        vectorScore: 0,
                        bm25Score: normalised[i],
                    });
                }
            }
        }
        // --- Keyword subscription queries (per-agent reactive knowledge) ---
        // When keywordSubscriptions are configured, two complementary searches run:
        //   1. BM25 full-text search per keyword (catches content matches)
        //   2. Property-based search on the node `keywords` array (catches tagged nodes)
        // Both run WITHOUT the agentSlug clause — keyword matches are scope-inclusive
        // by design. ALLOWED_SCOPES still applies (defense in depth).
        //
        // QUERY --> BM25 per keyword ────────────────--> ┐
        //      └--> Property match (node.keywords) ──--> ├--> scoredMap (union)
        //                                                 ┘
        if (keywordSubscriptions && keywordSubscriptions.length > 0) {
            // 1. BM25 full-text search per keyword (case-insensitive via analyzer)
            for (const kw of keywordSubscriptions) {
                const kwResults = await bm25Search(kw, accountId, limit, allowedScopes);
                if (kwResults.length > 0) {
                    const rawScores = kwResults.map((r) => r.score);
                    const normalised = normaliseBm25Scores(rawScores);
                    for (let i = 0; i < kwResults.length; i++) {
                        const r = kwResults[i];
                        const existing = scoredMap.get(r.nodeId);
                        if (existing) {
                            existing.bm25Score = Math.max(existing.bm25Score, normalised[i]);
                        }
                        else {
                            scoredMap.set(r.nodeId, {
                                nodeId: r.nodeId,
                                labels: r.labels,
                                properties: r.properties,
                                vectorScore: 0,
                                bm25Score: normalised[i],
                            });
                        }
                    }
                }
            }
            // 2. Property-based search: find nodes whose `keywords` array contains
            //    any of the subscription keywords. Runs against ALL node labels (not
            //    just KnowledgeDocument) since knowledge may be stored as DefinedTerm,
            //    Question, Service, etc. Uses toLower() on both sides as a transitional
            //    measure for existing mixed-case data.
            const propScopeClause = allowedScopes
                ? "AND (node.scope IS NULL OR node.scope IN $allowedScopes)"
                : "";
            const propResult = await session.run(`MATCH (node)
         WHERE node.accountId = $accountId
         AND node.deletedAt IS NULL
         AND node.keywords IS NOT NULL
         AND ANY(kw IN $kwSubs WHERE ANY(nk IN node.keywords WHERE toLower(nk) = kw))
         ${propScopeClause}
         RETURN node, labels(node) AS nodeLabels, elementId(node) AS nodeId
         LIMIT $limit`, {
                accountId,
                kwSubs: keywordSubscriptions,
                limit: int(limit),
                ...(allowedScopes ? { allowedScopes } : {}),
            });
            for (const record of propResult.records) {
                const nodeId = record.get("nodeId");
                const existing = scoredMap.get(nodeId);
                if (existing) {
                    // Property match is exact — boost bm25Score to maximum
                    existing.bm25Score = Math.max(existing.bm25Score, 1.0);
                }
                else {
                    scoredMap.set(nodeId, {
                        nodeId,
                        labels: record.get("nodeLabels"),
                        properties: nodeToPlain(record.get("node").properties),
                        vectorScore: 0,
                        bm25Score: 1.0,
                    });
                }
            }
        }
        // --- Merge: combine vector + BM25 scores ---
        const merged = [...scoredMap.values()].map((node) => ({
            ...node,
            combinedScore: VECTOR_WEIGHT * node.vectorScore + BM25_WEIGHT * node.bm25Score,
        }));
        merged.sort((a, b) => b.combinedScore - a.combinedScore);
        const topResults = merged.slice(0, limit);
        // --- Graph expand ---
        const allResults = [];
        for (const node of topResults) {
            const result = {
                nodeId: node.nodeId,
                labels: node.labels,
                properties: node.properties,
                score: node.combinedScore,
                related: [],
            };
            if (expandHops > 0) {
                const expandScopeClause = allowedScopes
                    ? "AND (related.scope IS NULL OR related.scope IN $allowedScopes)"
                    : "";
                // Per-agent expand filter: related nodes must be untagged or tagged for this agent.
                // Prevents cross-agent content leakage via graph traversal.
                const expandAgentClause = agentSlug
                    ? "AND (related.agents IS NULL OR $agentSlug IN related.agents)"
                    : "";
                const expandQuery = `
          MATCH (n)-[r]-(related)
          WHERE elementId(n) = $nodeId
          AND related.deletedAt IS NULL
          ${expandScopeClause}
          ${expandAgentClause}
          RETURN type(r) AS relType,
                 CASE WHEN startNode(r) = n THEN 'outgoing' ELSE 'incoming' END AS direction,
                 labels(related) AS relatedLabels,
                 related
          LIMIT 20
        `;
                const expandResult = await session.run(expandQuery, {
                    nodeId: node.nodeId,
                    ...scopeParams,
                    ...agentParams,
                });
                for (const relRecord of expandResult.records) {
                    result.related.push({
                        relationship: relRecord.get("relType"),
                        direction: relRecord.get("direction"),
                        labels: relRecord.get("relatedLabels"),
                        properties: nodeToPlain(relRecord.get("related").properties),
                    });
                }
            }
            allResults.push(result);
        }
        return allResults;
    }
    finally {
        await session.close();
    }
}
function nodeToPlain(properties) {
    const plain = {};
    for (const [key, value] of Object.entries(properties)) {
        if (key === "embedding")
            continue;
        if (value && typeof value === "object" && "toNumber" in value) {
            plain[key] = value.toNumber();
        }
        else {
            plain[key] = value;
        }
    }
    return plain;
}
//# sourceMappingURL=memory-search.js.map