import { abortIfNeeded, compareCodeUnits, formatPayloadJson } from '../../utils.js';
import { incomingEdges, nodeById, outgoingEdges } from '../query.js';
import { inlineSourceFromText, readInlineSource } from '../source-inline.js';
import { buildTokenBudgetRefusal, describeProjectedFit, isTokenBudgetRefusal, renderTokenBudgetRefusal, TokenBudgetRefusalError, } from '../narrowing.js';
import { ENTITY_CONTEXT_DEFAULT_BUDGET } from './api-types.js';
import { classifyEntityEdgeEndpoint } from './edge-accounting.js';
import { parserEvidenceDiagnostic } from './parser-honesty.js';
const CONTEXT_EDGES = ['calls', 'uses', 'inherits', 'implements'];
const REFERENCE_CLOSURE_EDGES = ['calls', 'uses', 'imports'];
const MAX_REFERENCE_CLOSURE_HANDLES = 20;
const CLI_CONTEXT_TOKENIZERS = new Set(['heuristic', 'o200k_base', 'cl100k_base']);
export class EntityContextBudgetError extends TokenBudgetRefusalError {
    name = 'EntityContextBudgetError';
    constructor(refusal, format) {
        super(refusal, `${format} entity-context`);
    }
}
const ROLES = {
    deps: { direct: 'direct_dependency', transitive: 'transitive_dependency' },
    dependents: { direct: 'direct_dependent', transitive: 'transitive_dependent' },
};
/**
 * Everything one hop-direction reaches, nearest first.
 *
 * Both directions matter and they answer different questions: dependents say
 * what a change would break, dependencies say what the entity is actually made
 * of. For the stated purpose — read this before changing it — the callees are
 * usually the larger half.
 *
 * `hops: 0` means unbounded. That is the established convention for this kind
 * of bound, and quietly reinterpreting it as "no hops" would silently return
 * only the target.
 */
function collectRelated(entity, context, hops, direction) {
    const nodeId = entity.ref.nodeId;
    if (!nodeId)
        return [];
    const nodes = nodeById(context.index);
    const roles = ROLES[direction];
    const seen = new Map();
    let queue = [nodeId];
    const visited = new Set([nodeId]);
    let distance = 0;
    while (queue.length > 0 && (hops === 0 || distance < hops)) {
        distance++;
        const next = [];
        for (const current of queue) {
            for (const type of CONTEXT_EDGES) {
                const edges = direction === 'deps'
                    ? outgoingEdges(context.index, type, current)
                    : incomingEdges(context.index, type, current);
                for (const edge of edges) {
                    const otherId = direction === 'deps' ? edge.to : edge.from;
                    if (edge.status !== 'resolved' || !otherId || otherId === nodeId)
                        continue;
                    if (seen.has(otherId) || !nodes.has(otherId))
                        continue;
                    const related = context.live.byNodeId.get(otherId);
                    if (!related)
                        continue;
                    seen.set(otherId, {
                        entity: related,
                        role: distance === 1 ? roles.direct : roles.transitive,
                        distance,
                    });
                    if (!visited.has(otherId)) {
                        visited.add(otherId);
                        next.push(otherId);
                    }
                }
            }
        }
        queue = next;
    }
    return [...seen.values()];
}
/**
 * Nearest first, dependencies before dependents at equal distance, each entity
 * once.
 *
 * The budget is spent in this order, so when it runs out it has bought the
 * first hop in both directions before any second hop — and, within a hop, the
 * callees a reader needs in order to understand the target at all. Mutual
 * recursion puts the same entity in both directions; it is admitted once, under
 * the role this ordering reached first, because paying for one body twice is
 * budget a caller would rather spend on another entity.
 */
