import path from 'node:path';
import { createTokenizer, estimateTokenCount, resolveTokenizerName } from '../tokens.js';
import { buildDiffRangeArgs, GitError, gitVersion, resolveDiffRange, runGit, runGitRaw } from './client.js';
import { aggregateCochange, aggregateChurn, assertValidGitPath, decodeGitPath, parseDiffPatchHunks, parseLogNumstatZ, parseLogRecords, parseNameStatusZ, parseNumstatZ, splitNulStrict, } from './parse.js';
const DEFAULT_NOISE_EXCLUDES = [
    ':(exclude)package-lock.json',
    ':(exclude)pnpm-lock.yaml',
    ':(exclude)yarn.lock',
    ':(exclude)*.min.js',
    ':(exclude)*.map',
    ':(exclude)dist/**',
    ':(exclude)build/**',
    ':(exclude)vendor/**',
    ':(exclude)**/__generated__/**',
    ':(exclude)*.snap',
];
const LOG_FORMAT = 'tformat:%H%x1f%h%x1f%an%x1f%ae%x1f%aI%x1f%cI%x1f%s%x1f%b';
const LINE_LOG_FORMAT = 'tformat:%x1e%H%x1f%h%x1f%an%x1f%ae%x1f%aI%x1f%cI%x1f%s';
function repoRootFromOptions(options) {
    return path.resolve(options.repoRoot ?? options.cwd ?? process.cwd());
}
function rangeResolutionOptions(options) {
    const { maxBuffer: _maxBuffer, allowExitCodes: _allowExitCodes, ...rest } = options;
    return rest;
}
function pathspecs(options, includeDefaultExcludes = true) {
    const specs = [];
    specs.push(...(options.paths ?? []));
    if (includeDefaultExcludes)
        specs.push(...DEFAULT_NOISE_EXCLUDES);
    specs.push(...(options.excludePaths ?? []));
    for (const spec of specs)
        assertValidGitPath(spec);
    return specs;
}
function withPathspecs(args, specs) {
    return [...args, '--', ...specs];
}
async function resolveRangeAndVersion(options) {
    const repoRoot = repoRootFromOptions(options);
    const rangeOptions = rangeResolutionOptions(options);
    const range = await resolveDiffRange(rangeOptions);
    const version = await gitVersion(repoRoot, rangeOptions).catch(() => undefined);
    return { range, version, repoRoot };
}
function historyRange(since) {
    return { kind: 'commit', head: 'HEAD', describe: since ? `HEAD history since ${since}` : 'HEAD history' };
}
async function resolveScopedRange(options) {
    if (options.scope === 'history') {
        return { range: historyRange(options.since), repoRoot: repoRootFromOptions(options) };
    }
    return resolveRangeAndVersion(options);
}
function sumNumstat(stats) {
    let added = 0;
    let deleted = 0;
    let binaryFiles = 0;
    for (const stat of stats) {
        if (stat.binary)
            binaryFiles++;
        added += stat.added ?? 0;
        deleted += stat.deleted ?? 0;
    }
    return { added, deleted, binaryFiles };
}
function countPatchFiles(patch) {
    return patch.split(/\r?\n/).filter(line => line.startsWith('diff --git ')).length;
}
function rangeSpec(range) {
    if (range.base && range.head)
        return `${range.base}..${range.head}`;
    return range.head;
}
function toFileHistoryEntry(commit, added = null, deleted = null) {
    return { ...commit, added, deleted };
}
export function parseLineHistory(output) {
    return output
        .split('\x1e')
        .map(record => record.trim())
        .filter(Boolean)
        .map(record => {
        const parts = record.split('\x1f');
        const [hash, shortHash, authorName, authorEmail, authorDate, committerDate] = parts;
        // `git log -L` appends the traced patch after the formatted record, so keep
        // only the first line of the subject field to discard the trailing hunks.
        const subject = parts.slice(6).join('\x1f').split('\n')[0];
        return {
            hash,
            shortHash,
            authorName,
            authorEmail: authorEmail ?? '',
            authorDate,
            committerDate: committerDate ?? authorDate,
            subject,
            added: null,
            deleted: null,
        };
    });
}
export async function resolveGitRange(options = {}) {
    return resolveDiffRange(options);
}
export async function getGitChangedFiles(options = {}) {
    const { range, version, repoRoot } = await resolveRangeAndVersion(options);
    const args = withPathspecs(['diff', '--no-color', '--name-status', '-M', '-z', ...buildDiffRangeArgs(range, version)], pathspecs(options));
    const result = await runGitRaw(args, { ...options, repoRoot });
    return parseNameStatusZ(result.stdout);
}
/**
 * Files present in the working tree that git is not tracking, honoring
 * `.gitignore` and the other standard exclude sources.
 *
 * Every other API here reads a diff, and a diff by definition cannot see an
 * untracked file — so callers that need the *whole* working state (entity
 * blame on a brand-new file, for instance) previously had no way to learn such
 * a file existed. Directories are expanded to their files rather than reported
 * as a single entry, so the result is always a list of paths that can be read.
 */
