/**
 * Token-budgeted, importance-ranked repo map / pack.
 *
 * `buildRepoMap` ranks the graph (see `rank.ts`), then greedily admits the
 * highest-ranked files until a token budget is hit — shedding low-rank symbols
 * or shrinking content (partial admission) rather than dropping a whole file
 * when it nearly fits. Two modes:
 *   - `map`  — Aider-style: top symbol signatures per file (cheap context).
 *   - `pack` — Repomix-style: file content, optionally compressed to the
 *              architecturally-significant section via `extractKeySection`.
 *
 * Token counting is injected (`countTokens`) so a real BPE tokenizer can
 * enforce an exact budget; it defaults to the language-aware heuristic.
 */
import path from 'node:path';
import { z } from 'zod';
import { estimateTokenCount } from '../tokens.js';
import { compareCodeUnits, formatPayloadJson } from '../utils.js';
import { getGitDiffSummary } from '../git/api.js';
import { extractKeySection } from './query.js';
import { projectDirectoryTokenRollups } from './tree.js';
import { toNormalizedSymbolKind } from './types.js';
import { rankNodesCached, RankOptionsSchema } from './rank.js';
import { computeFocusScores } from './focus.js';
import { buildTokenBudgetRefusal, describeProjectedFit, TokenBudgetRefusalError, } from './narrowing.js';
export const RepoMapFormatSchema = z.enum(['markdown', 'xml', 'json', 'plain']);
export const RepoMapModeSchema = z.enum(['map', 'pack']);
/**
 * Presentation layout. `ranked` is today's output, byte-for-byte. `stable`
 * changes presentation only — rank still decides what is admitted — so the
 * document is worth placing in a cached prompt layer: entries are path-ordered,
 * per-entry rank scores are not rendered (one edit anywhere perturbs the whole
 * eigenvector and would rewrite every heading), and volatile aggregates move to
 * a constant-position tail block. Guarantee: byte-identical output for an
 * identical index + identical options + same OCC version, including across
 * cache cold/warm runs; on code change, unchanged files render byte-identical
 * blocks in unchanged relative order. It is not stable across OCC versions,
 * tokenizer changes, or option changes, and at the same numeric budget stable
 * prices its own score-less serialization, so it may admit more files than
 * ranked — rank fixes the admission order, not the cross-layout boundary.
 */
