/**
 * OwlCoda Native Grep Tool
 *
 * Search file contents with a shared runtime contract across ripgrep and the
 * native walker: ignore pruning, heartbeat, timeout, and partial results.
 */
import { spawn } from 'node:child_process';
import { readdir, readFile, stat } from 'node:fs/promises';
import { join, relative, resolve } from 'node:path';
import { IGNORE_DIR_NAMES, IGNORE_GLOB_PATTERNS } from './ignore.js';
import { detectRipgrep } from './rg-detect.js';
const MAX_RESULTS = 500;
const MAX_FILE_SIZE = 1024 * 1024; // Skip files > 1 MiB
const DEFAULT_TIMEOUT_MS = 60_000;
const HEARTBEAT_MS = 500;
const grepLedgers = new WeakMap();
function makeGrepKey(input) {
    return JSON.stringify({
        pattern: input.pattern,
        path: input.path ?? null,
        include: input.include ?? null,
        ignoreCase: !!input.ignoreCase,
    });
}
function ordinalSuffix(n) {
    const lastTwo = n % 100;
    if (lastTwo >= 11 && lastTwo <= 13)
        return 'th';
    const last = n % 10;
    if (last === 1)
        return 'st';
    if (last === 2)
        return 'nd';
    if (last === 3)
        return 'rd';
    return 'th';
}
export function recordGrepAndBuildNudge(taskState, input, matchCount) {
    if (!taskState)
        return { prefix: null, count: 1 };
    let ledger = grepLedgers.get(taskState);
    if (!ledger) {
        ledger = new Map();
        grepLedgers.set(taskState, ledger);
    }
    const key = makeGrepKey(input);
    const existing = ledger.get(key);
    if (!existing) {
        ledger.set(key, { count: 1, lastResultMatchCount: matchCount });
        return { prefix: null, count: 1 };
    }
    existing.count += 1;
    const prevMatchCount = existing.lastResultMatchCount;
    existing.lastResultMatchCount = matchCount;
    const includeNote = input.include ? `, include=${input.include}` : '';
    const pathNote = input.path ?? '(default cwd)';
    if (existing.count === 2) {
        return {
            prefix: `[Runtime grep-repeat notice]\n` +
                `You already ran this grep earlier in this task: pattern=${JSON.stringify(input.pattern)}, ` +
                `path=${pathNote}${includeNote}.\n` +
                `Previous run matched ${prevMatchCount} line${prevMatchCount === 1 ? '' : 's'}; this run matched ${matchCount}. ` +
                `Reuse the prior evidence — re-running the same regex on the same path rarely produces new information.`,
            count: existing.count,
        };
    }
    return {
        prefix: `[Runtime grep-repeat warning]\n` +
            `This is the ${existing.count}${ordinalSuffix(existing.count)} run of the same grep in this task: ` +
            `pattern=${JSON.stringify(input.pattern)}, path=${pathNote}.\n` +
            `Use the prior evidence, narrow the pattern/path, or switch tools (read for line-range context, glob for file listing).`,
        count: existing.count,
    };
}
/** Test-only: clear the grep ledger for a given task. */
export function __resetGrepLedgerForTask(taskState) {
    grepLedgers.delete(taskState);
}
export function createGrepTool() {
    return {
        name: 'grep',
        description: 'Search file contents for a regex pattern. Uses ripgrep if available.',
        async execute(input, context) {
            const searchPath = resolve(input.path ?? process.cwd());
            const ignorePatterns = [...IGNORE_GLOB_PATTERNS];
            const matches = [];
            const resultLimit = input.maxResults ?? MAX_RESULTS;
            let engine = 'native';
            const budget = createExecutionBudget('grep', context, DEFAULT_TIMEOUT_MS, `Scanning ${searchPath}`);
            try {
                throwIfAborted(budget.signal, 'grep');
                const usedRipgrep = await tryRipgrep(input, searchPath, ignorePatterns, matches, budget);
                engine = usedRipgrep ? 'ripgrep' : 'native';
                if (!usedRipgrep) {
                    await nativeSearch(input, searchPath, matches, budget);
                }
            }
            catch (err) {
                const reason = budget.reason();
                budget.finish();
                if (matches.length > resultLimit) {
                    matches.length = resultLimit;
                }
                if (reason) {
                    return partialResult(matches, engine, reason, budget.elapsedMs());
                }
                if (err instanceof Error && err.message.startsWith('Invalid regular expression')) {
                    return { output: `Error: invalid regex — ${err.message}`, isError: true };
                }
                const msg = err instanceof Error ? err.message : String(err);
                return { output: `Error: ${msg}`, isError: true };
            }
            const reason = budget.reason();
            budget.finish();
            if (matches.length > resultLimit) {
                matches.length = resultLimit;
            }
            if (reason) {
                const partial = partialResult(matches, engine, reason, budget.elapsedMs());
                // Partial/timeout/aborted runs don't get a repeat nudge — the
                // model has a legitimate reason to retry with narrower scope.
                return partial;
            }
            const result = successResult(matches, engine, input.pattern, searchPath);
            // 0.13.71 evidence_ledger_v1 — repeat-grep nudge on successful runs.
            if (!result.isError && context?.taskState) {
                const matchCount = matches.length;
                const nudge = recordGrepAndBuildNudge(context.taskState, input, matchCount);
                if (nudge.prefix) {
                    result.output = `${nudge.prefix}\n${result.output}`;
                    result.metadata = {
                        ...(result.metadata ?? {}),
                        grepRepeatCount: nudge.count,
                    };
                }
            }
            return result;
        },
    };
}
async function tryRipgrep(input, searchPath, ignorePatterns, matches, budget) {
    const rg = await detectRipgrep();
    if (!rg)
        return false;
    let usable = true;
    await new Promise((resolvePromise, rejectPromise) => {
        const args = ['--line-number', '--with-filename', '--no-heading', '--color=never', '--hidden'];
        if (input.ignoreCase)
            args.push('-i');
        if (input.maxResults)
            args.push('--max-count', String(input.maxResults));
        if (input.include)
            args.push('-g', input.include);
        args.push('--max-filesize', '1M');
        args.push(...ignorePatterns.flatMap((pattern) => ['-g', `!${pattern}`]));
        args.push(input.pattern, searchPath);
        const child = spawn(rg.bin, args, { stdio: ['ignore', 'pipe', 'pipe'] });
        let stdoutBuf = '';
        let stderr = '';
        let settled = false;
        const settle = (err) => {
            if (settled)
                return;
            settled = true;
            budget.signal.removeEventListener('abort', onAbort);
            if (err)
                rejectPromise(err);
            else
                resolvePromise();
        };
        const onAbort = () => {
            try {
                child.kill('SIGTERM');
            }
            catch { /* noop */ }
        };
        budget.signal.addEventListener('abort', onAbort, { once: true });
        child.stdout.setEncoding('utf8');
        child.stdout.on('data', (chunk) => {
            stdoutBuf += chunk;
            const lines = stdoutBuf.split('\n');
            stdoutBuf = lines.pop() ?? '';
            for (const line of lines) {
                const trimmed = line.trimEnd();
                if (!trimmed)
                    continue;
                matches.push(trimmed);
                budget.update(trimmed, matches.length);
                if (matches.length >= (input.maxResults ?? MAX_RESULTS)) {
                    try {
                        child.kill('SIGTERM');
                    }
                    catch { /* noop */ }
                    return;
                }
            }
        });
        child.stderr.setEncoding('utf8');
        child.stderr.on('data', (chunk) => {
            if (stderr.length < 8_192)
                stderr += chunk;
        });
        child.once('error', () => {
            usable = false;
            settle();
        });
        child.once('close', (code, signal) => {
            if (stdoutBuf.trim()) {
                const trimmed = stdoutBuf.trimEnd();
                if (trimmed) {
                    matches.push(trimmed);
                    budget.update(trimmed, matches.length);
                }
            }
            if (budget.reason()) {
                settle();
                return;
            }
            if (signal === 'SIGTERM' && matches.length >= (input.maxResults ?? MAX_RESULTS)) {
                settle();
                return;
            }
            if (code === 0 || code === 1) {
                settle();
                return;
            }
            if (stderr.trim()) {
                settle(new Error(stderr.trim()));
                return;
            }
            usable = false;
            settle();
        });
    });
    if (!usable) {
        matches.length = 0;
    }
    return usable;
}
async function nativeSearch(input, searchPath, matches, budget) {
    const flags = input.ignoreCase ? 'i' : '';
    const regex = new RegExp(input.pattern, flags);
    const maxResults = input.maxResults ?? MAX_RESULTS;
    const includeGlob = input.include
        ? simpleGlobToRegex(input.include)
        : null;
    const info = await stat(searchPath);
    throwIfAborted(budget.signal, 'grep');
    if (info.isFile()) {
        await searchFile(searchPath, searchPath, regex, matches, maxResults, budget);
        return;
    }
    await walkAndSearch(searchPath, searchPath, regex, matches, maxResults, includeGlob, budget);
}
async function walkAndSearch(dir, basePath, regex, matches, maxResults, includeGlob, budget) {
    throwIfAborted(budget.signal, 'grep');
    if (matches.length >= maxResults)
        return;
    let entries;
    try {
        entries = await readdir(dir, { withFileTypes: true });
    }
    catch {
        return;
    }
    for (const entry of entries) {
        if (matches.length >= maxResults)
            return;
        throwIfAborted(budget.signal, 'grep');
        if (entry.isDirectory()) {
            if (IGNORE_DIR_NAMES.has(entry.name))
                continue;
            const nextDir = join(dir, entry.name);
            const relDir = normalizePath(relative(basePath, nextDir));
            budget.update(`Scanning ${relDir}/`, matches.length);
            await walkAndSearch(nextDir, basePath, regex, matches, maxResults, includeGlob, budget);
            continue;
        }
        if (!entry.isFile())
            continue;
        if (includeGlob && !includeGlob.test(entry.name))
            continue;
        await searchFile(join(dir, entry.name), basePath, regex, matches, maxResults, budget);
    }
}
async function searchFile(filePath, basePath, regex, matches, maxResults, budget) {
    try {
        throwIfAborted(budget.signal, 'grep');
        const info = await stat(filePath);
        if (info.size > MAX_FILE_SIZE)
            return;
        const content = await readFile(filePath, 'utf-8');
        throwIfAborted(budget.signal, 'grep');
        const lines = content.split('\n');
        const relPath = relative(basePath, filePath) || filePath;
        for (let i = 0; i < lines.length && matches.length < maxResults; i++) {
            throwIfAborted(budget.signal, 'grep');
            if (!regex.test(lines[i]))
                continue;
            const displayPath = filePath.startsWith('/') ? filePath : relPath;
            const matchLine = `${displayPath}:${i + 1}:${lines[i]}`;
            matches.push(matchLine);
            budget.update(matchLine, matches.length);
        }
    }
    catch (err) {
        if (err instanceof Error && err.name === 'AbortError') {
            throw err;
        }
        // Skip unreadable files.
    }
}
function createExecutionBudget(toolName, context, timeoutMs, initialSample) {
    const timeoutController = new AbortController();
    const startedAt = Date.now();
    let timedOut = false;
    let sample = initialSample;
    let totalLines = 0;
    let totalBytes = 0;
    const emit = () => {
        context?.onProgress?.({
            lines: [sample],
            totalLines,
            totalBytes,
            elapsedMs: Date.now() - startedAt,
        });
    };
    const heartbeat = context?.onProgress
        ? setInterval(emit, HEARTBEAT_MS)
        : null;
    heartbeat?.unref?.();
    emit();
    const timeout = setTimeout(() => {
        timedOut = true;
        timeoutController.abort(new Error(`${toolName} timed out`));
    }, timeoutMs);
    timeout.unref?.();
    const signal = context?.signal
        ? AbortSignal.any([context.signal, timeoutController.signal])
        : timeoutController.signal;
    return {
        signal,
        update(nextSample, nextTotalLines, nextTotalBytes) {
            sample = nextSample;
            totalLines = nextTotalLines;
            totalBytes = nextTotalBytes ?? totalBytes;
        },
        finish() {
            clearTimeout(timeout);
            if (heartbeat)
                clearInterval(heartbeat);
        },
        reason() {
            if (timedOut)
                return 'timeout';
            if (context?.signal?.aborted)
                return 'aborted';
            return null;
        },
        elapsedMs() {
            return Date.now() - startedAt;
        },
    };
}
function successResult(matches, engine, pattern, searchPath) {
    if (matches.length === 0) {
        // 0.13.60: explicit "[grep ok]" prefix + pattern + path so the
        // model can't misread "no matches" as "search failed". Pre-0.13.60
        // wording was just "No matches found", which long-context models
        // sometimes interpreted as a tool failure and retried with a
        // different (also-empty) pattern.
        const patternEcho = pattern ? ` for pattern ${JSON.stringify(pattern)}` : '';
        const pathEcho = searchPath ? ` in ${searchPath}` : '';
        return {
            output: `[grep ok] 0 matches${patternEcho}${pathEcho}. Search ran cleanly; the pattern simply does not occur. ` +
                `If you expected a match, broaden the pattern or check the path.`,
            isError: false,
            metadata: { matchLines: 0, engine, zeroMatches: true },
        };
    }
    return {
        output: matches.slice(0, MAX_RESULTS).join('\n'),
        isError: false,
        metadata: { matchLines: Math.min(matches.length, MAX_RESULTS), engine },
    };
}
function partialResult(matches, engine, reason, elapsedMs) {
    const reasonText = reason === 'timeout'
        ? `timed out after ${Math.max(1, Math.round(elapsedMs / 1000))}s`
        : 'was aborted';
    const prefix = `[partial ${reason}] Returned ${matches.length} match line${matches.length === 1 ? '' : 's'} before grep ${reasonText}. Narrow the pattern or path to continue.`;
    const body = matches.length > 0 ? matches.slice(0, MAX_RESULTS).join('\n') : 'No matches found before the run stopped.';
    return {
        output: `${prefix}\n${body}`,
        isError: false,
        metadata: {
            matchLines: Math.min(matches.length, MAX_RESULTS),
            engine,
            partial: true,
            reason,
            narrowedNeeded: true,
        },
    };
}
function simpleGlobToRegex(glob) {
    const re = normalizePath(glob)
        .replace(/[.+^${}()|[\]\\]/g, '\\$&')
        .replace(/\*/g, '.*')
        .replace(/\?/g, '.');
    return new RegExp('^' + re + '$');
}
function normalizePath(path) {
    return path.replace(/\\/g, '/');
}
function throwIfAborted(signal, toolName) {
    if (!signal.aborted)
        return;
    const err = new Error(`${toolName} aborted`);
    err.name = 'AbortError';
    throw err;
}
//# sourceMappingURL=grep.js.map