import { execFile, spawn } from "node:child_process";
import { readFile, readdir, stat } from "node:fs/promises";
import { join, relative, resolve } from "node:path";
import { promisify } from "node:util";
import { MAX_READ_BYTES, SEARCH_SKIP_DIRS } from "./file-manager.js";
const execFileAsync = promisify(execFile);
/* ----------------------------- detection ----------------------------- */
let cachedRipgrepAvailable;
export async function ripgrepAvailable() {
    if (cachedRipgrepAvailable !== undefined)
        return cachedRipgrepAvailable;
    try {
        await execFileAsync("rg", ["--version"], { timeout: 2_000 });
        cachedRipgrepAvailable = true;
    }
    catch {
        // ENOENT (not installed), timeout, non-zero exit — all collapse to
        // "no". A single warn-once log lives at the route layer so the
        // operator notices a busted install.
        cachedRipgrepAvailable = false;
    }
    return cachedRipgrepAvailable;
}
/** Reset the detection cache. Test-only; do not call from app code. */
export function _resetRipgrepCache() {
    cachedRipgrepAvailable = undefined;
}
/**
 * Thrown when a search mode requires the ripgrep engine that isn't
 * available on this host. Specifically: regex queries against the
 * Node fallback engine are vulnerable to ReDoS (catastrophic
 * backtracking pegs the event loop for the entire opts.timeoutMs
 * window — the timeout is checked between iterations, not inside the
 * regex engine), so we refuse them rather than silently DoS the
 * server. Substring queries against the Node fallback are fine.
 */
