import { estimateTokenCount } from '../tokens.js';
import { compareCodeUnits } from '../utils.js';
import { rankNodesCached } from './rank.js';
import { createFileTokenCountCache, getFileTokenCountCache } from './token-count-cache.js';
import { toNormalizedSymbolKind } from './types.js';
const DEFAULT_TREE_MODE = 'compact';
const DEFAULT_MAX_DEPTH = 6;
const DEFAULT_MAX_ENTRIES_PER_DIR = 50;
const DEFAULT_TOKEN_BUDGET = 4096;
const DEFAULT_SYMBOLS_PER_DIRECTORY = 3;
const sharedFileTokenCountCache = createFileTokenCountCache();
function normalizeTreePath(value) {
    return value.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+/g, '/').replace(/\/$/, '');
}
function normalizeFocusPath(value) {
    if (!value)
        return undefined;
    const normalized = normalizeTreePath(value.trim());
    if (!normalized || normalized === '.')
        return undefined;
    return normalized;
}
function createDirectory(name, path) {
    return {
        name,
        path,
        directories: new Map(),
        files: [],
        fileCount: 0,
        tokenCount: 0,
        countedFileCount: 0,
    };
}
function isInFocus(relativePath, focusPath) {
    if (!focusPath)
        return true;
    return relativePath === focusPath || relativePath.startsWith(`${focusPath}/`);
}
function pathWithinFocus(relativePath, focusPath) {
    if (!focusPath)
        return relativePath;
    if (relativePath === focusPath)
        return relativePath.split('/').at(-1) ?? relativePath;
    return relativePath.slice(focusPath.length + 1);
}
function joinTreePath(base, segment) {
    if (!base || base === '.')
        return segment;
    return `${base}/${segment}`;
}
function insertFile(root, file, focusPath) {
    const relativePath = normalizeTreePath(file.relativePath);
    const localPath = pathWithinFocus(relativePath, focusPath);
    const parts = localPath.split('/').filter(Boolean);
    if (parts.length === 0)
        return;
    root.fileCount++;
    let current = root;
    let currentPath = focusPath ?? '';
    for (const directoryName of parts.slice(0, -1)) {
        currentPath = joinTreePath(currentPath, directoryName);
        let child = current.directories.get(directoryName);
        if (!child) {
            child = createDirectory(directoryName, currentPath);
            current.directories.set(directoryName, child);
        }
        child.fileCount++;
        current = child;
    }
    current.files.push(file);
}
function symbolIdentity(parts) {
    return [parts.type, parts.name, parts.line ?? '', parts.containerName ?? ''].join('|');
}
function symbolsForFile(file, rankedNodes) {
    const byKey = new Map();
    for (const symbol of file.symbols) {
        byKey.set(symbolIdentity({ type: symbol.type, name: symbol.name, line: symbol.line, containerName: symbol.containerName }), symbol);
    }
    const symbols = [];
    const seen = new Set();
    for (const ranked of rankedNodes) {
        const key = symbolIdentity({
            type: ranked.type,
            name: ranked.name,
            line: ranked.line,
            containerName: ranked.containerName,
        });
        if (seen.has(key))
            continue;
        const parsed = byKey.get(key);
        if (!parsed)
            continue;
        seen.add(key);
        symbols.push({
            nodeId: parsed.nodeId,
            name: parsed.name,
            kind: toNormalizedSymbolKind(parsed.type, parsed.containerName),
            signature: parsed.fullSignature ?? parsed.signature ?? parsed.name,
            line: parsed.line,
            relativePath: file.relativePath,
        });
    }
    return symbols;
}
function computeBestFiles(directory, fileScoresByPath) {
    let bestScore;
    let bestFile;
    for (const file of directory.files) {
        const score = fileScoresByPath.get(file.path);
        if (!score)
            continue;
        if (!bestScore ||
            score.score > bestScore.score ||
            (score.score === bestScore.score && compareCodeUnits(file.relativePath, bestScore.relativePath) < 0)) {
            bestScore = score;
            bestFile = file;
        }
    }
    for (const child of directory.directories.values()) {
        const childScore = computeBestFiles(child, fileScoresByPath);
        if (!childScore || !child.bestFile)
            continue;
        if (!bestScore ||
            childScore.score > bestScore.score ||
            (childScore.score === bestScore.score &&
                compareCodeUnits(child.bestFile.relativePath, bestScore.relativePath) < 0)) {
            bestScore = childScore;
            bestFile = child.bestFile;
        }
    }
    directory.bestFile = bestFile;
    directory.bestFileScore = bestScore;
    return bestScore;
}
function computeTokenRollup(directory, fileTokens) {
    let tokens = 0;
    let countedFiles = 0;
    for (const file of directory.files) {
        const count = fileTokens.get(file.path);
        if (count == null)
            continue;
        tokens += count;
        countedFiles++;
    }
    for (const child of directory.directories.values()) {
        const rollup = computeTokenRollup(child, fileTokens);
        tokens += rollup.tokens;
        countedFiles += rollup.countedFiles;
    }
    directory.tokenCount = tokens;
    directory.countedFileCount = countedFiles;
    return { tokens, countedFiles };
}
function sortedDirectories(directory) {
    return [...directory.directories.values()].sort((a, b) => compareCodeUnits(a.name, b.name));
}
function sortedFiles(directory) {
    return [...directory.files].sort((a, b) => compareCodeUnits(a.relativePath, b.relativePath));
}
/**
 * Project held source spend per directory with the same post-order rollup used
 * by `dirtree --tokens`. Excerpts are accepted when a map index deliberately
 * did not retain full bodies; this is projection-only and never reads or parses
 * another file.
 */
