import { getSession } from "../lib/neo4j.js";
import { embed } from "../lib/embeddings.js";
import { hybrid, isPublicOnlyScope, DEFAULT_VECTOR_THRESHOLD } from "../../../../../lib/graph-search/dist/index.js";
// ---------------------------------------------------------------------------
// Memory search — thin adapter over the shared `graph-search` lib.
// The hybrid pipeline, Lucene escape, BM25 normalisation, and graph expand
// all live in platform/lib/graph-search; this file threads the MCP tool
// parameters through and applies the trash-census observability log.
//
// Trash filter visibility: when the account holds `:Trashed` nodes we emit
// `[trash:read-filter-skipped]` so silent over-filtering stays observable.
// The census lives here (not in the shared lib) because it is MCP-tool-
// specific context the admin route doesn't share.
//
// Response-side `fields` projection (Task 138): narrows `properties` on each
// returned row to the caller-requested keys. Indexes and ranking are
// unaffected — hybrid() sees the full text either way. `related[*].properties`
// is intentionally NOT projected here.
//
// Public-scope compiled-truth projection (Task 392): when the caller's
// `allowedScopes` is exactly `['public']` (i.e. the public-agent spawn),
// rewrite the property bag on each result so the agent never sees the
// admin-side compiledTruth on a public-twin label, and never sees any
// compiledTruth on a :Person row. The Cypher `scope IN $allowedScopes`
// filter already drops nodes scoped admin/shared/user:X; this layer
// rewrites the *field shape* for the rows that survived that filter, so
// a :Organization with scope='public' surfaces compiledTruthPublic in
// the `compiledTruth` slot the agent already knows how to read.
// ---------------------------------------------------------------------------
/** Labels whose admin-side compiledTruth gets swapped for compiledTruthPublic in public projection (Task 392). */
const PUBLIC_TWIN_LABELS = new Set([
    "Organization",
    "Concept",
    "Project",
    "LocalBusiness",
]);
/**
 * Rewrite the property bag on a single result row to surface the public twin.
 * - Public-twin labels: swap `compiledTruth` → `compiledTruthPublic` (drop the admin field).
 * - :Person: strip `compiledTruth` entirely (no public twin exists by design).
 * - Other labels: no-op.
 * Always strips the public-twin housekeeping fields (`compiledTruthPublicUpdatedAt`,
 * `compiledTruthPublicSource`) — they are operator-side metadata, never customer-facing.
 */