function orderCandidates(candidates) {
    const rank = {
        target: 0,
        direct_dependency: 1,
        transitive_dependency: 1,
        direct_dependent: 2,
        transitive_dependent: 2,
    };
    const ordered = candidates.sort((a, b) => a.distance - b.distance ||
        rank[a.role] - rank[b.role] ||
        compareCodeUnits(a.entity.ref.relativePath, b.entity.ref.relativePath) ||
        a.entity.ref.startLine - b.entity.ref.startLine ||
        compareCodeUnits(a.entity.ref.name, b.entity.ref.name));
    const seen = new Set();
    return ordered.filter(candidate => {
        const key = candidate.entity.ref.nodeId ?? candidate.entity.ref.lineageId;
        if (seen.has(key))
            return false;
        seen.add(key);
        return true;
    });
}
function compareClosureHandles(left, right) {
    return (compareCodeUnits(left.kind, right.kind) ||
        compareCodeUnits(left.edgeType, right.edgeType) ||
        compareCodeUnits(left.relativePath ?? '', right.relativePath ?? '') ||
        compareCodeUnits(left.name, right.name) ||
        compareCodeUnits(left.nodeId ?? '', right.nodeId ?? ''));
}
function closureHandleKey(handle) {
    return [handle.kind, handle.edgeType, handle.name, handle.nodeId ?? '', handle.relativePath ?? ''].join('\0');
}
/** Reference closure over resolved 1-hop edges under the pinned index. */
function referenceClosureFor(entries, context) {
    const nodes = nodeById(context.index);
    const files = new Map(context.index.files.map(file => [file.relativePath, file]));
    const emittedWhole = new Set(entries
        .filter(entry => !entry.truncated)
        .map(entry => entry.entity.nodeId)
        .filter((nodeId) => nodeId !== undefined));
    const omittedTargets = new Set();
    const handles = new Map();
    let edgesResolved = 0;
    let edgesUnresolved = 0;
    let unreachableEdges = 0;
    const addHandle = (handle) => {
        const key = closureHandleKey(handle);
        if (!handles.has(key))
            handles.set(key, handle);
    };
    for (const entry of entries) {
        const nodeId = entry.entity.nodeId;
        if (!nodeId)
            continue;
        for (const edgeType of REFERENCE_CLOSURE_EDGES) {
            for (const edge of outgoingEdges(context.index, edgeType, nodeId)) {
                const endpoint = classifyEntityEdgeEndpoint(edge, 'out', nodes, context.live);
                if (endpoint.status === 'unresolved') {
                    edgesUnresolved++;
                    addHandle({
                        kind: edge.status === 'ambiguous' ? 'ambiguous-edge' : 'unresolved-edge',
                        edgeType,
                        name: edge.targetName ?? edge.specifier ?? '(unresolved reference)',
                        relativePath: edge.toPath,
                    });
                    continue;
                }
                if (endpoint.status === 'ignored')
                    continue;
                edgesResolved++;
                if (endpoint.status === 'unreachable') {
                    unreachableEdges++;
                    const target = endpoint.nodeId ? nodes.get(endpoint.nodeId) : undefined;
                    addHandle({
                        kind: 'unreachable-target',
                        edgeType,
                        name: target?.name ?? edge.targetName ?? edge.specifier ?? endpoint.nodeId ?? '(unreachable target)',
                        ...(endpoint.nodeId ? { nodeId: endpoint.nodeId } : {}),
                        relativePath: target?.relativePath ?? edge.toPath,
                    });
                    continue;
                }
                if (emittedWhole.has(endpoint.nodeId))
                    continue;
                omittedTargets.add(endpoint.nodeId);
                addHandle({
                    kind: 'omitted-target',
                    edgeType,
                    name: endpoint.entity.ref.qualifiedName ?? endpoint.entity.ref.name,
                    nodeId: endpoint.nodeId,
                    relativePath: endpoint.entity.ref.relativePath,
                });
            }
        }
    }
    const truncatedEntity = entries.some(entry => entry.truncated);
    const graphNodeUnavailable = entries.some(entry => entry.entity.nodeId === undefined);
    const regexFallback = entries.some(entry => files.get(entry.entity.relativePath)?.provenance?.tier === 'regex-fallback');
    const referenceClosureReasons = [];
    if (edgesUnresolved > 0)
        referenceClosureReasons.push('unresolved-edges');
    if (unreachableEdges > 0)
        referenceClosureReasons.push('unreachable-edges');
    if (graphNodeUnavailable)
        referenceClosureReasons.push('graph-node-unavailable');
    if (regexFallback)
        referenceClosureReasons.push('regex-fallback');
    if (truncatedEntity)
        referenceClosureReasons.push('truncated-entity');
    if (omittedTargets.size > 0)
        referenceClosureReasons.push('targets-omitted');
    const orderedHandles = [...handles.values()].sort(compareClosureHandles);
    return {
        referenceClosure: edgesUnresolved > 0 || unreachableEdges > 0 || graphNodeUnavailable || truncatedEntity || regexFallback
            ? 'unknown'
            : omittedTargets.size > 0
                ? 'open'
                : 'closed',
        referenceClosureReasons,
        edgesResolved,
        edgesUnresolved,
        targetsOmitted: omittedTargets.size,
        unreachableEdges,
        unresolvedHandles: orderedHandles.slice(0, MAX_REFERENCE_CLOSURE_HANDLES),
        unresolvedHandlesOmitted: Math.max(0, orderedHandles.length - MAX_REFERENCE_CLOSURE_HANDLES),
    };
}
/**
 * Read each file once, not once per entity.
 *
 * A hot file can hold dozens of entities in one context bundle; reading it per
 * entity turns a bundle into dozens of syscalls for the same bytes.
 */