export const RepoMapLayoutSchema = z.enum(['ranked', 'stable']);
/** Opt-in query/path focus. Absent or empty → global structural rank only (no behavior change). */
export const RepoMapFocusSchema = z.object({
    query: z.string().optional(),
    paths: z.array(z.string()).optional(),
    graphDepth: z.number().int().nonnegative().optional(),
});
export const RepoMapOptionsSchema = z.object({
    tokenBudget: z.number().int().positive(),
    mode: RepoMapModeSchema.optional(),
    format: RepoMapFormatSchema.optional(),
    layout: RepoMapLayoutSchema.optional(),
    tokenizer: z.string().optional(),
    pretty: z.boolean().optional(),
    compress: z.boolean().optional(),
    maxSymbolsPerFile: z.number().int().positive().optional(),
    rank: RankOptionsSchema.optional(),
    focus: RepoMapFocusSchema.optional(),
    meta: z
        .object({
        loadSource: z.enum(['cache', 'build']),
    })
        .optional(),
});
export const RepoMapSymbolSchema = z.object({
    nodeId: z.string(),
    name: z.string(),
    kind: z.string(),
    signature: z.string(),
    line: z.number().optional(),
    endLine: z.number().optional(),
    containerName: z.string().optional(),
    exported: z.boolean(),
    jsdoc: z.string().optional(),
});
export const RepoMapEntrySchema = z.object({
    relativePath: z.string(),
    path: z.string(),
    language: z.string(),
    score: z.number(),
    normalizedScore: z.number(),
    symbols: z.array(RepoMapSymbolSchema),
    content: z.string().optional(),
    contentReason: z.string().optional(),
    /** Measured cost of this entry in the selected output format, including this field for JSON. */
    tokens: z.number(),
    droppedSymbols: z.number(),
    /** 0–100 focus relevance (present only when a focus is active). */
    focusScore: z.number().optional(),
    /** Human-readable reasons this file matched the focus. */
    focusReasons: z.array(z.string()).optional(),
    /** Present when the entry corresponds to a non-deleted file in the selected Git diff. */
    changed: z.boolean().optional(),
    /** Git numstat for changed entries. Kept out of markdown/plain budget accounting. */
    churn: z
        .object({
        added: z.number(),
        deleted: z.number(),
        binary: z.boolean(),
    })
        .optional(),
});
/** Echoed focus selector plus how many files the focus touched. */
export const RepoMapFocusResultSchema = z.object({
    query: z.string().optional(),
    paths: z.array(z.string()),
    matchedFiles: z.number(),
});
export const RepoMapResultSchema = z.object({
    mode: RepoMapModeSchema,
    format: RepoMapFormatSchema,
    /** Present only when the stable layout was selected. */
    layout: z.literal('stable').optional(),
    meta: z
        .object({
        loadSource: z.enum(['cache', 'build']),
    })
        .optional(),
    tokenizer: z.string().optional(),
    tokenBudget: z.number(),
    tokensUsed: z.number(),
    totalFiles: z.number(),
    entries: z.array(RepoMapEntrySchema),
    droppedFiles: z.number(),
    droppedSymbols: z.number(),
    truncated: z.boolean(),
    rankIterations: z.number(),
    rankConverged: z.boolean(),
    focus: RepoMapFocusResultSchema.optional(),
});
const APPROX_CHARS_PER_TOKEN = 4;
const DEFAULT_COMPRESS_CHARS = 4_000;
const MAX_MAP_NARROWING_OPTIONS = 5;
const CLI_TOKENIZERS = new Set(['heuristic', 'o200k_base', 'cl100k_base']);
// Focus blend: `combined = global * 0.35 + focus * 0.65`. The focus score is
// normalized against a FIXED saturation (not a per-run max) so a strong query
// hit stays high even when another file has a huge path score.
const FOCUS_SATURATION = 10;
const FOCUS_GLOBAL_WEIGHT = 0.35;
const FOCUS_RELEVANCE_WEIGHT = 0.65;
function round1(value) {
    return Math.round(value * 10) / 10;
}
function mapNarrowingOptions(index, options, mode, format, required, countTokens) {
    const argvFor = (repoRoot, projectedTokens) => {
        const argv = ['code', mode, '--path', repoRoot, '--map-tokens', String(projectedTokens), '--map-format', format];
        if (options.tokenizer && CLI_TOKENIZERS.has(options.tokenizer))
            argv.push('--tokenizer', options.tokenizer);
        if (options.layout === 'stable')
            argv.push('--layout', 'stable');
        if (mode === 'pack' && options.compress)
            argv.push('--compress');
        if (options.maxSymbolsPerFile != null)
            argv.push('--max-symbols', String(options.maxSymbolsPerFile));
        return argv;
    };
    const directories = projectDirectoryTokenRollups(index, {
        countTokens,
        tokenizer: options.tokenizer,
    })
        .filter(entry => entry.tokens > 0)
        .sort((left, right) => left.depth - right.depth || left.tokens - right.tokens || compareCodeUnits(left.path, right.path))
        .slice(0, MAX_MAP_NARROWING_OPTIONS);
    const built = directories.map(entry => {
        const projectedTokens = Math.max(required, required + entry.tokens);
        return {
            description: describeProjectedFit(`Scope the ${mode} to ${entry.path}`, projectedTokens),
            argv: argvFor(path.resolve(index.repoRoot, entry.path), projectedTokens),
            projectedTokens,
        };
    });
    if (built.length > 0)
        return built;
    return [
        {
            description: describeProjectedFit(`Raise the ${mode} budget for the current scope`, required),
            argv: argvFor(index.repoRoot, required),
            projectedTokens: required,
        },
    ];
}
/** A focus is active only if it carries a non-blank query term or path. */
function isFocusActive(focus) {
    if (focus == null)
        return false;
    if ((focus.query ?? '').trim().length > 0)
        return true;
    return (focus.paths ?? []).some(value => value.trim().length > 0);
}
function symbolKey(parts) {
    return [parts.type, parts.name, parts.line ?? '', parts.containerName ?? ''].join('|');
}
/** Order ranked symbols of a file: exported first, then by descending rank. */
function orderedSymbolsForFile(file, rankedByPath) {
    const byKey = new Map();
    for (const symbol of file.symbols) {
        byKey.set(symbolKey({ type: symbol.type, name: symbol.name, line: symbol.line, containerName: symbol.containerName }), symbol);
    }
    const seen = new Set();
    const built = [];
    for (const node of rankedByPath) {
        const key = symbolKey({ type: node.type, name: node.name, line: node.line, containerName: node.containerName });
        const symbol = byKey.get(key);
        if (!symbol || seen.has(key))
            continue;
        seen.add(key);
        built.push({
            nodeId: node.nodeId,
            name: symbol.name,
            kind: toNormalizedSymbolKind(symbol.type, symbol.containerName),
            signature: symbol.fullSignature ?? symbol.signature ?? symbol.name,
            line: symbol.line,
            endLine: symbol.endLine,
            containerName: symbol.containerName ?? node.containerName,
            exported: symbol.exported ?? false,
            jsdoc: symbol.jsdoc,
        });
    }
    // Stable partition: exported first, preserving the rank order within each group.
    const exported = built.filter(symbol => symbol.exported);
    const rest = built.filter(symbol => !symbol.exported);
    return [...exported, ...rest];
}
/**
 * Render one symbol as a readable declaration line. The stored `signature` is
 * the nameless callable form (`(params) => Return`), so we re-attach the name
 * and a kind keyword to produce e.g. `export function foo(a: number): void`.
 */
export function repoMapSignatureLine(symbol) {
    const prefix = symbol.exported ? 'export ' : '';
    const sig = symbol.signature;
    if (symbol.kind === 'function' || symbol.kind === 'method') {
        const keyword = symbol.kind === 'method' ? '' : 'function ';
        const body = sig.startsWith('(') ? `${symbol.name}${sig}` : sig && sig !== symbol.name ? sig : `${symbol.name}()`;
        return `${prefix}${keyword}${body}`;
    }
    const kindWord = symbol.kind === 'type' ? 'type' : symbol.kind;
    if (sig && sig !== symbol.name && !sig.startsWith('(')) {
        return `${prefix}${kindWord} ${symbol.name} ${sig}`;
    }
    return `${prefix}${kindWord} ${symbol.name}`;
}
/**
 * Markdown serialization of a single entry. Shared between token measurement
 * (here) and the markdown renderer in `output.ts`, so the enforced budget
 * matches the emitted document exactly.
 */
