import path from 'node:path';
import { documentToMarkdown } from '../markdown/convert.js';
import { extractFromMarkdown, getSectionContent, flatten } from '../structure/index.js';
import { estimateTokens } from '../inspect/shared.js';
import { countWords, getExtension, normalizeRelativePath } from '../utils.js';
function slugify(value) {
    const slug = value
        .toLowerCase()
        .replace(/[`*_~()[\]{}<>]/g, '')
        .replace(/[^a-z0-9]+/g, '-')
        .replace(/^-+|-+$/g, '');
    return slug || 'section';
}
function lineAtChar(content, charIndex) {
    if (charIndex <= 0)
        return 1;
    let line = 1;
    for (let index = 0; index < Math.min(charIndex, content.length); index++) {
        if (content[index] === '\n')
            line++;
    }
    return line;
}
function headingPathFor(node, nodesById) {
    const pathParts = [node.title];
    let current = node;
    while (current.parentNodeId) {
        const parent = nodesById.get(current.parentNodeId);
        if (!parent)
            break;
        pathParts.unshift(parent.title);
        current = parent;
    }
    return pathParts;
}
/**
 * Split a section's content into line-aligned chunks that respect a token budget.
 *
 * Per-line token counts are precomputed once and budget decisions use a running
 * sum, so `countTokens` runs O(lines + chunks) times instead of re-counting the
 * accumulated chunk on every line. Each precomputed count includes the line's
 * trailing `'\n'` separator (except the seed's final line, which has none), so
 * the running sum upper-bounds the joined+trimmed chunk under tokenizers that
 * charge for newlines — emitted chunks stay within `maxTokens`, at the cost of
 * slightly conservative boundaries that may land a line earlier than an exact
 * count would allow. The emitted `tokenEstimate` is still the exact
 * `countTokens(content)` of each joined+trimmed chunk.
 */
function splitByTokenBudget(seed, maxTokens, overlapTokens, countTokens) {
    const lines = seed.content.split('\n');
    const lineTokens = lines.map((line, index) => countTokens(index < lines.length - 1 ? `${line}\n` : line));
    const chunks = [];
    let currentLines = [];
    let currentStartLine = seed.startLine;
    let runningTokens = 0;
    const emit = () => {
        const content = currentLines.join('\n').trim();
        if (!content) {
            currentLines = [];
            runningTokens = 0;
            return;
        }
        const emittedStartLine = currentStartLine;
        const emittedLineCount = currentLines.length;
        const tokenEstimate = countTokens(content);
        chunks.push({
            content,
            startLine: emittedStartLine,
            endLine: emittedStartLine + emittedLineCount - 1,
            tokenEstimate,
            wordCount: countWords(content),
        });
        if (overlapTokens <= 0 || (currentLines.length === 1 && tokenEstimate > overlapTokens)) {
            currentLines = [];
            runningTokens = 0;
            return;
        }
        // Walk backwards over the precomputed per-line counts: always carry the
        // last line, then keep extending the overlap while it still fits.
        const firstLineIndex = emittedStartLine - seed.startLine;
        const overlapLines = [];
        let overlapEstimate = 0;
        for (let index = currentLines.length - 1; index >= 0; index--) {
            const lineEstimate = lineTokens[firstLineIndex + index];
            if (overlapEstimate > 0 && overlapEstimate + lineEstimate > overlapTokens)
                break;
            overlapLines.unshift(currentLines[index]);
            overlapEstimate += lineEstimate;
        }
        currentStartLine = emittedStartLine + emittedLineCount - overlapLines.length;
        currentLines = overlapLines;
        runningTokens = overlapEstimate;
    };
    for (let index = 0; index < lines.length; index++) {
        const lineEstimate = lineTokens[index];
        if (currentLines.length > 0 && runningTokens + lineEstimate > maxTokens) {
            emit();
        }
        if (currentLines.length === 0) {
            currentStartLine = seed.startLine + index;
        }
        currentLines.push(lines[index]);
        runningTokens += lineEstimate;
        if (currentLines.length === 1 && runningTokens > maxTokens) {
            emit();
        }
    }
    if (currentLines.length > 0) {
        emit();
    }
    return chunks;
}
function buildChunkSeeds(markdown, filePath) {
    const structure = extractFromMarkdown(markdown);
    const nodes = flatten(structure.rootNodes);
    if (nodes.length === 0) {
        return [
            {
                title: path.basename(filePath),
                content: markdown,
                anchorBase: `line-${lineAtChar(markdown, 0)}`,
                headingPath: [],
                startLine: 1,
            },
        ];
    }
    const nodesById = new Map(nodes.map(node => [node.nodeId, node]));
    const seeds = [];
    for (const node of nodes) {
        const content = getSectionContent(markdown, node, false);
        if (!content.trim())
            continue;
        const headingPath = headingPathFor(node, nodesById);
        seeds.push({
            title: node.title,
            content,
            anchorBase: slugify(headingPath.join(' ')),
            headingPath,
            startLine: node.startLine,
        });
    }
    return seeds.length > 0
        ? seeds
        : [
            {
                title: path.basename(filePath),
                content: markdown,
                anchorBase: `line-${lineAtChar(markdown, 0)}`,
                headingPath: [],
                startLine: 1,
            },
        ];
}
/**
 * Chunk already-converted markdown without touching disk.
 *
 * Useful when the markdown was just produced elsewhere (for example
 * `inspectWorkspaceDocumentSet` with `includeMarkdown`) — it skips the
 * re-read + re-convert that `chunkDocument` performs. For the same markdown
 * and options the output is identical to `chunkDocument`.
 */
export function chunkDocumentFromMarkdown(markdown, options) {
    const filePath = options.filePath;
    const maxTokens = options.maxTokens ?? 800;
    const overlapTokens = Math.min(options.overlapTokens ?? 80, Math.max(0, maxTokens - 1));
    const countTokens = options.countTokens ?? estimateTokens;
    const relativePath = options.relativePath ?? normalizeRelativePath(filePath);
    const format = options.format ?? getExtension(filePath);
    const chunks = [];
    const seeds = buildChunkSeeds(markdown, filePath);
    for (const seed of seeds) {
        const lineChunks = splitByTokenBudget(seed, maxTokens, overlapTokens, countTokens);
        for (let index = 0; index < lineChunks.length; index++) {
            const lineChunk = lineChunks[index];
            const suffix = lineChunks.length > 1 ? `-part-${index + 1}` : '';
            chunks.push({
                chunkId: `${relativePath}#${seed.anchorBase}${suffix}`,
                filePath,
                relativePath,
                format,
                title: seed.title,
                content: lineChunk.content,
                anchor: `${seed.anchorBase}${suffix}`,
                headingPath: seed.headingPath,
                sourceLine: lineChunk.startLine,
                startLine: lineChunk.startLine,
                endLine: lineChunk.endLine,
                tokenEstimate: lineChunk.tokenEstimate,
                wordCount: lineChunk.wordCount,
            });
        }
    }
    return chunks;
}
export async function chunkDocument(filePath, options = {}) {
    const markdown = await documentToMarkdown(filePath);
    if (markdown == null) {
        throw new Error(`Unsupported document format for chunking: ${filePath}`);
    }
    return chunkDocumentFromMarkdown(markdown, { ...options, filePath });
}
//# sourceMappingURL=chunk.js.map