export function projectDirectoryTokenRollups(index, options = {}) {
    const root = createDirectory('.', '.');
    for (const file of [...index.files].sort((a, b) => compareCodeUnits(a.relativePath, b.relativePath))) {
        insertFile(root, file, undefined);
    }
    const countTokens = options.countTokens ?? estimateTokenCount;
    const tokenCountCache = options.tokenCountCache ??
        (options.countTokens && !options.tokenizer
            ? createFileTokenCountCache()
            : (getFileTokenCountCache(index) ?? sharedFileTokenCountCache));
    const tokenizer = options.tokenizer ?? 'heuristic';
    const fileTokens = new Map();
    for (const file of index.files) {
        const held = file.content ?? file.excerpt?.content;
        if (held == null)
            continue;
        fileTokens.set(file.path, tokenCountCache.count(held, tokenizer, countTokens));
    }
    computeTokenRollup(root, fileTokens);
    const projections = [];
    const visit = (directory, depth) => {
        for (const child of sortedDirectories(directory)) {
            projections.push({ path: child.path, depth, files: child.fileCount, tokens: child.tokenCount });
            visit(child, depth + 1);
        }
    };
    visit(root, 0);
    return projections;
}
function collapseDirectory(directory, mode) {
    if (mode === 'verbose')
        return directory;
    let current = directory;
    while (current.files.length === 0 && current.directories.size === 1) {
        const child = [...current.directories.values()][0];
        if (!child)
            break;
        current = child;
    }
    return current;
}
function displayNameForDirectory(directory, focusPath) {
    if (!focusPath)
        return directory.path;
    if (directory.path === focusPath)
        return directory.name;
    return directory.path.startsWith(`${focusPath}/`) ? directory.path.slice(focusPath.length + 1) : directory.path;
}
function directoryEntry(directory, depth, mode, focusPath, rankedSymbolsByPath, totalTokens) {
    const entry = {
        kind: 'directory',
        path: directory.path,
        name: displayNameForDirectory(directory, focusPath),
        depth,
        fileCount: directory.fileCount,
    };
    if (mode === 'map' && directory.bestFile && directory.bestFileScore) {
        entry.score = directory.bestFileScore.score;
        entry.normalizedScore = directory.bestFileScore.normalizedScore;
        const symbols = symbolsForFile(directory.bestFile, rankedSymbolsByPath.get(directory.bestFile.path) ?? []).slice(0, DEFAULT_SYMBOLS_PER_DIRECTORY);
        if (symbols.length > 0)
            entry.symbols = symbols;
    }
    if (totalTokens != null) {
        entry.tokens = directory.tokenCount;
        entry.pct = totalTokens === 0 ? 0 : Number(((directory.tokenCount / totalTokens) * 100).toFixed(2));
        entry.files = directory.fileCount;
    }
    return entry;
}
function fileEntry(file, depth, fileScoresByPath, fileTokens, totalTokens) {
    const score = fileScoresByPath.get(file.path);
    const entry = {
        kind: 'file',
        path: file.relativePath,
        name: file.relativePath.split('/').at(-1) ?? file.relativePath,
        depth,
        language: file.language,
    };
    if (score) {
        entry.score = score.score;
        entry.normalizedScore = score.normalizedScore;
    }
    if (totalTokens != null) {
        const tokens = fileTokens.get(file.path) ?? 0;
        entry.tokens = tokens;
        entry.pct = totalTokens === 0 ? 0 : Number(((tokens / totalTokens) * 100).toFixed(2));
        entry.files = 1;
    }
    return entry;
}
function renderEntryLines(entry) {
    const indent = '  '.repeat(entry.depth);
    if (entry.kind === 'file') {
        const language = entry.language ? ` [${entry.language}]` : '';
        const spend = entry.tokens == null ? '' : ` (${entry.tokens} tokens, ${entry.pct}% of focus)`;
        return [`${indent}- ${entry.name}${language}${spend}`];
    }
    const count = entry.fileCount === 1 ? '1 file' : `${entry.fileCount ?? 0} files`;
    const spend = entry.tokens == null ? '' : `, ${entry.tokens} tokens, ${entry.pct}% of focus`;
    const lines = [`${indent}+ ${entry.name}/ (${count}${spend})`];
    for (const symbol of entry.symbols ?? []) {
        const location = symbol.line ? `${symbol.relativePath}:${symbol.line}` : symbol.relativePath;
        lines.push(`${indent}  - ${location} ${symbol.signature}`);
    }
    return lines;
}
function addTruncatedPath(paths, treePath) {
    if (treePath.length > 0)
        paths.add(treePath);
}
function collectDisplayEntries(directory, options, depth = 0) {
    const displays = [];
    const directoryItems = sortedDirectories(directory).map(child => ({
        kind: 'directory',
        directory: collapseDirectory(child, options.mode),
        sortPath: child.path,
    }));
    const fileItems = sortedFiles(directory).map(file => ({
        kind: 'file',
        file,
        sortPath: file.relativePath,
    }));
    let children = [...directoryItems, ...fileItems].sort((a, b) => {
        if (options.totalTokens != null) {
            const aTokens = a.kind === 'directory' ? a.directory.tokenCount : (options.fileTokens.get(a.file.path) ?? 0);
            const bTokens = b.kind === 'directory' ? b.directory.tokenCount : (options.fileTokens.get(b.file.path) ?? 0);
            if (aTokens !== bTokens)
                return bTokens - aTokens;
        }
        if (a.kind !== b.kind)
            return a.kind === 'directory' ? -1 : 1;
        return compareCodeUnits(a.sortPath, b.sortPath);
    });
    if (options.minTokens != null) {
        const admittedByMinimum = [];
        for (const child of children) {
            const tokens = child.kind === 'directory' ? child.directory.tokenCount : (options.fileTokens.get(child.file.path) ?? 0);
            if (tokens >= options.minTokens)
                admittedByMinimum.push(child);
            else
                addTruncatedPath(options.truncatedPaths, child.sortPath);
        }
        children = admittedByMinimum;
    }
    const entryLimit = Math.min(options.maxEntriesPerDir, options.top ?? Number.POSITIVE_INFINITY);
    const admitted = children.slice(0, entryLimit);
    for (const omitted of children.slice(entryLimit)) {
        addTruncatedPath(options.truncatedPaths, omitted.sortPath);
    }
    for (const child of admitted) {
        if (depth > options.maxDepth) {
            addTruncatedPath(options.truncatedPaths, child.sortPath);
            continue;
        }
        if (child.kind === 'file') {
            const entry = fileEntry(child.file, depth, options.fileScoresByPath, options.fileTokens, options.totalTokens);
            displays.push({ entry, lines: renderEntryLines(entry) });
            continue;
        }
        const target = child.directory;
        const entry = directoryEntry(target, depth, options.mode, options.focusPath, options.rankedSymbolsByPath, options.totalTokens);
        displays.push({ entry, lines: renderEntryLines(entry) });
        if (depth === options.maxDepth) {
            if (target.fileCount > 0)
                addTruncatedPath(options.truncatedPaths, target.path);
            continue;
        }
        displays.push(...collectDisplayEntries(target, options, depth + 1));
    }
    return displays;
}
export function directoryTreeHeader(result) {
    const sourceSpend = result.totalTokens == null
        ? ''
        : `, ${result.totalTokens} source tokens${result.mapTokens == null ? '' : `, ${result.wouldFitBudget ? 'fits' : 'exceeds'} ${result.mapTokens}`}`;
    return `Directory Tree: ${result.root} (${result.mode}, ${result.totalFiles} files${sourceSpend})`;
}
export function directoryTreeFooter(result) {
    if (!result.truncated)
        return null;
    return `truncated: ${result.truncatedPaths.length} path(s)`;
}
function fixedBudgetReserve(root, mode, totalFiles, totalTokens, mapTokens, wouldFitBudget, countTokens) {
    const headerTokens = countTokens(directoryTreeHeader({ root, mode, totalFiles, totalTokens, mapTokens, wouldFitBudget }));
    const footerTokens = countTokens('truncated: 000 path(s)');
    const newlineTokens = countTokens('\n\n');
    return headerTokens + footerTokens + newlineTokens;
}
function enforceBudgets(candidates, truncatedPaths, options) {
    const entries = [];
    const lines = [];
    const tokenBudget = options.tokenBudget;
    const byteBudget = options.byteBudget;
    const tokenReserve = tokenBudget == null
        ? 0
        : fixedBudgetReserve(options.root, options.mode, options.totalFiles, options.totalTokens, options.mapTokens, options.wouldFitBudget, options.countTokens);
    const byteReserve = byteBudget == null
        ? 0
        : Buffer.byteLength(`${directoryTreeHeader({
            root: options.root,
            mode: options.mode,
            totalFiles: options.totalFiles,
            totalTokens: options.totalTokens,
            mapTokens: options.mapTokens,
            wouldFitBudget: options.wouldFitBudget,
        })}\ntruncated: 000 path(s)\n`, 'utf8');
    let tokensUsed = tokenReserve;
    let bytesUsed = byteReserve;
    for (let candidateIndex = 0; candidateIndex < candidates.length; candidateIndex++) {
        const candidate = candidates[candidateIndex];
        if (!candidate)
            continue;
        const tokensExhausted = tokenBudget != null && tokensUsed >= tokenBudget;
        const bytesExhausted = byteBudget != null && bytesUsed >= byteBudget;
        if (tokensExhausted || bytesExhausted) {
            // No positive-cost entry can fit after either reserve is exactly spent.
            // Preserve the old omission envelope while avoiding token/byte work for
            // every remaining candidate.
            for (let omittedIndex = candidateIndex; omittedIndex < candidates.length; omittedIndex++) {
                const omitted = candidates[omittedIndex];
                if (omitted)
                    addTruncatedPath(truncatedPaths, omitted.entry.path);
            }
            break;
        }
        const text = candidate.lines.join('\n');
        const tokenCost = options.countTokens(`${text}\n`);
        const byteCost = Buffer.byteLength(`${text}\n`, 'utf8');
        const exceedsTokens = tokenBudget != null && tokensUsed + tokenCost > tokenBudget;
        const exceedsBytes = byteBudget != null && bytesUsed + byteCost > byteBudget;
        if (exceedsTokens || exceedsBytes) {
            addTruncatedPath(truncatedPaths, candidate.entry.path);
            continue;
        }
        entries.push(candidate.entry);
        lines.push(...candidate.lines);
        tokensUsed += tokenCost;
        bytesUsed += byteCost;
    }
    if (tokenBudget == null)
        tokensUsed = options.countTokens(lines.join('\n'));
    if (byteBudget == null)
        bytesUsed = Buffer.byteLength(lines.join('\n'), 'utf8');
    return { entries, lines, tokensUsed, bytesUsed };
}
export function buildDirectoryTree(index, options = {}) {
    const mode = options.mode ?? DEFAULT_TREE_MODE;
    const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
    const maxEntriesPerDir = options.maxEntriesPerDir ?? DEFAULT_MAX_ENTRIES_PER_DIR;
    const tokenBudget = options.tokenBudget ?? DEFAULT_TOKEN_BUDGET;
    const byteBudget = options.byteBudget;
    const countTokens = options.countTokens ?? estimateTokenCount;
    const focusPath = normalizeFocusPath(options.focusPath);
    const root = createDirectory(focusPath ?? '.', focusPath ?? '.');
    for (const file of [...index.files].sort((a, b) => compareCodeUnits(a.relativePath, b.relativePath))) {
        const relativePath = normalizeTreePath(file.relativePath);
        if (!isInFocus(relativePath, focusPath))
            continue;
        insertFile(root, file, focusPath);
    }
    const fileTokens = new Map();
    if (options.tokens) {
        // An injected counter without a tokenizer name is an opaque algorithm:
        // keep its cache local so another callback cannot inherit its counts.
        const tokenCountCache = options.tokenCountCache ??
            (options.countTokens && !options.tokenizer
                ? createFileTokenCountCache()
                : (getFileTokenCountCache(index) ?? sharedFileTokenCountCache));
        const tokenizer = options.tokenizer ?? 'heuristic';
        for (const file of index.files) {
            if (!isInFocus(normalizeTreePath(file.relativePath), focusPath) || file.content == null)
                continue;
            fileTokens.set(file.path, tokenCountCache.count(file.content, tokenizer, countTokens));
        }
        computeTokenRollup(root, fileTokens);
    }
    const totalTokens = options.tokens ? root.tokenCount : undefined;
    const mapTokens = options.tokens ? (options.mapTokens ?? tokenBudget) : undefined;
    const wouldFitBudget = totalTokens == null || mapTokens == null ? undefined : totalTokens <= mapTokens;
    const ranking = rankNodesCached(index);
    const fileScoresByPath = new Map(ranking.fileScores.map(score => [score.path, score]));
    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]);
    }
    computeBestFiles(root, fileScoresByPath);
    const truncated = new Set();
    const candidates = collectDisplayEntries(root, {
        mode,
        focusPath,
        maxDepth,
        maxEntriesPerDir,
        fileScoresByPath,
        rankedSymbolsByPath,
        fileTokens,
        totalTokens,
        minTokens: options.tokens ? options.minTokens : undefined,
        top: options.tokens ? options.top : undefined,
        truncatedPaths: truncated,
    });
    const budgeted = enforceBudgets(candidates, truncated, {
        root: root.path,
        mode,
        totalFiles: root.fileCount,
        totalTokens,
        mapTokens,
        wouldFitBudget,
        tokenBudget,
        byteBudget,
        countTokens,
    });
    return {
        root: root.path,
        focusPath,
        mode,
        maxDepth,
        maxEntriesPerDir,
        tokenBudget,
        tokensUsed: budgeted.tokensUsed,
        byteBudget,
        bytesUsed: budgeted.bytesUsed,
        totalFiles: root.fileCount,
        totalDirectories: countDirectories(root),
        entries: budgeted.entries,
        lines: budgeted.lines,
        truncated: truncated.size > 0,
        truncatedPaths: [...truncated].sort(compareCodeUnits),
        totalTokens,
        tokenizer: options.tokens ? (options.tokenizer ?? 'heuristic') : undefined,
        mapTokens,
        wouldFitBudget,
        minTokens: options.tokens ? options.minTokens : undefined,
        top: options.tokens ? options.top : undefined,
        honesty: {
            freshness: 'index-snapshot',
            stalenessReason: 'working tree was not rechecked after index construction',
            truncation: {
                truncated: truncated.size > 0,
                omittedPaths: truncated.size,
            },
            coverage: {
                countedFiles: options.tokens ? root.countedFileCount : 0,
                totalFiles: root.fileCount,
                unavailableFiles: options.tokens ? root.fileCount - root.countedFileCount : root.fileCount,
            },
            ambiguityCount: 0,
        },
    };
}
function countDirectories(directory) {
    let count = directory.path === '.' ? 0 : 1;
    for (const child of directory.directories.values())
        count += countDirectories(child);
    return count;
}
//# sourceMappingURL=tree.js.map