function sourceReader(context, contextLines, signal) {
    const cache = new Map();
    return async function sourceFor(ref) {
        abortIfNeeded(signal);
        const key = `${ref.relativePath}\0${ref.startLine}\0${ref.endLine}\0${contextLines}`;
        let pending = cache.get(key);
        if (!pending) {
            const formatSource = context.formatFiles?.get(ref.relativePath);
            pending = Promise.resolve(formatSource !== undefined
                ? inlineSourceFromText(ref.relativePath, formatSource, ref.startLine, ref.endLine, contextLines)
                : readInlineSource(context.index, {
                    relativePath: ref.relativePath,
                    startLine: ref.startLine,
                    endLine: ref.endLine,
                    contextLines,
                    signal,
                }));
            cache.set(key, pending);
        }
        const result = await pending;
        abortIfNeeded(signal);
        return result;
    };
}
function rangeWithoutSource(block) {
    return {
        relativePath: block.relativePath,
        startLine: block.startLine,
        endLine: block.endLine,
        focusStartLine: block.focusStartLine,
        focusEndLine: block.focusEndLine,
    };
}
function pushInlineDiagnostic(diagnostics, diagnosed, diagnostic) {
    if (!diagnostic)
        return;
    const key = `${diagnostic.relativePath}\0${diagnostic.message}`;
    if (diagnosed.has(key))
        return;
    diagnosed.add(key);
    diagnostics.push(diagnostic);
}
function clippedInlineRange(range, source) {
    const endLine = Math.min(range.endLine, range.startLine + Math.max(0, source.split('\n').length - 1));
    return {
        ...range,
        endLine,
        focusStartLine: Math.min(range.focusStartLine, endLine),
        focusEndLine: Math.min(range.focusEndLine, endLine),
    };
}
function renderContextRef(ref) {
    const marks = [ref.isExported ? 'exported' : '', ref.isTest ? 'test' : '', ref.provisional ? 'uncommitted' : '']
        .filter(Boolean)
        .join(', ');
    return `${ref.kind} ${ref.qualifiedName ?? ref.name}  ${ref.relativePath}:${ref.startLine}${marks ? `  (${marks})` : ''}`;
}
function renderContextDiagnostics(diagnostics) {
    if (diagnostics.length === 0)
        return '';
    return `\n\nDiagnostics:\n${diagnostics.map(diagnostic => `  [${diagnostic.code}] ${diagnostic.message}`).join('\n')}`;
}
function renderClosureHandle(handle) {
    const location = handle.relativePath ? ` ${handle.relativePath}` : '';
    const node = handle.nodeId ? ` nodeId=${handle.nodeId}` : '';
    return `  [${handle.kind}/${handle.edgeType}] ${handle.name}${location}${node}`;
}
/** Exact text serializer used both for budget admission and public rendering. */
export function renderEntityContextText(result) {
    if (result.status !== 'resolved') {
        if (result.status === 'error') {
            if (isTokenBudgetRefusal(result))
                return renderTokenBudgetRefusal(result);
            return `Error (${result.code}): ${result.message}\n`;
        }
        const candidates = result.candidates.length > 0 ? `\n\nCandidates:\n${result.candidates.map(renderContextRef).join('\n')}` : '';
        return `${result.message}${candidates}\n`;
    }
    const header = `${renderContextRef(result.entity)}\n${result.tokensUsed}/${result.tokenBudget} tokens (${result.tokenizer}), ${result.entries.length} entities, truncated=${result.truncated}`;
    const closure = `reference closure over resolved 1-hop edges under the pinned index: ${result.referenceClosure} ` +
        `(edgesResolved=${result.edgesResolved}, edgesUnresolved=${result.edgesUnresolved}, ` +
        `targetsOmitted=${result.targetsOmitted}, unreachableEdges=${result.unreachableEdges}, ` +
        `reasons=${result.referenceClosureReasons.join(',') || 'none'})`;
    const handles = result.unresolvedHandles.length > 0 || result.unresolvedHandlesOmitted > 0
        ? `\nreference handles:\n${result.unresolvedHandles.map(renderClosureHandle).join('\n')}${result.unresolvedHandlesOmitted > 0
            ? `\n  ... ${result.unresolvedHandlesOmitted} additional handle(s) omitted`
            : ''}`
        : '';
    const blocks = result.entries.map(entry => {
        const inline = entry.inlineContext
            ? `, inline ${entry.inlineContext.startLine}-${entry.inlineContext.endLine}`
            : '';
        return `\n// ${entry.role} d${entry.distance} ${entry.entity.relativePath}:${entry.entity.startLine} (${entry.tokens} tokens${entry.truncated ? ', clipped' : ''}${inline})\n${entry.source}`;
    });
    return `${header}\n${closure}${handles}\n${blocks.join('\n')}${renderContextDiagnostics(result.diagnostics)}\n`;
}
/** JSON serializer used both for budget admission and public rendering. */
export function renderEntityContextJson(result, pretty = false) {
    return `${formatPayloadJson(result, pretty)}\n`;
}
function contextOutcome(payload, query) {
    return { status: 'resolved', query, ...payload };
}
function measureContextPayload(payload, query, format, countTokens, pretty = false) {
    const render = format === 'json'
        ? (value) => renderEntityContextJson(value, pretty)
        : renderEntityContextText;
    const seen = new Set();
    for (let attempt = 0; attempt < 32; attempt++) {
        const measured = countTokens(render(contextOutcome(payload, query)));
        if (measured === payload.tokensUsed)
            return measured;
        const state = `${payload.tokensUsed}:${measured}`;
        if (seen.has(state)) {
            throw new Error('Could not stabilize entity-context token accounting; choose a deterministic tokenizer.');
        }
        seen.add(state);
        payload.tokensUsed = measured;
    }
    throw new Error('Could not stabilize entity-context token accounting after 32 passes.');
}
/**
 * A token-budgeted source bundle for an entity and what depends on it.
 *
 * The budget is a hard ceiling, never an estimate: an entity is admitted whole
 * or not at all, so the bundle can always be handed to a model without the last
 * function in it being cut mid-body. What did not fit is reported per role
 * rather than dropped silently.
 */