export function repoMapEntryToMarkdown(entry, mode, layout = 'ranked') {
    const lines = [
        layout === 'stable'
            ? `## ${entry.relativePath}`
            : `## ${entry.relativePath}  (score ${entry.normalizedScore.toFixed(1)})`,
    ];
    if (mode === 'pack' && entry.content != null) {
        if (entry.contentReason)
            lines.push(`<!-- ${entry.contentReason} -->`);
        lines.push('```' + entry.language);
        lines.push(entry.content);
        lines.push('```');
    }
    else {
        for (const symbol of entry.symbols)
            lines.push(`- ${repoMapSignatureLine(symbol)}`);
    }
    return lines.join('\n');
}
/** Plain-text serialization of one entry, excluding the document separator. */
export function repoMapEntryToPlain(entry, mode, layout = 'ranked') {
    const lines = [
        layout === 'stable' ? entry.relativePath : `${entry.relativePath}  [${entry.normalizedScore.toFixed(1)}]`,
    ];
    if (mode === 'pack' && entry.content != null) {
        for (const line of entry.content.split('\n'))
            lines.push(`    ${line}`);
    }
    else {
        for (const symbol of entry.symbols)
            lines.push(`    ${repoMapSignatureLine(symbol)}`);
    }
    return lines.join('\n');
}
function escapeXml(value) {
    return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
/** XML serialization of one entry, excluding the document separator. */
export function repoMapEntryToXml(entry, mode, layout = 'ranked') {
    // `focusScore` stays in the stable layout: it is entry-local (normalized
    // against the fixed saturation, not a per-run max) and it is XML's only
    // per-entry focus carrier. Only the eigenvector-coupled `score` is dropped.
    const focusAttr = entry.focusScore != null ? ` focusScore="${entry.focusScore.toFixed(1)}"` : '';
    const scoreAttr = layout === 'stable' ? '' : ` score="${entry.normalizedScore.toFixed(1)}"`;
    const lines = [
        `  <file path="${escapeXml(entry.relativePath)}"${scoreAttr}${focusAttr} language="${escapeXml(entry.language)}">`,
    ];
    for (const symbol of entry.symbols) {
        lines.push(`    <symbol kind="${escapeXml(symbol.kind)}" exported="${symbol.exported}"${symbol.line != null ? ` line="${symbol.line}"` : ''}>${escapeXml(symbol.signature)}</symbol>`);
    }
    if (mode === 'pack' && entry.content != null) {
        lines.push(`    <content${entry.contentReason ? ` reason="${escapeXml(entry.contentReason)}"` : ''}>${escapeXml(entry.content)}</content>`);
    }
    lines.push('  </file>');
    return lines.join('\n');
}
/**
 * The stable-layout wire projection of one entry: no absolute paths anywhere —
 * not the `path` field, not inside `symbols[].nodeId` (whose graph ids embed
 * the checkout path), not in path-derived `focusReasons` — so the document is
 * genuinely relocatable across checkouts. Relativized node ids resolve through
 * `--node-id` via the repo-relative alias `readCodeSource` accepts. No
 * `score`/`normalizedScore` (eigenvector-coupled). Key order is the byte order.
 */
function stableJsonEntry(entry) {
    // The checkout prefix (with its trailing separator) is recoverable from the
    // entry itself, so the projection needs no extra plumbing.
    const repoPrefix = entry.path.length > entry.relativePath.length && entry.path.endsWith(entry.relativePath)
        ? entry.path.slice(0, entry.path.length - entry.relativePath.length)
        : undefined;
    const strip = (value) => (repoPrefix !== undefined ? value.split(repoPrefix).join('') : value);
    const symbols = repoPrefix !== undefined
        ? entry.symbols.map(symbol => ({ ...symbol, nodeId: strip(symbol.nodeId) }))
        : entry.symbols;
    const focusReasons = entry.focusReasons != null && repoPrefix !== undefined ? entry.focusReasons.map(strip) : entry.focusReasons;
    return {
        relativePath: entry.relativePath,
        language: entry.language,
        symbols,
        ...(entry.content != null ? { content: entry.content } : {}),
        ...(entry.contentReason != null ? { contentReason: entry.contentReason } : {}),
        tokens: entry.tokens,
        droppedSymbols: entry.droppedSymbols,
        ...(entry.focusScore != null ? { focusScore: entry.focusScore } : {}),
        ...(focusReasons != null ? { focusReasons } : {}),
        ...(entry.changed != null ? { changed: entry.changed } : {}),
        ...(entry.churn != null ? { churn: entry.churn } : {}),
    };
}
/** JSON serialization of one entry as it appears inside the document array. */
export function repoMapEntryToJson(entry, pretty = false, layout = 'ranked') {
    const serialized = formatPayloadJson(layout === 'stable' ? stableJsonEntry(entry) : entry, pretty);
    return pretty
        ? serialized
            .split('\n')
            .map(line => `    ${line}`)
            .join('\n')
        : serialized;
}
/** One-line description of an active focus, or '' when there is none. */
function focusDescriptor(result) {
    const focus = result.focus;
    if (!focus)
        return '';
    const parts = [];
    if (focus.query)
        parts.push(`query "${focus.query}"`);
    if (focus.paths.length > 0)
        parts.push(`${focus.paths.length} path(s)`);
    const what = parts.length > 0 ? parts.join(' + ') : 'focus';
    return `${what}, ${focus.matchedFiles} matched`;
}
function repoMapHeader(result) {
    const tokenizer = result.tokenizer && result.tokenizer !== 'heuristic' ? `, ${result.tokenizer}` : '';
    const focus = result.focus ? ` — focus: ${focusDescriptor(result)}` : '';
    return `# Repo Map — ${result.entries.length}/${result.totalFiles} files, ${result.tokensUsed}/${result.tokenBudget} tokens${tokenizer}${focus}`;
}
function repoMapFooter(result) {
    if (!result.truncated)
        return null;
    const parts = [];
    if (result.droppedFiles > 0)
        parts.push(`${result.droppedFiles} file(s)`);
    if (result.droppedSymbols > 0)
        parts.push(`${result.droppedSymbols} symbol(s)`);
    return `_${parts.join(' / ')} omitted to fit the ${result.tokenBudget}-token budget._`;
}
export function formatRepoMapMarkdown(result) {
    const blocks = [repoMapHeader(result), ''];
    for (const entry of result.entries)
        blocks.push(repoMapEntryToMarkdown(entry, result.mode));
    const footer = repoMapFooter(result);
    if (footer)
        blocks.push('', footer);
    return blocks.join('\n\n') + '\n';
}
export function formatRepoMapPlain(result) {
    const focus = result.focus ? ` — focus: ${focusDescriptor(result)}` : '';
    const lines = [
        `Repo Map (${result.entries.length}/${result.totalFiles} files, ${result.tokensUsed}/${result.tokenBudget} tokens)${focus}`,
        '',
    ];
    for (const entry of result.entries)
        lines.push(repoMapEntryToPlain(entry, result.mode), '');
    const footer = repoMapFooter(result);
    if (footer)
        lines.push(footer.replace(/_/g, ''));
    return lines.join('\n') + '\n';
}
function repoMapFocusXmlAttrs(result) {
    const focus = result.focus;
    if (!focus)
        return '';
    const query = focus.query ? ` focusQuery="${escapeXml(focus.query)}"` : '';
    const paths = focus.paths.length > 0 ? ` focusPaths="${escapeXml(focus.paths.join(';'))}"` : '';
    return `${query}${paths} focusMatched="${focus.matchedFiles}"`;
}
export function formatRepoMapXml(result) {
    const lines = [
        `<repomap mode="${result.mode}" tokens="${result.tokensUsed}" budget="${result.tokenBudget}" files="${result.entries.length}" totalFiles="${result.totalFiles}"${result.truncated ? ' truncated="true"' : ''}${repoMapFocusXmlAttrs(result)}>`,
    ];
    for (const entry of result.entries)
        lines.push(repoMapEntryToXml(entry, result.mode));
    lines.push('</repomap>');
    return lines.join('\n') + '\n';
}
// ---------------------------------------------------------------------------
// Stable layout rendering: option-derived text in the prefix, volatile
// aggregates in a constant-position tail, no per-entry rank scores anywhere.
// ---------------------------------------------------------------------------
export const RepoMapStableEntrySchema = RepoMapEntrySchema.omit({
    path: true,
    score: true,
    normalizedScore: true,
});
/**
 * The documented wire shape of a stable-layout JSON map. A projection built at
 * render time — the in-memory {@link RepoMapResult} keeps `meta` and scores
 * for library callers; the wire omits `meta` entirely (its `loadSource` flips
 * between the first and second run, which is exactly the cold/warm byte leak
 * this layout exists to close) and puts every volatile aggregate last.
 */
export const RepoMapStableDocumentSchema = z.object({
    layout: z.literal('stable'),
    mode: RepoMapModeSchema,
    format: RepoMapFormatSchema,
    tokenizer: z.string().optional(),
    tokenBudget: z.number(),
    entries: z.array(RepoMapStableEntrySchema),
    tail: z.object({
        tokensUsed: z.number(),
        totalFiles: z.number(),
        droppedFiles: z.number(),
        droppedSymbols: z.number(),
        truncated: z.boolean(),
        rankIterations: z.number(),
        rankConverged: z.boolean(),
        focus: RepoMapFocusResultSchema.optional(),
    }),
});
function stableJsonDocument(result) {
    return {
        layout: 'stable',
        mode: result.mode,
        format: result.format,
        ...(result.tokenizer !== undefined ? { tokenizer: result.tokenizer } : {}),
        tokenBudget: result.tokenBudget,
        entries: result.entries.map(entry => stableJsonEntry(entry)),
        tail: {
            tokensUsed: result.tokensUsed,
            totalFiles: result.totalFiles,
            droppedFiles: result.droppedFiles,
            droppedSymbols: result.droppedSymbols,
            truncated: result.truncated,
            rankIterations: result.rankIterations,
            rankConverged: result.rankConverged,
            ...(result.focus ? { focus: result.focus } : {}),
        },
    };
}
/** The volatile counts line shared by the markdown and plain tail blocks. */
function stableCountsLine(result) {
    const omitted = [];
    if (result.droppedFiles > 0)
        omitted.push(`${result.droppedFiles} file(s)`);
    if (result.droppedSymbols > 0)
        omitted.push(`${result.droppedSymbols} symbol(s)`);
    const omittedPart = omitted.length > 0 ? `; ${omitted.join(' / ')} omitted` : '';
    const focus = result.focus ? `; focus: ${focusDescriptor(result)}` : '';
    return `${result.entries.length}/${result.totalFiles} files, ${result.tokensUsed}/${result.tokenBudget} tokens${omittedPart}${focus}`;
}
function stableTokenizerSuffix(result) {
    return result.tokenizer && result.tokenizer !== 'heuristic' ? `, ${result.tokenizer}` : '';
}
function formatRepoMapMarkdownStable(result) {
    // The header carries only option-derived text; every volatile aggregate
    // lives after the constant `---` marker, so an agent wanting a fully static
    // prefix can truncate at the final `\n\n---\n\n`.
    const header = `# Repo Map — stable layout, budget ${result.tokenBudget}${stableTokenizerSuffix(result)}`;
    const blocks = [header, ''];
    for (const entry of result.entries)
        blocks.push(repoMapEntryToMarkdown(entry, result.mode, 'stable'));
    blocks.push('---', `_${stableCountsLine(result)}_`);
    return blocks.join('\n\n') + '\n';
}
function formatRepoMapPlainStable(result) {
    const lines = [`Repo Map (stable, budget ${result.tokenBudget}${stableTokenizerSuffix(result)})`, ''];
    for (const entry of result.entries)
        lines.push(repoMapEntryToPlain(entry, result.mode, 'stable'), '');
    lines.push('---', stableCountsLine(result));
    return lines.join('\n') + '\n';
}
function formatRepoMapXmlStable(result) {
    const tokenizer = result.tokenizer !== undefined ? ` tokenizer="${escapeXml(result.tokenizer)}"` : '';
    const lines = [
        `<repomap mode="${result.mode}" layout="stable" budget="${result.tokenBudget}"${tokenizer}>`,
    ];
    for (const entry of result.entries)
        lines.push(repoMapEntryToXml(entry, result.mode, 'stable'));
    lines.push(`  <tail tokens="${result.tokensUsed}" files="${result.entries.length}" totalFiles="${result.totalFiles}" droppedFiles="${result.droppedFiles}" droppedSymbols="${result.droppedSymbols}"${result.truncated ? ' truncated="true"' : ''}${repoMapFocusXmlAttrs(result)}/>`);
    lines.push('</repomap>');
    return lines.join('\n') + '\n';
}
/** Serialize exactly the format whose bytes are charged to `tokenBudget`. */
export function renderRepoMap(result, pretty = false) {
    if (result.layout === 'stable') {
        switch (result.format) {
            case 'json':
                return `${formatPayloadJson(stableJsonDocument(result), pretty)}\n`;
            case 'xml':
                return formatRepoMapXmlStable(result);
            case 'plain':
                return formatRepoMapPlainStable(result);
            case 'markdown':
            default:
                return formatRepoMapMarkdownStable(result);
        }
    }
    switch (result.format) {
        case 'json':
            return `${formatPayloadJson(result, pretty)}\n`;
        case 'xml':
            return formatRepoMapXml(result);
        case 'plain':
            return formatRepoMapPlain(result);
        case 'markdown':
        default:
            return formatRepoMapMarkdown(result);
    }
}
function repoMapEntrySerialization(entry, mode, format, pretty = false, layout = 'ranked') {
    switch (format) {
        case 'plain':
            return repoMapEntryToPlain(entry, mode, layout);
        case 'xml':
            return repoMapEntryToXml(entry, mode, layout);
        case 'json':
            return repoMapEntryToJson(entry, pretty, layout);
        case 'markdown':
            return repoMapEntryToMarkdown(entry, mode, layout);
    }
}
function settleMeasuredValue(read, write, render, countTokens, label) {
    const seen = new Set();
    for (let attempt = 0; attempt < 32; attempt++) {
        const measured = countTokens(render());
        if (measured === read())
            return measured;
        const state = `${read()}:${measured}`;
        if (seen.has(state)) {
            throw new Error(`Could not stabilize ${label} token accounting; choose a deterministic tokenizer.`);
        }
        seen.add(state);
        write(measured);
    }
    throw new Error(`Could not stabilize ${label} token accounting after 32 passes.`);
}
function measureRepoMap(result, countTokens, pretty = false) {
    for (const entry of result.entries) {
        settleMeasuredValue(() => entry.tokens, value => {
            entry.tokens = value;
        }, () => repoMapEntrySerialization(entry, result.mode, result.format, pretty, result.layout ?? 'ranked'), countTokens, `${result.format} repo-map entry`);
    }
    return settleMeasuredValue(() => result.tokensUsed, value => {
        result.tokensUsed = value;
    }, () => renderRepoMap(result, pretty), countTokens, `${result.format} repo-map document`);
}
/**
 * Which admission slot each entry took, so the stable layout can shed exactly
 * the entry ranked admission would have shed. A WeakMap rather than a field:
 * JSON serializes every entry field, and an admission index on the wire would
 * both leak and re-introduce the rank-order churn the layout removes.
 */
const admissionOrder = new WeakMap();
/**
 * The rebudget victim. Ranked entries are still in admission order, so the
 * last one is the lowest-ranked; stable entries are path-ordered, where
 * `entries.at(-1)` would silently shed the path-last (possibly top-ranked)
 * file — the recorded admission index reproduces ranked's choice under any
 * ordering, focus-blended or not.
 */
function shedVictim(result) {
    if (result.layout !== 'stable')
        return result.entries.at(-1);
    let victim;
    let latest = -1;
    for (const entry of result.entries) {
        const order = admissionOrder.get(entry);
        if (order !== undefined && order > latest) {
            latest = order;
            victim = entry;
        }
    }
    if (victim)
        return victim;
    // Entries without admission identity (a hand-constructed result rather than
    // buildRepoMap's own objects): the lowest in-memory score reproduces
    // ranked's choice for the unfocused case rather than shedding the
    // path-last — possibly top-ranked — file.
    let lowest;
    for (const entry of result.entries) {
        if (!lowest || entry.score < lowest.score)
            lowest = entry;
    }
    return lowest ?? result.entries.at(-1);
}
/**
 * Re-measure and, if needed, shed whole tail entries/symbols until emitted
 * bytes fit. Stable-layout shedding relies on the admission identity recorded
 * on the entry objects `buildRepoMap` produced; a result reconstructed from
 * serialized data falls back to the lowest-scored entry.
 */
export function rebudgetRepoMapResult(result, countTokens = estimateTokenCount, pretty = false) {
    result.truncated = result.droppedFiles > 0 || result.droppedSymbols > 0;
    while (measureRepoMap(result, countTokens, pretty) > result.tokenBudget) {
        const tail = shedVictim(result);
        if (!tail) {
            throw new Error(`The empty ${result.format} repo-map envelope exceeds the ${result.tokenBudget}-token budget; increase --map-tokens.`);
        }
        if (result.mode === 'map' && tail.symbols.length > 1) {
            tail.symbols.pop();
            tail.droppedSymbols++;
            result.droppedSymbols++;
        }
        else {
            result.entries.splice(result.entries.indexOf(tail), 1);
            result.droppedFiles++;
            result.droppedSymbols += tail.symbols.length;
        }
        result.truncated = true;
    }
    return result;
}
function buildContentEntry(file, charBudget) {
    try {
        const section = extractKeySection(file, Math.max(charBudget, 200));
        return { content: section.content, reason: section.reason };
    }
    catch {
        return null;
    }
}
/**
 * Fixed and per-entry token overhead the selected renderer wraps around entry
 * bodies. The reserve is itself produced by the real serializer, including the
 * JSON object envelope, rather than by a format-independent approximation.
 */
function documentChromeReserve(format, mode, budget, totalFiles, focus, tokenizer, rankIterations, rankConverged, meta, countTokens, pretty = false, layout = 'ranked') {
    // The envelope must carry the selected layout: the stable tail is priced
    // with `tokensUsed: budget` (maximal digit width, the same conservative
    // trick the ranked header uses), and forgetting the layout here would price
    // ranked chrome for a stable document.
    const envelope = {
        mode,
        format,
        ...(layout === 'stable' ? { layout: 'stable' } : {}),
        ...(meta ? { meta } : {}),
        tokenizer,
        tokenBudget: budget,
        tokensUsed: budget,
        totalFiles,
        entries: [],
        droppedFiles: totalFiles,
        droppedSymbols: totalFiles,
        truncated: totalFiles > 0,
        rankIterations,
        rankConverged,
        focus,
    };
    const separator = format === 'markdown' ? '\n\n' : format === 'json' ? (pretty ? ',\n' : ',') : '\n';
    return {
        fixed: countTokens(renderRepoMap(envelope, pretty)),
        perEntry: countTokens(separator),
    };
}
/**
 * Build a token-budgeted, importance-ranked repo map (or pack) from an index.
 *
 * The index should be treated as immutable after construction: the underlying
 * ranking is memoized per index object (see `rankNodesCached`) with only a
 * `files`/`nodes`/`edges` length fingerprint, so in-place mutations that keep
 * those lengths constant are not detected and reuse the cached ranking.
 *
 * @throws {TokenBudgetRefusalError} When the selected renderer's empty,
 * honesty-marked envelope cannot fit the requested budget.
 */
export function buildRepoMap(index, options) {
    const mode = options.mode ?? 'map';
    const format = options.format ?? 'markdown';
    const layout = options.layout ?? 'ranked';
    const compress = options.compress ?? false;
    const countTokens = options.countTokens ?? estimateTokenCount;
    const budget = options.tokenBudget;
    const pretty = options.pretty ?? false;
    // Cached per index object: repeated map calls (e.g. focused maps) skip
    // PageRank. The shared RankResult is read-only here — fileScores is copied
    // before any sort and nodes are only iterated.
    const ranking = rankNodesCached(index, options.rank);
    const fileByPath = new Map();
    for (const file of index.files)
        fileByPath.set(file.path, file);
    const rankedSymbolsByPath = new Map();
    for (const node of ranking.nodes) {
        if (node.type === 'file' || node.type === 'module')
            continue;
        const list = rankedSymbolsByPath.get(node.path);
        if (list)
            list.push(node);
        else
            rankedSymbolsByPath.set(node.path, [node]);
    }
    // Focus (opt-in): blend per-file relevance with the global structural rank and
    // re-sort the admission order. The greedy loop below only depends on iteration
    // order, so this is the entire behavioral change — budgeting is untouched.
    const focus = options.focus;
    let orderedFileScores = ranking.fileScores;
    let focusByPath;
    let focusResult;
    if (isFocusActive(focus)) {
        focusByPath = computeFocusScores(index, focus);
        const focusScores = focusByPath;
        orderedFileScores = [...ranking.fileScores]
            .map(fileScore => {
            const focusNormalized = Math.min(1, (focusScores.get(fileScore.path)?.score ?? 0) / FOCUS_SATURATION);
            const globalNormalized = fileScore.normalizedScore / 100;
            return {
                fileScore,
                combined: globalNormalized * FOCUS_GLOBAL_WEIGHT + focusNormalized * FOCUS_RELEVANCE_WEIGHT,
            };
        })
            .sort((a, b) => b.combined - a.combined ||
            b.fileScore.score - a.fileScore.score ||
            compareCodeUnits(a.fileScore.relativePath, b.fileScore.relativePath))
            .map(item => item.fileScore);
        focusResult = {
            query: (focus.query ?? '').trim() || undefined,
            paths: (focus.paths ?? []).map(value => value.trim()).filter(Boolean),
            matchedFiles: [...focusByPath.values()].filter(entry => entry.score > 0).length,
        };
        if (layout === 'stable') {
            // Changed-focus maps feed absolute paths in; a relocatable stable
            // document must echo them repo-relative. Relativized before any pricing
            // so the chrome reserve measures the bytes that will actually render.
            focusResult = {
                ...focusResult,
                paths: focusResult.paths.map(value => path.isAbsolute(value) && value.startsWith(index.repoRoot + path.sep)
                    ? path.relative(index.repoRoot, value).replace(/\\/g, '/')
                    : value),
            };
        }
    }
    const emptyEnvelope = {
        mode,
        format,
        ...(layout === 'stable' ? { layout: 'stable' } : {}),
        ...(options.meta ? { meta: options.meta } : {}),
        tokenizer: options.tokenizer,
        tokenBudget: budget,
        tokensUsed: 0,
        totalFiles: ranking.fileScores.length,
        entries: [],
        droppedFiles: ranking.fileScores.length,
        droppedSymbols: ranking.nodes.filter(node => node.type !== 'file' && node.type !== 'module').length,
        truncated: ranking.fileScores.length > 0,
        rankIterations: ranking.iterations,
        rankConverged: ranking.converged,
        focus: focusResult,
    };
    const required = measureRepoMap(emptyEnvelope, countTokens, pretty);
    if (required > budget) {
        throw new TokenBudgetRefusalError(buildTokenBudgetRefusal(required, budget, mapNarrowingOptions(index, options, mode, format, required, countTokens)));
    }
    const entries = [];
    let tokensUsed = 0;
    let droppedFiles = 0;
    let droppedSymbols = 0;
    // Reserve the format's fixed document chrome (header/footer/separators) up
    // front and charge each admitted entry its block separator, so the rendered
    // document — not merely the summed entry bodies — stays within budget.
    const chrome = documentChromeReserve(format, mode, budget, ranking.fileScores.length, focusResult, options.tokenizer, ranking.iterations, ranking.converged, options.meta, countTokens, pretty, layout);
    const effectiveBudget = budget - chrome.fixed;
    for (const fileScore of orderedFileScores) {
        const file = fileByPath.get(fileScore.path);
        if (!file)
            continue;
        const rankedSymbols = rankedSymbolsByPath.get(file.path) ?? [];
        const remaining = effectiveBudget - tokensUsed;
        if (remaining <= 0) {
            // Once the budget is spent, no later entry can be admitted. The ranked
            // list is already the source of the projected symbols, so its cardinality
            // gives the same total drop accounting without rebuilding every tail
            // file's symbol map and rendered projection.
            if (mode === 'map' && rankedSymbols.length === 0)
                continue;
            droppedFiles++;
            droppedSymbols += rankedSymbols.length;
            continue;
        }
        let symbols = orderedSymbolsForFile(file, rankedSymbols);
        if (options.maxSymbolsPerFile != null && symbols.length > options.maxSymbolsPerFile) {
            droppedSymbols += symbols.length - options.maxSymbolsPerFile;
            symbols = symbols.slice(0, options.maxSymbolsPerFile);
        }
        let content;
        let contentReason;
        if (mode === 'pack') {
            const charBudget = compress
                ? Math.min(DEFAULT_COMPRESS_CHARS, remaining * APPROX_CHARS_PER_TOKEN)
                : remaining * APPROX_CHARS_PER_TOKEN;
            const built = buildContentEntry(file, charBudget);
            if (built) {
                content = built.content;
                contentReason = built.reason;
            }
        }
        // Map mode with no symbols carries no information — skip it.
        if (mode === 'map' && symbols.length === 0)
            continue;
        if (mode === 'pack' && content == null && symbols.length === 0)
            continue;
        const makeEntry = (entrySymbols, entryContent) => {
            const entry = {
                relativePath: file.relativePath,
                path: file.path,
                language: file.language,
                score: fileScore.score,
                normalizedScore: fileScore.normalizedScore,
                symbols: entrySymbols,
                content: entryContent,
                contentReason: entryContent != null ? contentReason : undefined,
                tokens: 0,
                droppedSymbols: 0,
            };
            // Focus metadata stays OUT of `repoMapEntryToMarkdown`, so token budgeting
            // is identical whether or not a focus is active.
            if (focusByPath) {
                const fileFocus = focusByPath.get(file.path);
                entry.focusScore = round1(Math.min(1, (fileFocus?.score ?? 0) / FOCUS_SATURATION) * 100);
                if (fileFocus && fileFocus.reasons.length > 0)
                    entry.focusReasons = fileFocus.reasons;
            }
            settleMeasuredValue(() => entry.tokens, value => {
                entry.tokens = value;
            }, () => repoMapEntrySerialization(entry, mode, format, pretty, layout), countTokens, `${format} repo-map entry`);
            return entry;
        };
        let entry = makeEntry(symbols, content);
        if (tokensUsed + entry.tokens + chrome.perEntry <= effectiveBudget) {
            tokensUsed += entry.tokens + chrome.perEntry;
            admissionOrder.set(entry, entries.length);
            entries.push(entry);
            continue;
        }
        // Partial admission — shrink until it fits or the file must be dropped.
        if (mode === 'pack' && content != null) {
            const shrunkBudget = Math.max(200, Math.floor((effectiveBudget - tokensUsed) * APPROX_CHARS_PER_TOKEN * 0.9));
            const shrunk = buildContentEntry(file, shrunkBudget);
            if (shrunk) {
                content = shrunk.content;
                contentReason = shrunk.reason;
                entry = makeEntry(symbols, content);
            }
        }
        else {
            // map mode: drop lowest-ranked symbols from the tail until it fits. Locate
            // the cut by subtracting each dropped symbol's line cost from the exact
            // full-entry count already measured above (avoiding an O(S^2) re-render of
            // the whole entry per step), then confirm the admitted prefix with an
            // exact re-count.
            let kept = symbols.length;
            let approxTokens = entry.tokens;
            while (kept > 0 && tokensUsed + approxTokens + chrome.perEntry > effectiveBudget) {
                kept--;
                approxTokens -= countTokens(`\n- ${repoMapSignatureLine(symbols[kept])}`);
            }
            while (kept > 0) {
                const trial = makeEntry(symbols.slice(0, kept));
                if (tokensUsed + trial.tokens + chrome.perEntry <= effectiveBudget) {
                    entry = trial;
                    break;
                }
                kept--;
            }
            if (kept === 0) {
                droppedFiles++;
                droppedSymbols += symbols.length;
                continue;
            }
            droppedSymbols += symbols.length - kept;
            entry.droppedSymbols = symbols.length - kept;
        }
        if (tokensUsed + entry.tokens + chrome.perEntry <= effectiveBudget) {
            tokensUsed += entry.tokens + chrome.perEntry;
            admissionOrder.set(entry, entries.length);
            entries.push(entry);
        }
        else {
            droppedFiles++;
            droppedSymbols += entry.symbols.length;
        }
    }
    if (layout === 'stable') {
        // Presentation only: rank decided the admitted set above; path order makes
        // unchanged files render byte-identical blocks in unchanged relative order
        // across edits elsewhere. relativePath is unique per entry, so this is a
        // total order. The rebudget pass below re-measures the re-ordered bytes.
        entries.sort((a, b) => compareCodeUnits(a.relativePath, b.relativePath));
    }
    const result = {
        mode,
        format,
        ...(layout === 'stable' ? { layout: 'stable' } : {}),
        ...(options.meta ? { meta: options.meta } : {}),
        tokenizer: options.tokenizer,
        tokenBudget: budget,
        tokensUsed,
        totalFiles: ranking.fileScores.length,
        entries,
        droppedFiles,
        droppedSymbols,
        truncated: droppedFiles > 0 || droppedSymbols > 0,
        rankIterations: ranking.iterations,
        rankConverged: ranking.converged,
        focus: focusResult,
    };
    return rebudgetRepoMapResult(result, countTokens, pretty);
}
// ---------------------------------------------------------------------------
// Changed-focus repo map
// ---------------------------------------------------------------------------
export const ChangedRepoMapRangeSchema = z.object({
    /** Base ref to diff from (merge-base three-dot semantics, as in `git diff base...head`). */
    base: z.string().optional(),
    /** Head ref to diff to; defaults to HEAD. */
    head: z.string().optional(),
    /** Use staged changes instead of a ref range. */
    staged: z.boolean().optional(),
});
/**
 * Resolve a git change range into repo-map focus inputs. Split out from
 * {@link buildChangedRepoMap} so callers holding a diff summary (or a CLI
 * that already fetched one) can reuse the same merge/stamp semantics.
 */
export function toRepoMapChangeContext(repoRoot, summary) {
    const numstatByPath = new Map(summary.numstat.map(entry => [entry.path, entry]));
    const focusPaths = [];
    const churnByPath = new Map();
    for (const file of summary.files) {
        if (file.status === 'deleted')
            continue;
        const absolutePath = path.resolve(repoRoot, file.path);
        const numstat = numstatByPath.get(file.path);
        focusPaths.push(absolutePath);
        churnByPath.set(absolutePath, {
            added: numstat?.added ?? 0,
            deleted: numstat?.deleted ?? 0,
            binary: file.binary === true || numstat?.binary === true,
        });
    }
    return { focusPaths, churnByPath };
}
/** Stamp changed/churn metadata onto admitted entries (idempotent). */
export function stampRepoMapChanges(result, context, countTokens = estimateTokenCount, pretty = false) {
    for (const entry of result.entries) {
        const churn = context.churnByPath.get(entry.path);
        if (!churn)
            continue;
        entry.changed = true;
        entry.churn = churn;
    }
    return rebudgetRepoMapResult(result, countTokens, pretty);
}
/**
 * {@link buildRepoMap} focused on a git change range: files changed in the
 * range are added to the focus set (merged with any caller-provided focus),
 * and admitted entries carry `changed`/`churn` metadata. Async because it
 * shells out to git for the diff summary; the map itself builds like any
 * other repo map, so token budgeting is unchanged.
 */
export async function buildChangedRepoMap(index, options) {
    const { changed, ...mapOptions } = options;
    // `relative`: the index may be rooted at a repository subdirectory, and git
    // reports toplevel-relative paths; without rebasing, every changed path
    // would resolve to a nonexistent file and the focus would silently no-op.
    const summary = await getGitDiffSummary({
        repoRoot: index.repoRoot,
        base: changed.base,
        head: changed.head,
        staged: changed.staged,
        relative: true,
    });
    const context = toRepoMapChangeContext(index.repoRoot, summary);
    const existingFocus = mapOptions.focus;
    const mergedPaths = [...new Set([...(existingFocus?.paths ?? []), ...context.focusPaths])];
    const focus = (existingFocus?.query ?? '').trim().length > 0 || mergedPaths.length > 0
        ? { ...existingFocus, paths: mergedPaths }
        : existingFocus;
    const result = buildRepoMap(index, { ...mapOptions, focus });
    return stampRepoMapChanges(result, context, mapOptions.countTokens, mapOptions.pretty);
}
//# sourceMappingURL=map.js.map