/**
 * In-memory knowledge index construction.
 *
 * Index construction is an application-process lifecycle operation, not a
 * per-session one. A complete `KnowledgeIndexSnapshot` is built once from the
 * normalized entries and then queried by every turn.
 *
 * The lexical scorer is a compact field-aware BM25 (BM25F-style): each of the
 * five text fields is scored independently and combined with per-field
 * weights, retaining the per-field breakdown so a retrieval decision is
 * explainable in traces. No embedding model or vector store is involved.
 */
import { normalizePhrases, normalizeText, tokenize } from "./normalize.js";
const FIELD_NAMES = [
    "title",
    "aliases",
    "keywords",
    "summary",
    "body",
];
/** Okapi BM25 saturation and length-normalization parameters. */
const BM25_K1 = 1.5;
const BM25_B = 0.75;
/**
 * Field-aware BM25 over the document set. Query text is scored per field and
 * the weighted per-field scores are summed. Satisfies the structural
 * `KnowledgeLexicalSearcher` type held by a snapshot.
 */
export class KnowledgeLexicalIndex {
    weights;
    // field -> (term -> (docId -> term frequency))
    postings = new Map();
    // field -> (docId -> field length in tokens)
    fieldLengths = new Map();
    // field -> total tokens across all docs (for average length)
    fieldTokenTotals = new Map();
    // field -> average field length (computed in finalize)
    avgFieldLength = new Map();
    docCount = 0;
    constructor(weights) {
        this.weights = weights;
        for (const field of FIELD_NAMES) {
            this.postings.set(field, new Map());
            this.fieldLengths.set(field, new Map());
            this.fieldTokenTotals.set(field, 0);
        }
    }
    add(document) {
        this.docCount += 1;
        for (const field of FIELD_NAMES) {
            const tokens = document.fields[field];
            const postingsForField = this.postings.get(field);
            const lengthsForField = this.fieldLengths.get(field);
            if (!postingsForField || !lengthsForField) {
                continue;
            }
            lengthsForField.set(document.id, tokens.length);
            this.fieldTokenTotals.set(field, (this.fieldTokenTotals.get(field) ?? 0) + tokens.length);
            const termFrequency = new Map();
            for (const token of tokens) {
                termFrequency.set(token, (termFrequency.get(token) ?? 0) + 1);
            }
            for (const [term, frequency] of termFrequency) {
                const docMap = postingsForField.get(term);
                if (docMap) {
                    docMap.set(document.id, frequency);
                }
                else {
                    postingsForField.set(term, new Map([[document.id, frequency]]));
                }
            }
        }
    }
    finalize() {
        for (const field of FIELD_NAMES) {
            const total = this.fieldTokenTotals.get(field) ?? 0;
            this.avgFieldLength.set(field, this.docCount > 0 ? total / this.docCount : 0);
        }
    }
    search(queryTokens, topK, eligibleEntryIds) {
        if (this.docCount === 0 || queryTokens.length === 0) {
            return [];
        }
        const uniqueTerms = Array.from(new Set(queryTokens));
        const totals = new Map();
        const breakdowns = new Map();
        for (const field of FIELD_NAMES) {
            const weight = this.weights[field];
            const avgdl = this.avgFieldLength.get(field) ?? 0;
            const postingsForField = this.postings.get(field);
            const lengthsForField = this.fieldLengths.get(field);
            if (weight <= 0 || avgdl <= 0 || !postingsForField || !lengthsForField) {
                continue;
            }
            for (const term of uniqueTerms) {
                const docMap = postingsForField.get(term);
                if (!docMap) {
                    continue;
                }
                const documentFrequency = docMap.size;
                const idf = Math.log(1 +
                    (this.docCount - documentFrequency + 0.5) /
                        (documentFrequency + 0.5));
                for (const [docId, frequency] of docMap) {
                    if (eligibleEntryIds && !eligibleEntryIds.has(docId)) {
                        continue;
                    }
                    const length = lengthsForField.get(docId) ?? 0;
                    const denominator = frequency + BM25_K1 * (1 - BM25_B + BM25_B * (length / avgdl));
                    const contribution = ((idf * (frequency * (BM25_K1 + 1))) / denominator) * weight;
                    totals.set(docId, (totals.get(docId) ?? 0) + contribution);
                    const breakdown = breakdowns.get(docId) ?? {};
                    breakdown[field] = (breakdown[field] ?? 0) + contribution;
                    breakdowns.set(docId, breakdown);
                }
            }
        }
        const matches = [];
        for (const [id, score] of totals) {
            matches.push({ id, score, fieldScores: breakdowns.get(id) ?? {} });
        }
        matches.sort((left, right) => right.score - left.score || compareIds(left.id, right.id));
        return matches.slice(0, topK);
    }
}
/** Deterministic tiebreak so equal scores produce a stable order. */
const compareIds = (left, right) => {
    if (left < right) {
        return -1;
    }
    if (left > right) {
        return 1;
    }
    return 0;
};
const addToIndex = (index, key, id) => {
    const bucket = index.get(key);
    if (bucket) {
        bucket.add(id);
    }
    else {
        index.set(key, new Set([id]));
    }
};
/**
 * Build the internal search document from a normalized entry. `exactKeys`
 * carry whole-phrase identifiers (entry id and title); each
 * text field is tokenized for the field-aware scorer.
 */
export const buildDocument = (entry) => {
    const exactKeys = normalizePhrases([entry.id, entry.title]);
    return {
        id: entry.id,
        exactKeys,
        fields: {
            title: tokenize(entry.title),
            aliases: entry.aliases.flatMap((alias) => tokenize(alias)),
            keywords: entry.keywords.flatMap((keyword) => tokenize(keyword)),
            summary: tokenize(entry.summary),
            body: tokenize(entry.body),
        },
    };
};
/**
 * Build the complete immutable snapshot: entry map, exact/alias/relation
 * indexes, and the field-aware lexical index.
 */
export const buildIndexSnapshot = (entries, weights) => {
    const entriesById = new Map();
    const exactIndex = new Map();
    const aliasIndex = new Map();
    const relationIndex = new Map();
    const lexical = new KnowledgeLexicalIndex(weights);
    for (const entry of entries) {
        entriesById.set(entry.id, entry);
        const document = buildDocument(entry);
        for (const key of document.exactKeys) {
            addToIndex(exactIndex, key, entry.id);
        }
        for (const alias of entry.aliases) {
            const key = normalizeText(alias);
            if (key) {
                addToIndex(aliasIndex, key, entry.id);
            }
        }
        const relations = [...entry.relatedEntryIds];
        if (entry.parentEntryId) {
            relations.push(entry.parentEntryId);
        }
        if (relations.length > 0) {
            relationIndex.set(entry.id, relations);
        }
        lexical.add(document);
    }
    lexical.finalize();
    return {
        entriesById,
        exactIndex,
        aliasIndex,
        relationIndex,
        lexical,
        entryCount: entries.length,
    };
};