export async function getGitUntrackedFiles(options = {}) {
    const repoRoot = options.repoRoot ?? options.cwd ?? process.cwd();
    const specs = [...(options.paths ?? []), ...(options.excludePaths ?? [])];
    for (const spec of specs)
        assertValidGitPath(spec);
    const args = ['ls-files', '--others', '--exclude-standard', '-z'];
    const result = await runGitRaw(specs.length > 0 ? [...args, '--', ...specs] : args, {
        ...options,
        repoRoot,
        allowExitCodes: [128],
    });
    if (result.exitCode !== 0)
        return [];
    return splitNulStrict(result.stdout, 'Untracked-path output')
        .map(field => decodeGitPath(field))
        .filter(Boolean);
}
export async function getGitDiffSummary(options = {}) {
    const { range, version, repoRoot } = await resolveRangeAndVersion(options);
    const specs = pathspecs(options);
    const relative = options.relative === true ? ['--relative'] : [];
    const fileResult = await runGitRaw(withPathspecs(['diff', '--no-color', ...relative, '--name-status', '-M', '-z', ...buildDiffRangeArgs(range, version)], specs), { ...options, repoRoot });
    const numstatResult = await runGitRaw(withPathspecs(['diff', '--no-color', ...relative, '--numstat', '-M', '-z', ...buildDiffRangeArgs(range, version)], specs), { ...options, repoRoot });
    const files = parseNameStatusZ(fileResult.stdout);
    const numstat = parseNumstatZ(numstatResult.stdout);
    const binaryByPath = new Set(numstat.filter(entry => entry.binary).map(entry => entry.path));
    const enrichedFiles = files.map(file => (binaryByPath.has(file.path) ? { ...file, binary: true } : file));
    const totals = sumNumstat(numstat);
    return {
        range,
        files: enrichedFiles,
        numstat,
        totalAdded: totals.added,
        totalDeleted: totals.deleted,
        filesChanged: files.length,
        binaryFiles: totals.binaryFiles,
    };
}
export async function getGitDiffPatch(options = {}) {
    const { range, version, repoRoot } = await resolveRangeAndVersion(options);
    const specs = pathspecs(options);
    const args = withPathspecs(['diff', '--no-color', ...buildDiffRangeArgs(range, version)], specs);
    const tokenBudget = options.tokenBudget;
    try {
        const result = await runGit(args, { ...options, repoRoot });
        const tokenizerName = resolveTokenizerName(options.tokenizer);
        const tokenizer = tokenBudget ? await createTokenizer(tokenizerName) : undefined;
        const fullPatch = result.stdout;
        const fullTokens = tokenizer ? tokenizer.count(fullPatch) : estimateTokenCount(fullPatch);
        const truncated = tokenBudget != null && fullTokens > tokenBudget;
        const patch = truncated && tokenizer ? tokenizer.slice(fullPatch, 0, tokenBudget) : fullPatch;
        const tokensUsed = tokenizer ? tokenizer.count(patch) : estimateTokenCount(patch);
        const filesIncluded = countPatchFiles(patch);
        const changedFiles = await getGitChangedFiles(rangeResolutionOptions(options)).catch(() => []);
        return {
            range,
            patch,
            truncated,
            tokensUsed,
            tokenBudget,
            filesIncluded,
            filesOmitted: Math.max(0, changedFiles.length - filesIncluded),
        };
    }
    catch (error) {
        if (error instanceof GitError && error.code === 'max-buffer') {
            const changedFiles = await getGitChangedFiles(rangeResolutionOptions(options)).catch(() => []);
            return {
                range,
                patch: '',
                truncated: true,
                tokensUsed: 0,
                tokenBudget,
                filesIncluded: 0,
                filesOmitted: changedFiles.length,
            };
        }
        throw error;
    }
}
export async function getGitDiffHunks(options = {}) {
    const { range, version, repoRoot } = await resolveRangeAndVersion(options);
    const result = await runGit(withPathspecs(['diff', '--no-color', '--unified=0', ...buildDiffRangeArgs(range, version)], pathspecs(options)), { ...options, repoRoot });
    return parseDiffPatchHunks(result.stdout, range);
}
export async function getGitDiffFunctionContext(options = {}) {
    const { range, version, repoRoot } = await resolveRangeAndVersion(options);
    const result = await runGit(withPathspecs(['diff', '--no-color', '-W', '--unified=3', ...buildDiffRangeArgs(range, version)], pathspecs(options)), { ...options, repoRoot });
    return parseDiffPatchHunks(result.stdout, range, { includeLineText: true });
}
export async function getGitCommitHistory(options = {}) {
    const { range, repoRoot } = await resolveScopedRange(options);
    const limit = options.limit ?? 50;
    const args = ['log', '--no-color', '-z', `--format=${LOG_FORMAT}`, `--max-count=${limit + 1}`];
    if (options.since)
        args.push(`--since=${options.since}`);
    const spec = rangeSpec(range);
    if (spec)
        args.push(spec);
    const result = await runGitRaw(withPathspecs(args, pathspecs(options)), { ...options, repoRoot });
    const commits = parseLogRecords(result.stdout);
    const truncated = commits.length > limit;
    return {
        range,
        commits: commits.slice(0, limit),
        total: Math.min(commits.length, limit),
        truncated,
        pathFilter: options.paths?.[0],
    };
}
export async function getGitFileHistory(options) {
    const repoRoot = repoRootFromOptions(options);
    const limit = options.limit ?? 50;
    assertValidGitPath(options.path);
    if (options.lineRange) {
        const lineArg = `${options.lineRange.start},${options.lineRange.end}:${options.path}`;
        const result = await runGit(['log', '--no-color', '-L', lineArg, `--format=${LINE_LOG_FORMAT}`, `--max-count=${limit + 1}`, 'HEAD'], { ...options, repoRoot }).catch(async (error) => {
            if (error instanceof GitError && /has only \d+ lines/i.test(error.stderr ?? error.message)) {
                return runGit([
                    'log',
                    '--no-color',
                    '-L',
                    `${options.lineRange?.start},${options.lineRange?.start}:${options.path}`,
                    `--format=${LINE_LOG_FORMAT}`,
                    `--max-count=${limit + 1}`,
                    'HEAD',
                ], { ...options, repoRoot });
            }
            throw error;
        });
        const parsed = parseLineHistory(result.stdout);
        const truncated = parsed.length > limit;
        const entries = parsed.slice(0, limit);
        return {
            path: options.path,
            lineRange: options.lineRange,
            entries,
            total: entries.length,
            truncated,
        };
    }
    const args = ['log', '--no-color', '-z', `--format=${LOG_FORMAT}`, `--max-count=${limit + 1}`];
    if (options.follow)
        args.push('--follow');
    if (options.since)
        args.push(`--since=${options.since}`);
    args.push('HEAD');
    const result = await runGitRaw(withPathspecs(args, [options.path]), { ...options, repoRoot });
    const commits = parseLogRecords(result.stdout).map(commit => toFileHistoryEntry(commit));
    const truncated = commits.length > limit;
    return {
        path: options.path,
        entries: commits.slice(0, limit),
        total: Math.min(commits.length, limit),
        truncated,
    };
}
export async function getGitChurnSummary(options = {}) {
    const { range, repoRoot } = await resolveScopedRange(options);
    const args = ['log', '--no-color', '-z', `--format=${LOG_FORMAT}`, '--numstat'];
    if (options.since)
        args.push(`--since=${options.since}`);
    const spec = rangeSpec(range);
    if (spec)
        args.push(spec);
    const result = await runGitRaw(withPathspecs(args, pathspecs(options)), { ...options, repoRoot });
    const records = parseLogNumstatZ(result.stdout);
    const summary = aggregateChurn(records, range, options.sort ?? 'churn');
    if (options.limit != null) {
        return { ...summary, entries: summary.entries.slice(0, options.limit) };
    }
    return summary;
}
export async function getGitCochange(options = {}) {
    const { range, repoRoot } = await resolveScopedRange(options);
    const args = ['log', '--no-color', '-z', `--format=${LOG_FORMAT}`, '--numstat'];
    if (options.since)
        args.push(`--since=${options.since}`);
    const spec = rangeSpec(range);
    if (spec)
        args.push(spec);
    const result = await runGitRaw(withPathspecs(args, pathspecs(options)), { ...options, repoRoot });
    const records = parseLogNumstatZ(result.stdout);
    return aggregateCochange(records, range, {
        target: options.target,
        limit: options.limit,
        maxCommitFiles: options.maxCommitFiles,
    });
}
//# sourceMappingURL=api.js.map