export async function entityContext(entity, options, context) {
    const budget = options.tokenBudget ?? ENTITY_CONTEXT_DEFAULT_BUDGET;
    const hops = options.hops ?? 0;
    const contextLines = options.inlineContext ?? 0;
    const sourceFor = sourceReader(context, contextLines, options.signal);
    const format = options.budgetFormat ?? 'text';
    const pretty = options.budgetPretty ?? false;
    const query = options.budgetQuery ?? {
        name: entity.ref.name,
        relativePath: entity.ref.relativePath,
        kind: entity.ref.kind,
    };
    const include = options.include ?? ['deps', 'dependents'];
    const related = [];
    for (const direction of new Set(include))
        related.push(...collectRelated(entity, context, hops, direction));
    const candidates = [{ entity, role: 'target', distance: 0 }, ...orderCandidates(related)];
    const parserDiagnostic = parserEvidenceDiagnostic(context.index, candidates.map(candidate => candidate.entity.record.relativePath), 'context');
    const available = [];
    const totalByRole = {
        target: 0,
        direct_dependency: 0,
        transitive_dependency: 0,
        direct_dependent: 0,
        transitive_dependent: 0,
    };
    const availableByRole = {
        target: 0,
        direct_dependency: 0,
        transitive_dependency: 0,
        direct_dependent: 0,
        transitive_dependent: 0,
    };
    let sourceUnavailable = 0;
    const inlineDiagnostics = [];
    const diagnosedInline = new Set();
    for (const candidate of candidates) {
        abortIfNeeded(options.signal);
        totalByRole[candidate.role]++;
        const result = await sourceFor(candidate.entity.ref);
        if (!result.block) {
            sourceUnavailable++;
            pushInlineDiagnostic(inlineDiagnostics, diagnosedInline, result.diagnostic);
            continue;
        }
        const source = result.block.source;
        availableByRole[candidate.role]++;
        available.push({
            ...candidate,
            source,
            sourceTokens: context.tokenizer.count(source),
            ...(contextLines > 0 ? { inlineContext: rangeWithoutSource(result.block) } : {}),
        });
    }
    const payloadFor = (entries) => {
        const omitted = { ...totalByRole };
        for (const entry of entries)
            omitted[entry.role]--;
        const budgetOmitted = available.length - entries.length;
        const diagnostics = parserDiagnostic ? [parserDiagnostic] : [];
        const clippedTarget = entries.find(entry => entry.role === 'target' && entry.truncated);
        if (clippedTarget) {
            diagnostics.push({
                code: 'entity-budget-exhausted',
                message: `"${clippedTarget.entity.name}" alone exceeds the ${budget} token budget after output chrome and was clipped.`,
                relativePath: clippedTarget.entity.relativePath,
                limit: budget,
            });
        }
        if (budgetOmitted > 0) {
            const admittedByRole = {
                target: 0,
                direct_dependency: 0,
                transitive_dependency: 0,
                direct_dependent: 0,
                transitive_dependent: 0,
            };
            for (const entry of entries)
                admittedByRole[entry.role]++;
            const dependencies = availableByRole.direct_dependency +
                availableByRole.transitive_dependency -
                admittedByRole.direct_dependency -
                admittedByRole.transitive_dependency;
            const dependents = availableByRole.direct_dependent +
                availableByRole.transitive_dependent -
                admittedByRole.direct_dependent -
                admittedByRole.transitive_dependent;
            diagnostics.push({
                code: 'entity-budget-exhausted',
                message: `${budgetOmitted} entities did not fit the ${budget} token budget (${dependencies} dependencies, ${dependents} dependents).`,
                limit: budget,
                kept: entries.length,
                total: available.length,
            });
        }
        if (sourceUnavailable > 0) {
            diagnostics.push({
                code: 'evidence-unavailable',
                message: `${sourceUnavailable} entity source bodies could not be read.`,
                total: sourceUnavailable,
            });
        }
        for (const diagnostic of inlineDiagnostics)
            diagnostics.push(diagnostic);
        const payload = {
            entity: entity.ref,
            entries,
            tokenBudget: budget,
            tokensUsed: 0,
            tokenizer: context.tokenizer.name,
            truncated: budgetOmitted > 0 || sourceUnavailable > 0 || clippedTarget !== undefined,
            omitted,
            ...referenceClosureFor(entries, context),
            diagnostics,
        };
        measureContextPayload(payload, query, format, text => context.tokenizer.count(text), pretty);
        return payload;
    };
    const wholeEntryFor = (candidate) => ({
        entity: candidate.entity.ref,
        role: candidate.role,
        distance: candidate.distance,
        tokens: candidate.sourceTokens,
        source: candidate.source,
        truncated: false,
        ...(candidate.inlineContext ? { inlineContext: candidate.inlineContext } : {}),
    });
    const allWholeEntries = available.map(wholeEntryFor);
    const empty = payloadFor([]);
    const includeFlag = (directions) => {
        const selected = new Set(directions);
        if (selected.has('deps') && selected.has('dependents'))
            return 'both';
        return selected.has('deps') ? 'deps' : 'dependents';
    };
    const contextArgv = (projectedTokens, overrides = {}) => {
        const directions = overrides.include ?? include;
        const argv = [
            'code',
            'context',
            '--path',
            context.repoRoot,
            '--file',
            entity.ref.relativePath,
            '--kind',
            entity.ref.kind,
            '--token-budget',
            String(projectedTokens),
            '--hops',
            String(overrides.hops ?? hops),
            '--include',
            includeFlag(directions),
        ];
        if (format === 'json')
            argv.push('--format', 'json');
        if (CLI_CONTEXT_TOKENIZERS.has(context.tokenizer.name))
            argv.push('--tokenizer', context.tokenizer.name);
        if (contextLines > 0)
            argv.push('--inline-context', String(contextLines));
        argv.push('--', entity.ref.name);
        return argv;
    };
    const projectedCost = (entries, required) => Math.max(required, payloadFor(entries).tokensUsed);
    const contextNarrowingOptions = (required) => {
        const narrowing = [];
        const omitted = empty.omitted;
        const transitiveCallees = omitted.transitive_dependency;
        const transitiveCallers = omitted.transitive_dependent;
        if (transitiveCallees > 0 || transitiveCallers > 0) {
            const kept = allWholeEntries.filter(entry => entry.role !== 'transitive_dependency' && entry.role !== 'transitive_dependent');
            const projectedTokens = projectedCost(kept, required);
            const objects = [
                ...(transitiveCallees > 0 ? ['callees'] : []),
                ...(transitiveCallers > 0 ? ['callers'] : []),
            ].join(' and ');
            narrowing.push({
                description: describeProjectedFit(`Drop transitive ${objects}`, projectedTokens),
                argv: contextArgv(projectedTokens, { hops: 1 }),
                projectedTokens,
            });
        }
        const selectedDirections = new Set(include);
        const omittedCallees = omitted.direct_dependency + omitted.transitive_dependency;
        const omittedCallers = omitted.direct_dependent + omitted.transitive_dependent;
        if (selectedDirections.has('deps') && selectedDirections.has('dependents') && omittedCallers > 0) {
            const kept = allWholeEntries.filter(entry => entry.role === 'target' || entry.role === 'direct_dependency' || entry.role === 'transitive_dependency');
            const projectedTokens = projectedCost(kept, required);
            narrowing.push({
                description: describeProjectedFit('Drop callers', projectedTokens),
                argv: contextArgv(projectedTokens, { include: ['deps'] }),
                projectedTokens,
            });
        }
        if (selectedDirections.has('deps') && selectedDirections.has('dependents') && omittedCallees > 0) {
            const kept = allWholeEntries.filter(entry => entry.role === 'target' || entry.role === 'direct_dependent' || entry.role === 'transitive_dependent');
            const projectedTokens = projectedCost(kept, required);
            narrowing.push({
                description: describeProjectedFit('Drop callees', projectedTokens),
                argv: contextArgv(projectedTokens, { include: ['dependents'] }),
                projectedTokens,
            });
        }
        const projectedTokens = projectedCost(allWholeEntries, required);
        narrowing.push({
            description: describeProjectedFit('Keep the current graph scope and raise the budget', projectedTokens),
            argv: contextArgv(projectedTokens),
            projectedTokens,
        });
        return narrowing;
    };
    const refuseForBudget = (required) => {
        throw new EntityContextBudgetError(buildTokenBudgetRefusal(required, budget, contextNarrowingOptions(required)), format);
    };
    if (empty.tokensUsed > budget)
        refuseForBudget(empty.tokensUsed);
    const entries = [];
    for (const candidate of available) {
        abortIfNeeded(options.signal);
        const whole = wholeEntryFor(candidate);
        if (payloadFor([...entries, whole]).tokensUsed <= budget) {
            entries.push(whole);
            continue;
        }
        if (candidate.role !== 'target')
            continue;
        // The requested target wins the remaining budget, but its marker, outcome
        // envelope, counters, and diagnostic are all charged before its source.
        let low = 0;
        let high = candidate.sourceTokens;
        let best;
        while (low <= high) {
            const midpoint = Math.floor((low + high) / 2);
            const source = context.tokenizer.slice(candidate.source, 0, midpoint);
            const trial = {
                ...whole,
                source,
                tokens: context.tokenizer.count(source),
                truncated: true,
                ...(whole.inlineContext ? { inlineContext: clippedInlineRange(whole.inlineContext, source) } : {}),
            };
            if (payloadFor([...entries, trial]).tokensUsed <= budget) {
                best = trial;
                low = midpoint + 1;
            }
            else {
                high = midpoint - 1;
            }
        }
        if (!best) {
            const minimumTarget = payloadFor([
                {
                    ...whole,
                    source: '',
                    tokens: 0,
                    truncated: true,
                    ...(whole.inlineContext ? { inlineContext: clippedInlineRange(whole.inlineContext, '') } : {}),
                },
            ]);
            refuseForBudget(minimumTarget.tokensUsed);
        }
        if (best)
            entries.push(best);
    }
    return payloadFor(entries);
}
function entityKey(entity) {
    return entity.ref.nodeId ?? entity.ref.lineageId;
}
function relationRank(role) {
    switch (role) {
        case 'target':
            return 0;
        case 'direct_dependency':
        case 'transitive_dependency':
            return 1;
        case 'direct_dependent':
        case 'transitive_dependent':
            return 2;
    }
}
function orderManyCandidates(candidates) {
    return candidates.sort((a, b) => {
        const aRoot = a.firstRootIndex;
        const bRoot = b.firstRootIndex;
        if (aRoot !== undefined || bRoot !== undefined) {
            if (aRoot === undefined)
                return 1;
            if (bRoot === undefined)
                return -1;
            return aRoot - bRoot;
        }
        const aBest = [...a.relations].sort((left, right) => left.distance - right.distance ||
            relationRank(left.role) - relationRank(right.role) ||
            left.targetIndex - right.targetIndex)[0];
        const bBest = [...b.relations].sort((left, right) => left.distance - right.distance ||
            relationRank(left.role) - relationRank(right.role) ||
            left.targetIndex - right.targetIndex)[0];
        return ((aBest?.distance ?? 0) - (bBest?.distance ?? 0) ||
            relationRank(aBest?.role ?? 'target') - relationRank(bBest?.role ?? 'target') ||
            compareCodeUnits(a.entity.ref.relativePath, b.entity.ref.relativePath) ||
            a.entity.ref.startLine - b.entity.ref.startLine ||
            compareCodeUnits(a.entity.ref.name, b.entity.ref.name));
    });
}
/**
 * One whole-entity context bundle for several roots under one shared budget.
 *
 * This lower-level helper accepts already-resolved live entities. The session
 * method owns aligned query outcomes; keeping resolution there lets a mixed
 * batch retain misses and ambiguities without making this graph traversal know
 * about query parsing.
 */
