/**
 * 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 { getGitDiffSummary } from '../git/api.js';
import { extractKeySection } from './query.js';
import { toNormalizedSymbolKind } from './types.js';
import { rankNodesCached, RankOptionsSchema } from './rank.js';
import { computeFocusScores } from './focus.js';
export const RepoMapFormatSchema = z.enum(['markdown', 'xml', 'json', 'plain']);
export const RepoMapModeSchema = z.enum(['map', 'pack']);
/** 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(),
    compress: z.boolean().optional(),
    maxSymbolsPerFile: z.number().int().positive().optional(),
    rank: RankOptionsSchema.optional(),
    focus: RepoMapFocusSchema.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(),
    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,
    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;
// 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;
}
/** 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) {
    const lines = [`## ${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');
}
function buildContentEntry(file, charBudget) {
    try {
        const section = extractKeySection(file, Math.max(charBudget, 200));
        return { content: section.content, reason: section.reason };
    }
    catch {
        return null;
    }
}
/** Mirrors the focus descriptor the markdown/plain header renders, so its chrome is reserved accurately. */
function chromeFocusDescriptor(focus) {
    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`;
}
/**
 * Fixed and per-entry token overhead the format renderer wraps around the entry
 * bodies — the header line, block separators, truncation footer, and trailing
 * newline. The emitted document (not just the summed entry bodies) must fit the
 * budget, so this overhead is reserved before entries are admitted. Numeric
 * fields use budget / total-file upper bounds and the widest tokenizer suffix so
 * the reservation never underestimates the rendered chrome. `json` output is a
 * structural serialization unrelated to the entry markdown, so it reserves
 * nothing.
 */
function documentChromeReserve(format, budget, totalFiles, focus, countTokens) {
    if (format === 'json')
        return { fixed: 0, perEntry: 0 };
    const filesField = `${totalFiles}/${totalFiles} files`;
    const tokenField = `${budget}/${budget} tokens`;
    const omitted = `${totalFiles} file(s) / ${totalFiles} symbol(s) omitted to fit the ${budget}-token budget.`;
    const focusSuffix = focus ? ` — focus: ${chromeFocusDescriptor(focus)}` : '';
    if (format === 'plain') {
        const header = `Repo Map (${filesField}, ${tokenField})${focusSuffix}`;
        return {
            fixed: countTokens(header) + countTokens(omitted) + countTokens('\n') * 3,
            perEntry: countTokens('\n') * 2,
        };
    }
    if (format === 'xml') {
        const open = `<repomap mode="pack" tokens="${budget}" budget="${budget}" files="${totalFiles}" totalFiles="${totalFiles}" truncated="true">`;
        return {
            fixed: countTokens(open) + countTokens('</repomap>') + countTokens('\n') * 2,
            perEntry: countTokens('\n'),
        };
    }
    // markdown: `# Repo Map — E/M files, U/B tokens[, tokenizer][ — focus: …]`,
    // blocks joined by `\n\n`, an optional italic footer, and a trailing newline.
    const header = `# Repo Map — ${filesField}, ${tokenField}, cl100k_base${focusSuffix}`;
    const footer = `_${omitted}_`;
    const separator = '\n\n';
    return {
        fixed: countTokens(header) + countTokens(footer) + countTokens(separator) * 3 + countTokens('\n'),
        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.
 */
export function buildRepoMap(index, options) {
    const mode = options.mode ?? 'map';
    const format = options.format ?? 'markdown';
    const compress = options.compress ?? false;
    const countTokens = options.countTokens ?? estimateTokenCount;
    const budget = options.tokenBudget;
    // 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)
            .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,
        };
    }
    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, budget, ranking.fileScores.length, focusResult, countTokens);
    const effectiveBudget = budget - chrome.fixed;
    for (const fileScore of orderedFileScores) {
        const file = fileByPath.get(fileScore.path);
        if (!file)
            continue;
        let symbols = orderedSymbolsForFile(file, rankedSymbolsByPath.get(file.path) ?? []);
        if (options.maxSymbolsPerFile != null && symbols.length > options.maxSymbolsPerFile) {
            droppedSymbols += symbols.length - options.maxSymbolsPerFile;
            symbols = symbols.slice(0, options.maxSymbolsPerFile);
        }
        const remaining = effectiveBudget - tokensUsed;
        if (remaining <= 0) {
            // Budget already spent: nothing further can be admitted. Count only files
            // that would have carried information — a no-symbol map file is skipped
            // uncounted here exactly as it is on the normal path — and add every
            // dropped file's symbols to droppedSymbols so all drop paths agree.
            if (mode === 'map' && symbols.length === 0)
                continue;
            droppedFiles++;
            droppedSymbols += symbols.length;
            continue;
        }
        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;
            }
            entry.tokens = countTokens(repoMapEntryToMarkdown(entry, mode));
            return entry;
        };
        let entry = makeEntry(symbols, content);
        if (tokensUsed + entry.tokens + chrome.perEntry <= effectiveBudget) {
            tokensUsed += entry.tokens + chrome.perEntry;
            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;
            entries.push(entry);
        }
        else {
            droppedFiles++;
            droppedSymbols += entry.symbols.length;
        }
    }
    return {
        mode,
        format,
        tokenBudget: budget,
        tokensUsed,
        totalFiles: ranking.fileScores.length,
        entries,
        droppedFiles,
        droppedSymbols,
        truncated: droppedFiles > 0 || droppedSymbols > 0,
        rankIterations: ranking.iterations,
        rankConverged: ranking.converged,
        focus: focusResult,
    };
}
// ---------------------------------------------------------------------------
// 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) {
    for (const entry of result.entries) {
        const churn = context.churnByPath.get(entry.path);
        if (!churn)
            continue;
        entry.changed = true;
        entry.churn = churn;
    }
    return result;
}
/**
 * {@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;
    const summary = await getGitDiffSummary({
        repoRoot: index.repoRoot,
        base: changed.base,
        head: changed.head,
        staged: changed.staged,
    });
    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);
}
//# sourceMappingURL=map.js.map