// Public facade for the connected-context pack assembler (Epic #177, Issue #183). Bridges
// ranked candidates (#182) + already-redacted excerpt content into a ConnectedContextPack
// per the #178 contract. Pure orchestration: compaction + budget checkpointing + reranker
// seam + optional micro-index. No IO. The audit ledger (#187) owns persistence and
// `ledgerRef` is therefore always undefined here.
import { createHash } from "node:crypto";
import { CONNECTED_CONTEXT_SCHEMA_VERSION, } from "@oscharko-dev/keiko-contracts/connected-context";
import { connectedContextPackStableId } from "@oscharko-dev/keiko-workspace";
import { compactExcerpt, nextAtomFitsBudget } from "./compaction.js";
import { makeIndexKey } from "./microIndex.js";
import { disabledReranker } from "./reranker.js";
// ─── Constants ────────────────────────────────────────────────────────────────
const DEFAULT_MAX_BYTES_PER_EXCERPT = 8 * 1024;
function resolveOptions(options) {
    return {
        maxBytesPerExcerpt: options?.maxBytesPerExcerpt ?? DEFAULT_MAX_BYTES_PER_EXCERPT,
        editablePaths: options?.editablePaths ?? new Set(),
        reranker: options?.reranker ?? disabledReranker,
        microIndex: options?.microIndex,
        nowMs: options?.nowMs ?? Date.now,
    };
}
function zeroUsage() {
    return {
        searchCalls: 0,
        filesRead: 0,
        excerptBytes: 0,
        modelInputTokens: 0,
        modelOutputTokens: 0,
        elapsedMs: 0,
        rerankCalls: 0,
    };
}
function groupAtomsByPath(atoms) {
    const map = new Map();
    for (const atom of atoms) {
        const existing = map.get(atom.scopePath);
        if (existing === undefined) {
            map.set(atom.scopePath, [atom]);
        }
        else {
            existing.push(atom);
        }
    }
    return map;
}
function deriveSelectionReason(candidate) {
    const first = candidate.signals[0];
    if (first === undefined) {
        return "ranked candidate";
    }
    return `ranked by ${first.name}`;
}
function resolveRole(scopePath, editablePaths) {
    return editablePaths.has(scopePath) ? "editable" : "read-only";
}
async function applyReranker(reranker, ranked, atomsByPath, budget, usage) {
    // The seam is only invoked when the budget actually allows rerank calls. This keeps
    // ExplorationBudget.rerankCallsMax authoritative even when a custom reranker is supplied
    // and avoids billing a rerank call against a run whose budget set rerankCallsMax=0.
    if (usage.rerankCalls >= budget.rerankCallsMax) {
        return { ordered: ranked, reranked: false };
    }
    const availability = await reranker.isAvailable();
    if (!availability.available) {
        return { ordered: ranked, reranked: false };
    }
    const reordered = await reranker.rerank(ranked, atomsByPath, ranked.length);
    return { ordered: reordered, reranked: true };
}
function cloneUsage(usage) {
    if (usage === undefined) {
        return zeroUsage();
    }
    return { ...usage };
}
function emptyBuildPlan(initialUsage, initialUncertainty) {
    return {
        files: [],
        usage: cloneUsage(initialUsage),
        uncertainty: [...(initialUncertainty ?? [])],
        extraOmitted: [],
    };
}
function compactAtomsForCandidate(atomsForPath, source, maxBytesPerExcerpt) {
    const excerpts = [];
    let totalBytes = 0;
    for (const atom of atomsForPath) {
        const rawContent = contentForAtom(atom, source);
        if (rawContent === undefined) {
            continue;
        }
        const result = compactExcerpt({ atom, rawContent, maxBytes: maxBytesPerExcerpt });
        excerpts.push(result.excerpt);
        totalBytes += result.bytesConsumed;
    }
    return { excerpts, totalBytes };
}
function lineCount(content) {
    if (content.length === 0) {
        return 0;
    }
    return content.split("\n").length;
}
function isExcerptWindowArray(source) {
    return Array.isArray(source);
}
function normalizeExcerptWindows(source) {
    if (typeof source === "string") {
        return [{ startLine: 1, endLine: lineCount(source), content: source }];
    }
    if (isExcerptWindowArray(source)) {
        return source;
    }
    return [source];
}
function coversAtom(window, atom) {
    const range = atom.lineRange;
    return (range === undefined || (window.startLine <= range.startLine && window.endLine >= range.endLine));
}
function sliceWindowForAtom(window, atom) {
    const range = atom.lineRange;
    if (range === undefined) {
        return window.content;
    }
    const lines = window.content.split("\n");
    const startIndex = Math.max(0, range.startLine - window.startLine);
    const endIndex = Math.min(lines.length, range.endLine - window.startLine + 1);
    if (startIndex >= endIndex) {
        return "";
    }
    return lines.slice(startIndex, endIndex).join("\n");
}
function contentForAtom(atom, source) {
    const windows = normalizeExcerptWindows(source);
    const selected = windows.find((window) => coversAtom(window, atom));
    if (selected === undefined) {
        return undefined;
    }
    return sliceWindowForAtom(selected, atom);
}
function appendUsage(usage, addedBytes) {
    return {
        ...usage,
        filesRead: usage.filesRead + 1,
        excerptBytes: usage.excerptBytes + addedBytes,
    };
}
function recordBudgetClip(plan, candidate, atomsForPath, nowMs) {
    plan.uncertainty.push({
        kind: "budget-clipped",
        claim: `context pack truncated at ${candidate.scopePath}`,
        // The clipped candidate is omitted from pack.files, so its atoms are not valid
        // uncertainty references under the connected-context contract.
        impactedAtomIds: [],
        emittedAtMs: nowMs,
    });
    plan.extraOmitted.push({
        scopePath: candidate.scopePath,
        reason: "budget-exhausted",
        omittedAtMs: nowMs,
    });
}
function recordNoEvidence(plan, claim, nowMs) {
    plan.uncertainty.push({
        kind: "no-evidence",
        claim,
        impactedAtomIds: [],
        emittedAtMs: nowMs,
    });
}
function recordPreMarkedOmission(plan, candidate, nowMs) {
    if (candidate.omitted === undefined) {
        return false;
    }
    plan.extraOmitted.push({
        scopePath: candidate.scopePath,
        reason: candidate.omitted,
        omittedAtMs: nowMs,
    });
    return true;
}
function processCandidate(plan, candidate, ctx) {
    // Respect a pre-set omission reason from the ranker (e.g. "generated", "ignored").
    // The candidate's atoms may still exist in input.atoms (the ranker only drops them from
    // its kept list), but they must not enter pack.files — that would contradict the
    // omission semantics of CandidateFile.omitted.
    if (recordPreMarkedOmission(plan, candidate, ctx.nowMs)) {
        return "continue";
    }
    const excerptSource = ctx.excerpts.get(candidate.scopePath);
    if (excerptSource === undefined) {
        recordNoEvidence(plan, `excerpt unavailable for ${candidate.scopePath}`, ctx.nowMs);
        return "continue";
    }
    const atomsForPath = ctx.atomsByPath.get(candidate.scopePath) ?? [];
    if (atomsForPath.length === 0) {
        return "continue";
    }
    const { excerpts, totalBytes } = compactAtomsForCandidate(atomsForPath, excerptSource, ctx.maxBytesPerExcerpt);
    if (excerpts.length === 0) {
        recordNoEvidence(plan, `excerpt unavailable for cited ranges in ${candidate.scopePath}`, ctx.nowMs);
        return "continue";
    }
    const checkpoint = {
        atoms: atomsForPath,
        budget: ctx.budget,
        currentUsage: plan.usage,
    };
    if (!nextAtomFitsBudget(checkpoint, totalBytes).fits) {
        recordBudgetClip(plan, candidate, atomsForPath, ctx.nowMs);
        return "budget-clipped";
    }
    plan.files.push({
        scopePath: candidate.scopePath,
        role: resolveRole(candidate.scopePath, ctx.editablePaths),
        selectionReason: deriveSelectionReason(candidate),
        excerpts,
    });
    plan.usage = appendUsage(plan.usage, totalBytes);
    return "continue";
}
function buildPlan(ordered, ctx, initialUsage, initialUncertainty) {
    const plan = emptyBuildPlan(initialUsage, initialUncertainty);
    for (const candidate of ordered) {
        const outcome = processCandidate(plan, candidate, ctx);
        if (outcome === "budget-clipped") {
            return plan;
        }
    }
    return plan;
}
function buildStableId(scope, query, atoms) {
    return connectedContextPackStableId({
        scopeId: scope.scopeId,
        queryKind: query.kind,
        queryText: query.text,
        atomStableIds: atoms.map((a) => a.stableId),
    });
}
function buildPack(input, plan, nowMs) {
    return {
        schemaVersion: CONNECTED_CONTEXT_SCHEMA_VERSION,
        stableId: buildStableId(input.scope, input.query, input.atoms),
        scope: input.scope,
        query: input.query,
        budget: input.budget,
        usage: plan.usage,
        files: plan.files,
        omitted: [...input.omittedFromRanking, ...plan.extraOmitted],
        uncertainty: plan.uncertainty,
        emittedAtMs: nowMs,
        ledgerRef: undefined,
    };
}
// ─── Public facade ────────────────────────────────────────────────────────────
// Cache-key contributors that change the produced pack. Two runs with the same atoms but
// different budgets, per-excerpt caps, editable-file sets, or reranker MUST hash to
// different keys — otherwise we could serve a cached pack that violates the new budget or
// carries the wrong file roles/order.
function sha256Hex(value) {
    return createHash("sha256").update(value).digest("hex");
}
function cacheLineRange(range) {
    if (range === undefined) {
        return undefined;
    }
    return { startLine: range.startLine, endLine: range.endLine };
}
function cacheScope(scope) {
    return {
        schemaVersion: scope.schemaVersion,
        scopeId: scope.scopeId,
        workspaceRoot: scope.workspaceRoot,
        kind: scope.kind,
        relativePaths: scope.relativePaths,
        conversationId: scope.conversationId,
        connectedAtMs: scope.connectedAtMs,
    };
}
function cacheQuery(query) {
    return {
        kind: query.kind,
        text: query.text,
        caseSensitive: query.caseSensitive,
        maxResults: query.maxResults,
    };
}
function cacheAtom(atom) {
    return {
        stableId: atom.stableId,
        scopePath: atom.scopePath,
        lineRange: cacheLineRange(atom.lineRange),
        score: atom.score,
        provenance: atom.provenance,
        redactionState: atom.redactionState,
        ledgerRef: atom.ledgerRef,
    };
}
function cacheUsage(usage) {
    if (usage === undefined) {
        return undefined;
    }
    return {
        searchCalls: usage.searchCalls,
        filesRead: usage.filesRead,
        excerptBytes: usage.excerptBytes,
        modelInputTokens: usage.modelInputTokens,
        modelOutputTokens: usage.modelOutputTokens,
        rerankCalls: usage.rerankCalls,
    };
}
function cacheUncertainty(marker) {
    return {
        kind: marker.kind,
        claim: marker.claim,
        impactedAtomIds: marker.impactedAtomIds,
    };
}
function cacheCandidate(candidate) {
    return {
        scopePath: candidate.scopePath,
        score: candidate.score,
        signals: candidate.signals.map((signal) => ({ name: signal.name, value: signal.value })),
        omitted: candidate.omitted,
    };
}
function cacheOmitted(entry) {
    return {
        scopePath: entry.scopePath,
        reason: entry.reason,
    };
}
function cacheExcerptWindow(window) {
    return {
        startLine: window.startLine,
        endLine: window.endLine,
        contentHash: sha256Hex(window.content),
    };
}
function cacheExcerpts(excerpts) {
    return [...excerpts.entries()]
        .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
        .map(([scopePath, source]) => ({
        scopePath,
        windows: normalizeExcerptWindows(source).map(cacheExcerptWindow),
    }));
}
function buildCacheAtomIds(input, resolved) {
    const fingerprintSource = JSON.stringify({
        scope: cacheScope(input.scope),
        query: cacheQuery(input.query),
        atoms: input.atoms.map(cacheAtom),
        budget: input.budget,
        initialUsage: cacheUsage(input.initialUsage),
        initialUncertainty: input.initialUncertainty?.map(cacheUncertainty),
        ranked: input.ranked.map(cacheCandidate),
        omittedFromRanking: input.omittedFromRanking.map(cacheOmitted),
        excerpts: cacheExcerpts(input.excerpts),
        maxBytesPerExcerpt: resolved.maxBytesPerExcerpt,
        editablePaths: [...resolved.editablePaths].sort(),
        rerankerName: resolved.reranker.name,
    });
    return [`fp-${sha256Hex(fingerprintSource)}`];
}
export async function assembleContextPack(input, options) {
    const resolved = resolveOptions(options);
    const key = makeIndexKey({
        scopeId: input.scope.scopeId,
        queryKind: input.query.kind,
        queryText: input.query.text,
        atomStableIds: buildCacheAtomIds(input, resolved),
    });
    const cached = resolved.microIndex?.get(key);
    if (cached !== undefined) {
        return { pack: cached, fromIndex: true };
    }
    const atomsByPath = groupAtomsByPath(input.atoms);
    const initialUsage = cloneUsage(input.initialUsage);
    const rerankerOutcome = await applyReranker(resolved.reranker, input.ranked, atomsByPath, input.budget, initialUsage);
    const now = resolved.nowMs();
    const plan = buildPlan(rerankerOutcome.ordered, {
        atomsByPath,
        excerpts: input.excerpts,
        budget: input.budget,
        maxBytesPerExcerpt: resolved.maxBytesPerExcerpt,
        editablePaths: resolved.editablePaths,
        nowMs: now,
    }, initialUsage, input.initialUncertainty);
    if (rerankerOutcome.reranked) {
        plan.usage = { ...plan.usage, rerankCalls: plan.usage.rerankCalls + 1 };
    }
    const pack = buildPack(input, plan, now);
    resolved.microIndex?.set(key, pack);
    return { pack, fromIndex: false };
}