export async function entityContextMany(targets, options, context) {
    abortIfNeeded(options.signal);
    const budget = options.tokenBudget ?? ENTITY_CONTEXT_DEFAULT_BUDGET;
    const hops = options.hops ?? 0;
    const include = options.include ?? ['deps', 'dependents'];
    const candidates = new Map();
    const addRelation = (entity, relation, firstRootIndex) => {
        const key = entityKey(entity);
        const existing = candidates.get(key);
        if (!existing) {
            candidates.set(key, { entity, relations: [relation], firstRootIndex });
            return;
        }
        if (!existing.relations.some(item => item.targetIndex === relation.targetIndex &&
            item.role === relation.role &&
            item.distance === relation.distance)) {
            existing.relations.push(relation);
        }
        if (firstRootIndex !== undefined &&
            (existing.firstRootIndex === undefined || firstRootIndex < existing.firstRootIndex)) {
            existing.firstRootIndex = firstRootIndex;
        }
    };
    for (const target of targets) {
        addRelation(target.entity, { targetIndex: target.targetIndex, role: 'target', distance: 0 }, target.targetIndex);
    }
    for (const target of targets) {
        for (const direction of new Set(include)) {
            for (const candidate of collectRelated(target.entity, context, hops, direction)) {
                addRelation(candidate.entity, {
                    targetIndex: target.targetIndex,
                    role: candidate.role,
                    distance: candidate.distance,
                });
            }
        }
    }
    const contextLines = options.inlineContext ?? 0;
    const sourceFor = sourceReader(context, contextLines, options.signal);
    const entries = [];
    const omissions = [];
    const includedTargetIndexes = new Set();
    let tokensUsed = 0;
    const inlineDiagnostics = [];
    const diagnosedInline = new Set();
    for (const candidate of orderManyCandidates([...candidates.values()])) {
        abortIfNeeded(options.signal);
        candidate.relations.sort((a, b) => a.targetIndex - b.targetIndex || a.distance - b.distance || relationRank(a.role) - relationRank(b.role));
        const result = await sourceFor(candidate.entity.ref);
        if (!result.block) {
            pushInlineDiagnostic(inlineDiagnostics, diagnosedInline, result.diagnostic);
            omissions.push({
                entity: candidate.entity.ref,
                reason: 'source-unavailable',
                relations: candidate.relations,
            });
            continue;
        }
        const source = result.block.source;
        const tokens = context.tokenizer.count(source);
        if (tokensUsed + tokens > budget) {
            omissions.push({ entity: candidate.entity.ref, reason: 'budget', relations: candidate.relations });
            continue;
        }
        entries.push({
            entity: candidate.entity.ref,
            tokens,
            source,
            relations: candidate.relations,
            ...(contextLines > 0 ? { inlineContext: rangeWithoutSource(result.block) } : {}),
        });
        tokensUsed += tokens;
        for (const relation of candidate.relations) {
            if (relation.role === 'target')
                includedTargetIndexes.add(relation.targetIndex);
        }
    }
    const diagnostics = [];
    const parserDiagnostic = parserEvidenceDiagnostic(context.index, Array.from(candidates.values(), candidate => candidate.entity.record.relativePath), 'context');
    if (parserDiagnostic)
        diagnostics.push(parserDiagnostic);
    for (const diagnostic of inlineDiagnostics)
        diagnostics.push(diagnostic);
    if (omissions.length > 0) {
        const budgetOmissions = omissions.filter(item => item.reason === 'budget').length;
        const unavailable = omissions.length - budgetOmissions;
        if (budgetOmissions > 0) {
            diagnostics.push({
                code: 'entity-budget-exhausted',
                message: `${budgetOmissions} whole entities did not fit the shared ${budget} token budget.`,
                limit: budget,
                kept: entries.length,
                total: entries.length + omissions.length,
            });
        }
        if (unavailable > 0) {
            diagnostics.push({
                code: 'evidence-unavailable',
                message: `${unavailable} entity source bodies could not be read.`,
                total: unavailable,
            });
        }
    }
    return {
        entries,
        includedTargetIndexes: [...includedTargetIndexes].sort((a, b) => a - b),
        tokenBudget: budget,
        tokensUsed,
        tokenizer: context.tokenizer.name,
        truncated: omissions.length > 0,
        omissions,
        diagnostics,
    };
}
//# sourceMappingURL=context.js.map