function projectPublicProperties(labels, properties) {
    const isPublicTwin = labels.some((l) => PUBLIC_TWIN_LABELS.has(l));
    const isPerson = labels.includes("Person");
    const next = {};
    let swapped = false;
    let stripped = false;
    for (const [k, v] of Object.entries(properties)) {
        if (k === "compiledTruthPublicUpdatedAt" || k === "compiledTruthPublicSource") {
            continue;
        }
        if (k === "compiledTruthPublic") {
            if (isPublicTwin) {
                next.compiledTruth = v;
                swapped = true;
            }
            continue;
        }
        if (k === "compiledTruth") {
            if (isPublicTwin) {
                // Drop — the public twin (if present in the bag) will land in
                // `compiledTruth` instead. If the public twin is absent from the
                // bag (caller did not request it), the row loses compiledTruth
                // entirely, which is the correct safe default for public reads.
                continue;
            }
            if (isPerson) {
                stripped = true;
                continue;
            }
        }
        next[k] = v;
    }
    return { properties: next, swapped, stripped };
}
export async function memorySearch(params) {
    const session = getSession();
    const limit = params.limit ?? 10;
    const { fields, ...hybridParams } = params;
    const callStart = Date.now();
    try {
        // Task 635: every search surface applies the same default vector floor so
        // the agent's recall matches the /graph and /data operator surfaces for
        // the same query. The lib leaves `vectorThreshold` undefined ⇒ no floor;
        // the surface opts in here. `??` lets an explicit caller pass 0 ("show
        // all") or any other cutoff and have it win over the default.
        const res = await hybrid(session, embed, {
            ...hybridParams,
            limit,
            vectorThreshold: hybridParams.vectorThreshold ?? DEFAULT_VECTOR_THRESHOLD,
        });
        const callMs = Date.now() - callStart;
        process.stderr.write(`[graph-search:hybrid] accountId=${(params.accountId ?? "").slice(0, 8)} ` +
            `retrievalClass=${params.retrievalClass ?? "general"} ` +
            `expansions=${res.stageCounts.expansions} ` +
            `vector=${res.stageCounts.vector} ` +
            `bm25=${res.stageCounts.bm25} ` +
            `fused=${res.stageCounts.fused} ` +
            `boosted=${res.stageCounts.boosted} ` +
            `deduped=${res.stageCounts.deduped} ` +
            `final=${res.stageCounts.final} ` +
            `mode=${res.mode} ` +
            `ms=${callMs} ` +
            `expand-ms=${res.expandMs}\n`);
        try {
            const trashCensus = await session.run(`MATCH (n:Trashed) WHERE n.accountId = $accountId RETURN count(n) AS c`, { accountId: params.accountId });
            const skipped = trashCensus.records[0]?.get("c");
            const skippedCount = typeof skipped === "number"
                ? skipped
                : skipped?.toNumber?.() ?? 0;
            if (skippedCount > 0) {
                const fingerprint = (params.labels && params.labels.length > 0)
                    ? `labels=${params.labels.join(",")}`
                    : `query=${params.query.slice(0, 40).replace(/\s+/g, " ")}`;
                process.stderr.write(`[trash:read-filter-skipped] accountId=${params.accountId} ${fingerprint} skippedCount=${skippedCount}\n`);
            }
        }
        catch {
            // observability-only; never break a search on census failure.
        }
        // Step 1: public-scope projection (Task 392). Runs before the `fields`
        // narrowing so a caller that asks for `compiledTruth` on a public read
        // gets the public twin in that slot — the narrowing then preserves the
        // already-swapped key. Applied to both the top-level row and every
        // `related[]` entry: graph-search's scope filter drops admin-scoped
        // related nodes, but the property bag on the survivors still carries
        // their compiledTruth, so without this pass a public-scope :Organization
        // result could leak compiledTruth on a related :Person entry.
        let workingResults = res.results;
        const publicProjection = isPublicOnlyScope(params.allowedScopes);
        if (publicProjection) {
            let swappedRows = 0;
            let strippedRows = 0;
            let swappedRelated = 0;
            let strippedRelated = 0;
            workingResults = res.results.map((r) => {
                const top = projectPublicProperties(r.labels, r.properties);
                if (top.swapped)
                    swappedRows++;
                if (top.stripped)
                    strippedRows++;
                const related = r.related.map((rel) => {
                    const out = projectPublicProperties(rel.labels, rel.properties);
                    if (out.swapped)
                        swappedRelated++;
                    if (out.stripped)
                        strippedRelated++;
                    return { ...rel, properties: out.properties };
                });
                return { ...r, properties: top.properties, related };
            });
            process.stderr.write(`[memory-search:public-projection] accountId=${params.accountId} ` +
                `rows=${workingResults.length} swapped=${swappedRows} stripped=${strippedRows} ` +
                `relatedSwapped=${swappedRelated} relatedStripped=${strippedRelated}\n`);
        }
        if (fields === undefined)
            return workingResults;
        const allowed = new Set(fields);
        let returnedProps = 0;
        let droppedProps = 0;
        const projected = workingResults.map((r) => {
            const next = {};
            for (const [k, v] of Object.entries(r.properties)) {
                if (allowed.has(k)) {
                    next[k] = v;
                    returnedProps++;
                }
                else {
                    droppedProps++;
                }
            }
            return { ...r, properties: next };
        });
        process.stderr.write(`[memory-search] accountId=${params.accountId} fields=${fields.join(",")} returnedProps=${returnedProps} droppedProps=${droppedProps}\n`);
        return projected;
    }
    finally {
        await session.close();
    }
}
//# sourceMappingURL=memory-search.js.map