export class SearchEngineUnavailableError extends Error {
    constructor(msg) {
        super(msg);
        this.name = "SearchEngineUnavailableError";
    }
}
/* ----------------------------- entry point ----------------------------- */
export async function searchFiles(projectPath, opts) {
    if (await ripgrepAvailable()) {
        return searchWithRipgrep(projectPath, opts);
    }
    if (opts.regex) {
        throw new SearchEngineUnavailableError("regex search requires ripgrep, which isn't installed on this host");
    }
    return searchInProcess(projectPath, opts);
}
async function searchWithRipgrep(projectPath, opts) {
    const args = [
        "--json",
        "--no-heading",
        "--max-filesize",
        "5M",
        // Hard match-count cap as a backstop; we stop reading earlier if we
        // hit our own `limit`, but `--max-count` per file keeps a single
        // pathological file (e.g. `.*` against a 100k-line CSV) from
        // dominating the output.
        "--max-count",
        String(Math.max(1, Math.min(opts.limit, 1000))),
    ];
    if (!opts.regex)
        args.push("--fixed-strings");
    if (!opts.caseSensitive)
        args.push("-i");
    if (opts.includeGitignored) {
        // -uu: don't honor .gitignore + show hidden files. We deliberately
        // don't go to -uuu (also includes binary) — the binary-file skip
        // is desired regardless.
        args.push("-uu");
    }
    if (opts.include !== undefined && opts.include.length > 0) {
        args.push("--glob", opts.include);
    }
    if (opts.exclude !== undefined && opts.exclude.length > 0) {
        args.push("--glob", `!${opts.exclude}`);
    }
    args.push("--", opts.query, ".");
    return new Promise((resolveFn) => {
        const matches = [];
        let truncated = false;
        const child = spawn("rg", args, { cwd: projectPath });
        const timer = setTimeout(() => {
            truncated = true;
            child.kill("SIGTERM");
        }, opts.timeoutMs);
        let buf = "";
        let currentFile;
        const finish = () => {
            clearTimeout(timer);
            resolveFn({ engine: "ripgrep", matches, truncated });
        };
        child.stdout.setEncoding("utf8");
        child.stdout.on("data", (chunk) => {
            buf += chunk;
            let nl = buf.indexOf("\n");
            while (nl !== -1) {
                const line = buf.slice(0, nl);
                buf = buf.slice(nl + 1);
                if (line.length > 0)
                    handleEvent(line);
                nl = buf.indexOf("\n");
            }
        });
        child.on("error", () => finish()); // ENOENT or fork failure → empty result
        child.on("close", () => finish());
        const handleEvent = (jsonLine) => {
            let event;
            try {
                event = JSON.parse(jsonLine);
            }
            catch {
                // Non-JSON line from ripgrep (shouldn't happen with --json
                // but defensive). Skip without aborting the whole search.
                return;
            }
            if (event.type === "begin") {
                const data = event.data;
                currentFile = data?.path?.text;
            }
            else if (event.type === "match") {
                if (matches.length >= opts.limit) {
                    truncated = true;
                    child.kill("SIGTERM");
                    return;
                }
                const data = event.data;
                if (data === undefined || currentFile === undefined)
                    return;
                const lineText = data.lines?.text ?? "";
                const lineNumber = data.line_number ?? 0;
                const sub = data.submatches?.[0];
                const start = sub?.start ?? 0;
                const matchText = sub?.match?.text ?? "";
                matches.push({
                    path: currentFile,
                    line: lineNumber,
                    column: start + 1,
                    length: matchText.length,
                    lineSnippet: stripTrailingNewline(lineText),
                });
            }
        };
    });
}
/* ----------------------------- in-process path ----------------------------- */
async function searchInProcess(projectPath, opts) {
    const matches = [];
    let truncated = false;
    const deadline = Date.now() + opts.timeoutMs;
    // Build the matcher up front so we don't reconstruct the regex per
    // line. Plain substring uses indexOf for speed.
    const re = opts.regex ? safeRegex(opts.query, opts.caseSensitive) : undefined;
    if (opts.regex && re === undefined) {
        // Bad pattern → empty result (treat like "no matches"). Caller
        // can validate ahead of time if it wants a 400.
        return { engine: "node", matches: [], truncated: false };
    }
    // Pre-compile glob predicates. Very simple — matches `*.ts` /
    // `**/*.test.*` style patterns. Anything fancier than that we
    // skip and rely on the user installing ripgrep.
    const includeMatch = opts.include !== undefined ? globToRegExp(opts.include) : undefined;
    const excludeMatch = opts.exclude !== undefined ? globToRegExp(opts.exclude) : undefined;
    const root = resolve(projectPath);
    const stack = [root];
    const filesToScan = [];
    // First pass: discover files under depth 6 honoring SEARCH_SKIP_DIRS.
    while (stack.length > 0 && Date.now() < deadline) {
        const dir = stack.pop();
        if (dir === undefined)
            break;
        const depth = relative(root, dir)
            .split(/[\\/]/)
            .filter((p) => p.length > 0).length;
        if (depth > 6)
            continue;
        let entries;
        try {
            entries = await readdir(dir, { withFileTypes: true });
        }
        catch {
            // Permission denied / vanished directory — skip without
            // aborting the whole search. Common on system-managed
            // subdirs the user doesn't own.
            continue;
        }
        for (const ent of entries) {
            const full = join(dir, ent.name);
            if (ent.isDirectory()) {
                if (SEARCH_SKIP_DIRS.has(ent.name))
                    continue;
                if (ent.name.startsWith(".") && ent.name !== ".")
                    continue;
                stack.push(full);
            }
            else if (ent.isFile()) {
                const rel = relative(root, full);
                if (includeMatch !== undefined && !includeMatch.test(rel))
                    continue;
                if (excludeMatch !== undefined && excludeMatch.test(rel))
                    continue;
                filesToScan.push(rel);
            }
        }
    }
    if (Date.now() >= deadline)
        truncated = true;
    // Second pass: bounded-concurrency match scan. 16-wide is a
    // reasonable default for SSDs; HDD-bound users will see the same
    // throughput regardless because seek time dominates.
    const CONCURRENCY = 16;
    let cursor = 0;
    const worker = async () => {
        while (cursor < filesToScan.length) {
            if (Date.now() >= deadline) {
                truncated = true;
                return;
            }
            if (matches.length >= opts.limit) {
                truncated = true;
                return;
            }
            const i = cursor++;
            const rel = filesToScan[i];
            if (rel === undefined)
                continue;
            const full = join(root, rel);
            // Size + binary skip. The 5 MB cap mirrors file-manager's
            // readFile cap so search and editor agree on what's a "text
            // file." Binary detection is a NUL-byte test on the first
            // 4 KB — same heuristic git itself uses.
            let st;
            try {
                st = await stat(full);
            }
            catch {
                // File vanished between discovery and stat — common when
                // the agent edits files mid-search. Skip silently.
                continue;
            }
            if (!st.isFile() || st.size > MAX_READ_BYTES)
                continue;
            let content;
            try {
                const buf = await readFile(full);
                if (looksBinary(buf))
                    continue;
                content = buf.toString("utf8");
            }
            catch {
                // Read failure (perms, vanished mid-loop) — skip this file.
                continue;
            }
            scanText(content, rel, opts, re, matches, opts.limit);
        }
    };
    const workers = [];
    for (let w = 0; w < Math.min(CONCURRENCY, filesToScan.length); w++) {
        workers.push(worker());
    }
    await Promise.all(workers);
    if (matches.length >= opts.limit)
        truncated = true;
    return { engine: "node", matches, truncated };
}
function scanText(content, rel, opts, re, out, limit) {
    const lines = content.split("\n");
    const cmpQuery = opts.caseSensitive ? opts.query : opts.query.toLowerCase();
    for (let i = 0; i < lines.length; i++) {
        if (out.length >= limit)
            return;
        const line = lines[i] ?? "";
        if (re !== undefined) {
            // Reuse the passed-in regex (always built with the `g` flag —
            // see safeRegex). lastIndex carries between exec() calls so we
            // need to reset it once per line; previously this code rebuilt
            // the regex per line to side-step lastIndex, which on a
            // 100-file × 1000-line search allocated 100k regex objects.
            re.lastIndex = 0;
            let m;
            while ((m = re.exec(line)) !== null) {
                out.push({
                    path: rel,
                    line: i + 1,
                    column: m.index + 1,
                    length: m[0].length,
                    lineSnippet: line,
                });
                if (out.length >= limit)
                    return;
                // Avoid zero-length match infinite loop.
                if (m.index === re.lastIndex)
                    re.lastIndex += 1;
            }
        }
        else {
            const haystack = opts.caseSensitive ? line : line.toLowerCase();
            let from = 0;
            while (from <= haystack.length) {
                const idx = haystack.indexOf(cmpQuery, from);
                if (idx === -1)
                    break;
                out.push({
                    path: rel,
                    line: i + 1,
                    column: idx + 1,
                    length: opts.query.length,
                    lineSnippet: line,
                });
                if (out.length >= limit)
                    return;
                from = idx + Math.max(1, opts.query.length);
            }
        }
    }
}
function looksBinary(buf) {
    // Sniff at most the first 4 KB. A NUL byte in a text file is
    // virtually unheard of — git, ripgrep, and most editors use the
    // same heuristic.
    const probe = buf.subarray(0, Math.min(4096, buf.length));
    for (let i = 0; i < probe.length; i++) {
        if (probe[i] === 0)
            return true;
    }
    return false;
}
function safeRegex(pattern, caseSensitive) {
    try {
        // Always include the `g` flag so scanText() can do a lastIndex-bumping
        // exec loop without rebuilding the regex per line. Without `g`, exec()
        // returns the same first match on every call, which would silently
        // miss subsequent matches on the same line.
        return new RegExp(pattern, caseSensitive ? "g" : "gi");
    }
    catch {
        // Invalid regex pattern from the user. Caller treats undefined
        // as "bad pattern" and short-circuits with an empty result.
        return undefined;
    }
}
function stripTrailingNewline(s) {
    if (s.endsWith("\r\n"))
        return s.slice(0, -2);
    if (s.endsWith("\n"))
        return s.slice(0, -1);
    return s;
}
/**
 * Translate a simple glob pattern to a RegExp anchored to the full
 * path. Supports `*` (any chars except `/`), `**` (any chars
 * including `/`), and `?` (single char). Anything fancier — char
 * classes, brace expansion — is unsupported on this fallback path.
 */
function globToRegExp(glob) {
    let out = "^";
    for (let i = 0; i < glob.length; i++) {
        const c = glob[i] ?? "";
        if (c === "*") {
            if (glob[i + 1] === "*") {
                out += ".*";
                i++;
            }
            else {
                out += "[^/]*";
            }
        }
        else if (c === "?") {
            out += "[^/]";
        }
        else if (".+^$()[]{}|\\".includes(c)) {
            out += "\\" + c;
        }
        else {
            out += c;
        }
    }
    out += "$";
    return new RegExp(out);
}
//# sourceMappingURL=file-searcher.js.map