// Grounded repository Q&A orchestrator (Epic #177, Issue #185). Composes the connected-context
// layers — #181 exploration planner, #179 lexical search facade, #180 structural adapters,
// #182 candidate ranker, and #183 context-pack assembler — into a single linear pipeline that
// produces a redacted `ConnectedContextPack` plus an assistant-content string. The model call
// is injected through the `GroundedAnswerer` seam so production can route through the Model
// Gateway while tests can keep deterministic answerers.
//
// Pure orchestration: the only IO this module performs is delegated through the workspace
// package's already-bounded WorkspaceFs port. Path validation is enforced by every composed
// layer at its own boundary, so this file does not re-validate scope paths.
import { isValidScopePath, } from "@oscharko-dev/keiko-contracts/connected-context";
import { advanceRing, applyUsage, assembleContextPack, canContinue, complete, planAndGovern, rankCandidates, } from "@oscharko-dev/keiko-workflows";
import { DEFAULT_SEARCH_LIMITS, FileTooLargeError, RepoSearchUnsupportedFileError, detectWorkspaceAt, gitHistoryAdapter, importGraphAdapter, readExcerpt, runStructuralAdapters, searchText, testSourcePairingAdapter, } from "@oscharko-dev/keiko-workspace";
import { CancelledError } from "@oscharko-dev/keiko-model-gateway";
import { nodeWorkspaceFs } from "@oscharko-dev/keiko-workspace/internal/fs";
import { normalizeGroundedAnswerPayload } from "./grounded-answer.js";
// Raised when the planner asks for clarification (no anchors, too-generic prompt, etc.). The
// route maps this to a 400 BAD_REQUEST with the planner's clarification reason in the message.
export class ClarificationNeededError extends Error {
    clarification;
    constructor(clarification) {
        super(`clarification needed: ${clarification.reason}`);
        this.clarification = clarification;
        this.name = "ClarificationNeededError";
    }
}
// ─── Default deterministic answerer ───────────────────────────────────────────
export const echoAnswerer = {
    answer: (question, pack) => {
        try {
            const filePaths = pack.files.map((f) => f.scopePath).join(", ");
            const summary = `Inspected ${String(pack.files.length)} file(s) for: ${question}. ` +
                `Findings include: ${filePaths.length === 0 ? "(no evidence)" : filePaths}.`;
            return Promise.resolve(summary);
        }
        catch (error) {
            return Promise.reject(error instanceof Error ? error : new Error(String(error)));
        }
    },
};
const TEXT_ENCODER = new TextEncoder();
function throwIfCancelled(signal) {
    if (signal?.aborted === true) {
        throw new CancelledError("grounded repository request cancelled");
    }
}
function utf8ByteLength(value) {
    return TEXT_ENCODER.encode(value).length;
}
function usageDelta(overrides = {}) {
    return {
        searchCalls: 0,
        filesRead: 0,
        excerptBytes: 0,
        modelInputTokens: 0,
        modelOutputTokens: 0,
        elapsedMs: 0,
        rerankCalls: 0,
        ...overrides,
    };
}
function clampUsageToBudget(usage, budget) {
    return {
        searchCalls: Math.min(usage.searchCalls, budget.searchCallsMax),
        filesRead: Math.min(usage.filesRead, budget.filesReadMax),
        excerptBytes: Math.min(usage.excerptBytes, budget.excerptBytesMax),
        modelInputTokens: Math.min(usage.modelInputTokens, budget.modelInputTokensMax),
        modelOutputTokens: Math.min(usage.modelOutputTokens, budget.modelOutputTokensMax),
        elapsedMs: Math.min(usage.elapsedMs, budget.elapsedMsMax),
        rerankCalls: Math.min(usage.rerankCalls, budget.rerankCallsMax),
    };
}
function budgetClipped(stopReason, nowMs) {
    return {
        kind: "budget-clipped",
        claim: `repository exploration stopped: ${stopReason}`,
        impactedAtomIds: [],
        emittedAtMs: nowMs,
    };
}
function noEvidence(nowMs) {
    return {
        kind: "no-evidence",
        claim: "No repository evidence matched the connected scope for this question.",
        impactedAtomIds: [],
        emittedAtMs: nowMs,
    };
}
function readBudgetStopReason(budget) {
    const exhausted = [
        ...(budget.filesReadMax <= 0 ? ["filesRead"] : []),
        ...(budget.excerptBytesMax <= 0 ? ["excerptBytes"] : []),
    ];
    if (exhausted.length === 0) {
        return undefined;
    }
    return `budget-exhausted on ${exhausted.join(", ")}`;
}
function omittedFromSearchCandidates(candidates, nowMs) {
    const omitted = [];
    for (const candidate of candidates) {
        if (candidate.omitted === undefined) {
            continue;
        }
        if (!isValidScopePath(candidate.scopePath, { mustBeRelative: true })) {
            continue;
        }
        omitted.push({
            scopePath: candidate.scopePath,
            reason: candidate.omitted,
            omittedAtMs: nowMs,
        });
    }
    return omitted;
}
async function runRing(ring, inputs) {
    if (ring.kind === "lexical") {
        const result = await searchText(inputs.searchScope, inputs.query, ring.searchLimits, {
            fs: inputs.fs,
            nowMs: inputs.nowMs,
        });
        // Lexical scanning is transient: each candidate file is read to match lines, then discarded.
        // It does NOT consume the excerpt budget. Charging result.filesScanned against filesReadMax
        // (Epic #177 retrieval defect) let a wide scan exhaust the budget the excerpt READ phase needs
        // and starved multi-file scopes — the scan could only ever examine ~4 files. The reserved
        // search call (one per ring) plus elapsedMs already bound the scan; the files whose content
        // actually enters the pack are charged when their excerpts are read in the assembler.
        return {
            atoms: result.atoms,
            omitted: omittedFromSearchCandidates(result.candidates, inputs.nowMs()),
            usage: usageDelta({ elapsedMs: result.elapsedMs }),
        };
    }
    // Keep the planner's ring split authoritative: the structural ring should only run the
    // structural adapters, while the git-history ring should only run the repo-level history
    // adapter. Reusing the full default registry for both rings duplicates atoms and inflates
    // downstream ranking signals whenever a workspace-root query plans both rings.
    const registry = ring.kind === "structural"
        ? { adapters: [testSourcePairingAdapter, importGraphAdapter] }
        : { adapters: [gitHistoryAdapter] };
    const result = await runStructuralAdapters(registry, inputs.searchScope, inputs.query, ring.searchLimits, inputs.fs, { nowMs: inputs.nowMs });
    return {
        atoms: result.atoms,
        omitted: [],
        usage: usageDelta({ elapsedMs: result.elapsedMs }),
    };
}
async function runAllRings(rings, inputs, initialGovernor) {
    const blockedByReadBudget = readBudgetStopReason(initialGovernor.plan.budget);
    if (blockedByReadBudget !== undefined) {
        return {
            atoms: [],
            omitted: [],
            governor: complete(initialGovernor),
            uncertainty: [budgetClipped(blockedByReadBudget, inputs.nowMs())],
        };
    }
    const atoms = [];
    const omitted = [];
    const uncertainty = [];
    let governor = initialGovernor;
    for (const ring of rings) {
        throwIfCancelled(inputs.signal);
        if (!canContinue(governor)) {
            break;
        }
        const reservedSearchCall = applyUsage(governor, usageDelta({ searchCalls: 1 }));
        if (reservedSearchCall.status === "budget-exhausted") {
            governor = reservedSearchCall;
            uncertainty.push(budgetClipped(reservedSearchCall.stopReason ?? "budget exhausted", inputs.nowMs()));
            break;
        }
        governor = reservedSearchCall;
        const result = await runRing(ring, inputs);
        throwIfCancelled(inputs.signal);
        const afterRing = applyUsage(governor, result.usage);
        const ringAtoms = result.atoms;
        atoms.push(...ringAtoms);
        omitted.push(...result.omitted);
        if (afterRing.status === "budget-exhausted") {
            governor = afterRing;
            uncertainty.push(budgetClipped(afterRing.stopReason ?? "budget exhausted", inputs.nowMs()));
            break;
        }
        governor = advanceRing(afterRing);
    }
    if (governor.status === "running") {
        governor = complete(governor);
    }
    return { atoms, omitted, governor, uncertainty };
}
const DEFAULT_EXCERPT_WINDOW = { startLine: 1, endLine: 200 };
const EXCERPT_CONTEXT_LINES = 2;
const MAX_EXCERPT_WINDOWS_PER_FILE = 8;
function groupEvidenceAtomsByPath(atoms) {
    const grouped = new Map();
    for (const atom of atoms) {
        const existing = grouped.get(atom.scopePath);
        if (existing === undefined) {
            grouped.set(atom.scopePath, [atom]);
        }
        else {
            existing.push(atom);
        }
    }
    return grouped;
}
function lineWindowForAtom(atom) {
    const range = atom.lineRange;
    if (range === undefined) {
        return DEFAULT_EXCERPT_WINDOW;
    }
    return {
        startLine: Math.max(1, range.startLine - EXCERPT_CONTEXT_LINES),
        endLine: range.endLine + EXCERPT_CONTEXT_LINES,
    };
}
function mergeLineWindows(windows) {
    const sorted = [...windows].sort((a, b) => a.startLine === b.startLine ? a.endLine - b.endLine : a.startLine - b.startLine);
    const merged = [];
    for (const window of sorted) {
        const previous = merged[merged.length - 1];
        if (previous === undefined || window.startLine > previous.endLine + 1) {
            merged.push(window);
            continue;
        }
        merged[merged.length - 1] = {
            startLine: previous.startLine,
            endLine: Math.max(previous.endLine, window.endLine),
        };
    }
    return merged;
}
function excerptLineWindows(atomsForPath) {
    if (atomsForPath === undefined || atomsForPath.length === 0) {
        return [DEFAULT_EXCERPT_WINDOW];
    }
    return mergeLineWindows(atomsForPath.map(lineWindowForAtom)).slice(0, MAX_EXCERPT_WINDOWS_PER_FILE);
}
function exhaustedDimensions(remainingFiles, remainingBytes) {
    return [
        ...(remainingFiles <= 0 ? ["filesRead"] : []),
        ...(remainingBytes <= 0 ? ["excerptBytes"] : []),
    ].join(", ");
}
async function readPathExcerptWindows(scopePath, inputs, remainingBytes) {
    const windows = [];
    let bytesConsumed = 0;
    for (const window of excerptLineWindows(inputs.atomsByPath.get(scopePath))) {
        throwIfCancelled(inputs.signal);
        const availableBytes = remainingBytes - bytesConsumed;
        if (availableBytes <= 0) {
            break;
        }
        const maxBytes = Math.min(8192, availableBytes);
        const result = await readExcerpt(inputs.searchScope, { scopePath, startLine: window.startLine, endLine: window.endLine, maxBytes }, { fs: inputs.fs });
        throwIfCancelled(inputs.signal);
        windows.push({ ...window, content: result.content });
        bytesConsumed += utf8ByteLength(result.content);
    }
    return { windows, bytesConsumed };
}
async function readKeptExcerpts(keptPaths, inputs) {
    const excerpts = new Map();
    const uncertainty = [];
    let remainingFiles = Math.max(0, inputs.budget.filesReadMax - inputs.initialUsage.filesRead);
    let remainingBytes = Math.max(0, inputs.budget.excerptBytesMax - inputs.initialUsage.excerptBytes);
    for (const scopePath of keptPaths) {
        throwIfCancelled(inputs.signal);
        if (remainingFiles <= 0 || remainingBytes <= 0) {
            const dimensions = exhaustedDimensions(remainingFiles, remainingBytes);
            uncertainty.push(budgetClipped(`budget-exhausted on ${dimensions}`, inputs.nowMs()));
            break;
        }
        try {
            const result = await readPathExcerptWindows(scopePath, inputs, remainingBytes);
            const { windows } = result;
            if (windows.length > 0) {
                excerpts.set(scopePath, windows);
                remainingFiles -= 1;
                remainingBytes -= result.bytesConsumed;
            }
        }
        catch (error) {
            // A single unreadable file (unsupported/binary, or larger than the excerpt read cap) must
            // degrade to a skipped excerpt, never crash the whole grounded answer. Other kept files and
            // the rest of the pipeline continue; the file simply contributes no excerpt content.
            if (error instanceof RepoSearchUnsupportedFileError || error instanceof FileTooLargeError) {
                continue;
            }
            throw error;
        }
    }
    return { excerpts, uncertainty };
}
function buildSearchScope(scope, workspace) {
    return {
        workspace,
        scopeId: scope.scopeId,
        relativePaths: scope.relativePaths,
    };
}
function createReadyGovernedPlan(input, nowMs) {
    const planned = planAndGovern(input.budget === undefined
        ? { scope: input.scope, query: input.query }
        : { scope: input.scope, query: input.query, budget: input.budget }, { nowMs });
    const { plan } = planned;
    if (plan.state !== "ready") {
        if (plan.clarification !== undefined) {
            throw new ClarificationNeededError(plan.clarification);
        }
        throw new ClarificationNeededError({
            reason: "scope-invalid",
            suggestedQuestions: ["Reselect files or a directory before asking."],
            minimumAnchorCount: 0,
        });
    }
    if (planned.governor === undefined) {
        throw new Error("ready exploration plan did not produce a budget governor");
    }
    return { plan, governor: planned.governor };
}
async function assembleGroundedPack({ input, deps, plan, rings, searchScope, fs, nowMs, }) {
    const atoms = rings.atoms;
    const initialUsage = clampUsageToBudget(rings.governor.usage, plan.budget);
    const ranking = rankCandidates({ atoms, anchors: plan.anchors }, { nowMs });
    const atomsByPath = groupEvidenceAtomsByPath(atoms);
    const evidenceUncertainty = atoms.length === 0 || ranking.kept.length === 0 ? [noEvidence(nowMs())] : [];
    const excerptReads = await readKeptExcerpts(ranking.kept.map((c) => c.scopePath), { searchScope, fs, budget: plan.budget, initialUsage, atomsByPath, nowMs, signal: deps.signal });
    const assembleOptions = deps.microIndex === undefined ? { nowMs } : { nowMs, microIndex: deps.microIndex };
    const assemble = await assembleContextPack({
        scope: input.scope,
        query: input.query,
        budget: plan.budget,
        atoms,
        ranked: ranking.kept,
        omittedFromRanking: [...rings.omitted, ...ranking.omitted],
        excerpts: excerptReads.excerpts,
        initialUsage,
        initialUncertainty: [
            ...rings.uncertainty,
            ...excerptReads.uncertainty,
            ...evidenceUncertainty,
        ],
    }, assembleOptions);
    return assemble.pack;
}
// ─── Public entry ─────────────────────────────────────────────────────────────
// Epic #532 — retrieval-only pipeline: the ready-governed plan, workspace detection, ring run,
// and pack assembly (the original steps 1–4) WITHOUT the model answer. `deps.answerer` is part of
// the shared deps type but is intentionally not invoked here; the multi-source path answers once
// over the merged packs rather than per source.
export async function retrieveConnectedContextPack(input, deps) {
    const fs = deps.fs ?? nodeWorkspaceFs;
    const detect = deps.detectWorkspace ?? detectWorkspaceAt;
    const nowMs = deps.nowMs ?? Date.now;
    const start = nowMs();
    throwIfCancelled(deps.signal);
    const { plan, governor } = createReadyGovernedPlan(input, nowMs);
    deps.recordPlan?.(plan);
    throwIfCancelled(deps.signal);
    const workspace = detect(input.workspaceRoot, fs);
    const searchScope = buildSearchScope(input.scope, workspace);
    const rings = await runAllRings(plan.rings, { searchScope, query: input.query, fs, nowMs, signal: deps.signal }, governor);
    throwIfCancelled(deps.signal);
    const pack = await assembleGroundedPack({ input, deps, plan, rings, searchScope, fs, nowMs });
    throwIfCancelled(deps.signal);
    return { pack, elapsedMs: Math.max(0, nowMs() - start), plan };
}
export async function runGroundedExploration(input, deps) {
    // AC5 (#532): the single-source path measures its OWN total wall time (retrieval + answer) so the
    // observable elapsedMs is byte-identical to before this split. The retrieval-only elapsed returned
    // by retrieveConnectedContextPack is deliberately discarded here.
    const nowMs = deps.nowMs ?? Date.now;
    const start = nowMs();
    const { pack, plan } = await retrieveConnectedContextPack(input, deps);
    const answer = normalizeGroundedAnswerPayload(await deps.answerer.answer(input.query.text, pack));
    const groundedPack = {
        ...pack,
        usage: {
            ...pack.usage,
            modelInputTokens: answer.usage.promptTokens,
            modelOutputTokens: answer.usage.completionTokens,
        },
    };
    const elapsedMs = Math.max(0, nowMs() - start);
    return { pack: groundedPack, assistantContent: answer.content, elapsedMs, plan };
}
// Re-export DEFAULT_SEARCH_LIMITS for parity with #179 callers that import limits via the
// orchestrator. Keeps `grounded-qa.ts` from needing a second workspace import path.
export { DEFAULT_SEARCH_LIMITS };
