// Top-level scoped-memory retrieval orchestrator.
//
// Cross-scope isolation is structural: the function ONLY iterates `request.scopes` when
// calling port.listByScope. A caller cannot trick this layer into surfacing records from
// a scope it did not authorise because no other code path reaches the port.
//
// Pipeline:
//   1. Validate request (non-empty scopes, finite non-negative weights, finite integer
//      budget/maxIncluded).
//   2. For each scope in request.scopes -> port.listByScope(scope, {maxResults, include*}).
//      Wrap any port throw as RetrievalError('port-failure', cause: original).
//   3. Dedupe by memoryId (a record reachable from multiple scopes appears once).
//   4. Apply suppression (status / validity / confidence) -> "suppressed-by-status".
//      Apply type filter when request.types is set -> "type-filtered".
//   5. Build an edges-by-memory map for the candidate set if the port exposes
//      listOutgoingEdges (bounded fetch — we only ask about the candidates we still hold).
//   6. Rank with the hybrid ranker; assemble with the token-budgeted greedy assembler.
//   7. Attach request to the assembler's result and return.
//
// Determinism: every step is pure given the port's return values, so identical port
// responses + identical request -> identical output. The cross-scope isolation test pins
// this with a spy port that records every listByScope call.
import { assembleContextBlock } from "./context.js";
import { RetrievalError } from "./errors.js";
import { tokenize } from "./relevance.js";
import { rankMemories } from "./ranking.js";
import { isMemorySuppressed } from "./suppression.js";
import { DEFAULT_BUDGET_TOKENS, DEFAULT_LIST_BY_SCOPE_MAX_RESULTS, DEFAULT_MAX_INCLUDED, DEFAULT_RANKING_WEIGHTS, DEFAULT_STALE_CONFIDENCE_THRESHOLD, } from "./types.js";
function emptyResult(request, budgetTokens) {
    return {
        contextBlock: {
            text: "",
            memories: [],
        },
        included: [],
        omitted: [],
        budget: {
            tokens: budgetTokens,
            used: 0,
        },
        request,
    };
}
function resolveWeights(request) {
    return {
        relevance: request.relevanceWeight ?? DEFAULT_RANKING_WEIGHTS.relevance,
        recency: request.recencyWeight ?? DEFAULT_RANKING_WEIGHTS.recency,
        confidence: request.confidenceWeight ?? DEFAULT_RANKING_WEIGHTS.confidence,
        pinned: request.pinnedBoost ?? DEFAULT_RANKING_WEIGHTS.pinned,
        correction: request.correctionBoost ?? DEFAULT_RANKING_WEIGHTS.correction,
        graph: request.graphProximityBoost ?? DEFAULT_RANKING_WEIGHTS.graph,
        semantic: request.semanticWeight ?? DEFAULT_RANKING_WEIGHTS.semantic,
    };
}
function assertNonNegativeWeights(weights) {
    for (const [name, value] of Object.entries(weights)) {
        if (!Number.isFinite(value) || value < 0) {
            throw new RetrievalError("invalid-weight", `weight ${name} must be a finite number >= 0 (got ${String(value)})`);
        }
    }
}
function assertNonNegativeBudget(budgetTokens, maxIncluded) {
    if (!Number.isFinite(budgetTokens) || !Number.isInteger(budgetTokens) || budgetTokens < 0) {
        throw new RetrievalError("invalid-budget", `budgetTokens must be a finite integer >= 0 (got ${String(budgetTokens)})`);
    }
    if (!Number.isFinite(maxIncluded) || !Number.isInteger(maxIncluded) || maxIncluded < 0) {
        throw new RetrievalError("invalid-budget", `maxIncluded must be a finite integer >= 0 (got ${String(maxIncluded)})`);
    }
}
function assertValidThreshold(staleConfidenceThreshold) {
    if (Number.isFinite(staleConfidenceThreshold) &&
        staleConfidenceThreshold >= 0 &&
        staleConfidenceThreshold <= 1) {
        return;
    }
    throw new RetrievalError("invalid-threshold", `staleConfidenceThreshold must be a finite number in [0, 1] (got ${String(staleConfidenceThreshold)})`);
}
function validateAndResolve(request) {
    if (request.scopes.length === 0) {
        throw new RetrievalError("empty-scopes", "request.scopes must contain at least one scope");
    }
    const budgetTokens = request.budgetTokens ?? DEFAULT_BUDGET_TOKENS;
    const maxIncluded = request.maxIncluded ?? DEFAULT_MAX_INCLUDED;
    assertNonNegativeBudget(budgetTokens, maxIncluded);
    const weights = resolveWeights(request);
    assertNonNegativeWeights(weights);
    const staleConfidenceThreshold = request.staleConfidenceThreshold ?? DEFAULT_STALE_CONFIDENCE_THRESHOLD;
    assertValidThreshold(staleConfidenceThreshold);
    return {
        budgetTokens,
        maxIncluded,
        weights,
        staleConfidenceThreshold,
    };
}
function fetchScoped(port, scopes) {
    const all = [];
    for (const scope of scopes) {
        try {
            const batch = port.listByScope(scope, {
                includeForgotten: true,
                includeArchived: true,
                includeExpired: true,
                maxResults: DEFAULT_LIST_BY_SCOPE_MAX_RESULTS,
            });
            for (const r of batch)
                all.push(r);
        }
        catch (cause) {
            throw new RetrievalError("port-failure", `listByScope threw for scope.kind=${scope.kind}`, {
                cause,
            });
        }
    }
    return all;
}
function dedupeById(records) {
    const seen = new Set();
    const out = [];
    for (const r of records) {
        if (seen.has(r.id))
            continue;
        seen.add(r.id);
        out.push(r);
    }
    return out;
}
function applyFilters(records, request, resolved) {
    const typeFilter = request.types;
    const candidates = [];
    const omitted = [];
    for (const r of records) {
        if (typeFilter !== undefined && !typeFilter.includes(r.type)) {
            omitted.push({ memoryId: r.id, reason: "type-filtered" });
            continue;
        }
        const sup = isMemorySuppressed(r, request.nowMs, resolved.staleConfidenceThreshold);
        if (sup.suppressed) {
            // sup.reason is optional on SuppressionResult; under exactOptionalPropertyTypes we
            // must conditionally add the field so we never write `suppressionDetail: undefined`.
            omitted.push(sup.reason === undefined
                ? { memoryId: r.id, reason: "suppressed-by-status" }
                : { memoryId: r.id, reason: "suppressed-by-status", suppressionDetail: sup.reason });
            continue;
        }
        candidates.push(r);
    }
    return { candidates, omitted };
}
function buildEdgesIndex(port, candidates) {
    if (port.listOutgoingEdges === undefined)
        return undefined;
    const map = new Map();
    for (const c of candidates) {
        try {
            // Call through the port object directly so `this` binds correctly on a class-based
            // port implementation (avoids the @typescript-eslint/unbound-method trap).
            const edges = port.listOutgoingEdges(c.id);
            if (edges.length > 0)
                map.set(c.id, edges);
        }
        catch (cause) {
            throw new RetrievalError("port-failure", `listOutgoingEdges threw for ${c.id}`, { cause });
        }
    }
    return map;
}
function hasPositiveSemanticSignal(semanticById) {
    if (semanticById === undefined)
        return false;
    for (const score of semanticById.values()) {
        if (score > 0)
            return true;
    }
    return false;
}
function hasQuerySignal(request) {
    const lexicalSignal = request.queryText !== undefined && tokenize(request.queryText).length > 0;
    return lexicalSignal || hasPositiveSemanticSignal(request.semanticById);
}
function applyRelevanceFloor(ranked, request) {
    if (!hasQuerySignal(request)) {
        return { ranked, omitted: [] };
    }
    const kept = [];
    const omitted = [];
    for (const entry of ranked) {
        if (entry.subscores.relevance === 0 &&
            entry.subscores.semantic === 0 &&
            entry.subscores.graph === 0) {
            omitted.push({ memoryId: entry.memoryId, reason: "below-threshold" });
            continue;
        }
        kept.push(entry);
    }
    return { ranked: kept, omitted };
}
export function retrieveMemoryContext(request, port) {
    const resolved = validateAndResolve(request);
    if (resolved.maxIncluded === 0 || resolved.budgetTokens === 0) {
        return emptyResult(request, resolved.budgetTokens);
    }
    const fetched = fetchScoped(port, request.scopes);
    const deduped = dedupeById(fetched);
    const filtered = applyFilters(deduped, request, resolved);
    const edgesByMemory = buildEdgesIndex(port, filtered.candidates);
    const rankQuery = {
        nowMs: request.nowMs,
        weights: resolved.weights,
        ...(request.queryText === undefined ? {} : { queryText: request.queryText }),
        ...(request.semanticById === undefined ? {} : { semanticById: request.semanticById }),
    };
    const ranked = rankMemories(filtered.candidates, rankQuery, edgesByMemory === undefined ? {} : { edgesByMemory });
    const thresholded = applyRelevanceFloor(ranked, request);
    const assembled = assembleContextBlock(thresholded.ranked, filtered.candidates, {
        budgetTokens: resolved.budgetTokens,
        maxIncluded: resolved.maxIncluded,
    });
    return {
        ...assembled,
        omitted: [...filtered.omitted, ...thresholded.omitted, ...assembled.omitted],
        request,
    };
}
