/**
 * @license
 * Copyright 2025 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */
import fs from 'node:fs';
import fsPromises from 'node:fs/promises';
import path from 'node:path';
import { downloadRipGrep } from '@joshua.litt/get-ripgrep';
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
import { ToolErrorType } from './tool-error.js';
import { makeRelative, shortenPath } from '../utils/paths.js';
import { getErrorMessage, isNodeError } from '../utils/errors.js';
import { fileExists } from '../utils/fileUtils.js';
import { Storage } from '../config/storage.js';
import { GREP_TOOL_NAME } from './tool-names.js';
import { debugLogger } from '../utils/debugLogger.js';
import { FileExclusions, COMMON_DIRECTORY_EXCLUDES, } from '../utils/ignorePatterns.js';
import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
import { execStreaming } from '../utils/shell-utils.js';
import { DEFAULT_TOTAL_MAX_MATCHES, DEFAULT_SEARCH_TIMEOUT_MS, } from './constants.js';
function getRgCandidateFilenames() {
    return process.platform === 'win32' ? ['rg.exe', 'rg'] : ['rg'];
}
async function resolveExistingRgPath() {
    const binDir = Storage.getGlobalBinDir();
    for (const fileName of getRgCandidateFilenames()) {
        const candidatePath = path.join(binDir, fileName);
        if (await fileExists(candidatePath)) {
            return candidatePath;
        }
    }
    return null;
}
let ripgrepAcquisitionPromise = null;
async function ensureRipgrepAvailable() {
    const existingPath = await resolveExistingRgPath();
    if (existingPath) {
        return existingPath;
    }
    if (!ripgrepAcquisitionPromise) {
        ripgrepAcquisitionPromise = (async () => {
            try {
                await downloadRipGrep(Storage.getGlobalBinDir());
                return await resolveExistingRgPath();
            }
            finally {
                ripgrepAcquisitionPromise = null;
            }
        })();
    }
    return ripgrepAcquisitionPromise;
}
/**
 * Checks if `rg` exists, if not then attempt to download it.
 */
export async function canUseRipgrep() {
    return (await ensureRipgrepAvailable()) !== null;
}
/**
 * Ensures `rg` is downloaded, or throws.
 */
export async function ensureRgPath() {
    const downloadedPath = await ensureRipgrepAvailable();
    if (downloadedPath) {
        return downloadedPath;
    }
    throw new Error('Cannot use ripgrep.');
}
class GrepToolInvocation extends BaseToolInvocation {
    config;
    fileDiscoveryService;
    constructor(config, fileDiscoveryService, params, messageBus, _toolName, _toolDisplayName) {
        super(params, messageBus, _toolName, _toolDisplayName);
        this.config = config;
        this.fileDiscoveryService = fileDiscoveryService;
    }
    async execute(signal) {
        try {
            // Default to '.' if path is explicitly undefined/null.
            // This forces CWD search instead of 'all workspaces' search by default.
            const pathParam = this.params.dir_path || '.';
            const searchDirAbs = path.resolve(this.config.getTargetDir(), pathParam);
            const validationError = this.config.validatePathAccess(searchDirAbs, 'read');
            if (validationError) {
                return {
                    llmContent: validationError,
                    returnDisplay: 'Error: Path not in workspace.',
                    error: {
                        message: validationError,
                        type: ToolErrorType.PATH_NOT_IN_WORKSPACE,
                    },
                };
            }
            // Check existence and type asynchronously
            try {
                const stats = await fsPromises.stat(searchDirAbs);
                if (!stats.isDirectory() && !stats.isFile()) {
                    return {
                        llmContent: `Path is not a valid directory or file: ${searchDirAbs}`,
                        returnDisplay: 'Error: Path is not a valid directory or file.',
                    };
                }
            }
            catch (error) {
                if (isNodeError(error) && error.code === 'ENOENT') {
                    return {
                        llmContent: `Path does not exist: ${searchDirAbs}`,
                        returnDisplay: 'Error: Path does not exist.',
                        error: {
                            message: `Path does not exist: ${searchDirAbs}`,
                            type: ToolErrorType.FILE_NOT_FOUND,
                        },
                    };
                }
                return {
                    llmContent: `Failed to access path stats for ${searchDirAbs}: ${getErrorMessage(error)}`,
                    returnDisplay: 'Error: Failed to access path.',
                };
            }
            const searchDirDisplay = pathParam;
            const totalMaxMatches = this.params.total_max_matches ?? DEFAULT_TOTAL_MAX_MATCHES;
            if (this.config.getDebugMode()) {
                debugLogger.log(`[GrepTool] Total result limit: ${totalMaxMatches}`);
            }
            // Create a timeout controller to prevent indefinitely hanging searches
            const timeoutController = new AbortController();
            const timeoutId = setTimeout(() => {
                timeoutController.abort();
            }, DEFAULT_SEARCH_TIMEOUT_MS);
            // Link the passed signal to our timeout controller
            const onAbort = () => timeoutController.abort();
            if (signal.aborted) {
                onAbort();
            }
            else {
                signal.addEventListener('abort', onAbort, { once: true });
            }
            let allMatches;
            try {
                allMatches = await this.performRipgrepSearch({
                    pattern: this.params.pattern,
                    path: searchDirAbs,
                    include: this.params.include,
                    case_sensitive: this.params.case_sensitive,
                    fixed_strings: this.params.fixed_strings,
                    context: this.params.context,
                    after: this.params.after,
                    before: this.params.before,
                    no_ignore: this.params.no_ignore,
                    maxMatches: totalMaxMatches,
                    max_matches_per_file: this.params.max_matches_per_file,
                    signal: timeoutController.signal,
                });
            }
            finally {
                clearTimeout(timeoutId);
                signal.removeEventListener('abort', onAbort);
            }
            if (!this.params.no_ignore) {
                const uniqueFiles = Array.from(new Set(allMatches.map((m) => m.filePath)));
                const absoluteFilePaths = uniqueFiles.map((f) => path.resolve(searchDirAbs, f));
                const allowedFiles = this.fileDiscoveryService.filterFiles(absoluteFilePaths);
                const allowedSet = new Set(allowedFiles);
                allMatches = allMatches.filter((m) => allowedSet.has(path.resolve(searchDirAbs, m.filePath)));
            }
            const searchLocationDescription = `in path "${searchDirDisplay}"`;
            if (allMatches.length === 0) {
                const noMatchMsg = `No matches found for pattern "${this.params.pattern}" ${searchLocationDescription}${this.params.include ? ` (filter: "${this.params.include}")` : ''}.`;
                return { llmContent: noMatchMsg, returnDisplay: `No matches found` };
            }
            const matchesByFile = allMatches.reduce((acc, match) => {
                const fileKey = match.filePath;
                if (!acc[fileKey]) {
                    acc[fileKey] = [];
                }
                acc[fileKey].push(match);
                acc[fileKey].sort((a, b) => a.lineNumber - b.lineNumber);
                return acc;
            }, {});
            const matchesOnly = allMatches.filter((m) => !m.isContext);
            const matchCount = matchesOnly.length;
            const matchTerm = matchCount === 1 ? 'match' : 'matches';
            const wasTruncated = matchCount >= totalMaxMatches;
            let llmContent = `Found ${matchCount} ${matchTerm} for pattern "${this.params.pattern}" ${searchLocationDescription}${this.params.include ? ` (filter: "${this.params.include}")` : ''}${wasTruncated ? ` (results limited to ${totalMaxMatches} matches for performance)` : ''}:\n---\n`;
            for (const filePath in matchesByFile) {
                llmContent += `File: ${filePath}\n`;
                matchesByFile[filePath].forEach((match) => {
                    const separator = match.isContext ? '-' : ':';
                    llmContent += `L${match.lineNumber}${separator} ${match.line}\n`;
                });
                llmContent += '---\n';
            }
            return {
                llmContent: llmContent.trim(),
                returnDisplay: `Found ${matchCount} ${matchTerm}${wasTruncated ? ' (limited)' : ''}`,
            };
        }
        catch (error) {
            debugLogger.warn(`Error during GrepLogic execution: ${error}`);
            const errorMessage = getErrorMessage(error);
            return {
                llmContent: `Error during grep search operation: ${errorMessage}`,
                returnDisplay: `Error: ${errorMessage}`,
            };
        }
    }
    async performRipgrepSearch(options) {
        const { pattern, path: absolutePath, include, case_sensitive, fixed_strings, context, after, before, no_ignore, maxMatches, max_matches_per_file, } = options;
        const rgArgs = ['--json'];
        if (!case_sensitive) {
            rgArgs.push('--ignore-case');
        }
        if (fixed_strings) {
            rgArgs.push('--fixed-strings');
            rgArgs.push(pattern);
        }
        else {
            rgArgs.push('--regexp', pattern);
        }
        if (context) {
            rgArgs.push('--context', context.toString());
        }
        if (after) {
            rgArgs.push('--after-context', after.toString());
        }
        if (before) {
            rgArgs.push('--before-context', before.toString());
        }
        if (no_ignore) {
            rgArgs.push('--no-ignore');
        }
        if (max_matches_per_file) {
            rgArgs.push('--max-count', max_matches_per_file.toString());
        }
        if (include) {
            rgArgs.push('--glob', include);
        }
        if (!no_ignore) {
            if (!this.config.getFileFilteringRespectGitIgnore()) {
                rgArgs.push('--no-ignore-vcs', '--no-ignore-exclude');
            }
            const fileExclusions = new FileExclusions(this.config);
            const excludes = fileExclusions.getGlobExcludes([
                ...COMMON_DIRECTORY_EXCLUDES,
                '*.log',
                '*.tmp',
            ]);
            excludes.forEach((exclude) => {
                rgArgs.push('--glob', `!${exclude}`);
            });
            // Add .geminiignore and custom ignore files support (if provided/mandated)
            // (ripgrep natively handles .gitignore)
            const geminiIgnorePaths = this.fileDiscoveryService.getIgnoreFilePaths();
            for (const ignorePath of geminiIgnorePaths) {
                rgArgs.push('--ignore-file', ignorePath);
            }
        }
        rgArgs.push('--threads', '4');
        rgArgs.push(absolutePath);
        const results = [];
        try {
            const rgPath = await ensureRgPath();
            const generator = execStreaming(rgPath, rgArgs, {
                signal: options.signal,
                allowedExitCodes: [0, 1],
            });
            let matchesFound = 0;
            for await (const line of generator) {
                const match = this.parseRipgrepJsonLine(line, absolutePath);
                if (match) {
                    results.push(match);
                    if (!match.isContext) {
                        matchesFound++;
                    }
                    if (matchesFound >= maxMatches) {
                        break;
                    }
                }
            }
            return results;
        }
        catch (error) {
            debugLogger.debug(`GrepLogic: ripgrep failed: ${getErrorMessage(error)}`);
            throw error;
        }
    }
    parseRipgrepJsonLine(line, basePath) {
        try {
            const json = JSON.parse(line);
            if (json.type === 'match' || json.type === 'context') {
                const data = json.data;
                // Defensive check: ensure text properties exist (skips binary/invalid encoding)
                if (data.path?.text && data.lines?.text) {
                    const absoluteFilePath = path.resolve(basePath, data.path.text);
                    const relativeCheck = path.relative(basePath, absoluteFilePath);
                    if (relativeCheck === '..' ||
                        relativeCheck.startsWith(`..${path.sep}`) ||
                        path.isAbsolute(relativeCheck)) {
                        return null;
                    }
                    const relativeFilePath = path.relative(basePath, absoluteFilePath);
                    return {
                        filePath: relativeFilePath || path.basename(absoluteFilePath),
                        lineNumber: data.line_number,
                        line: data.lines.text.trimEnd(),
                        isContext: json.type === 'context',
                    };
                }
            }
        }
        catch (error) {
            // Only log if it's not a simple empty line or widely invalid
            if (line.trim().length > 0) {
                debugLogger.warn(`Failed to parse ripgrep JSON line: ${line.substring(0, 100)}...`, error);
            }
        }
        return null;
    }
    /**
     * Gets a description of the grep operation
     * @param params Parameters for the grep operation
     * @returns A string describing the grep
     */
    getDescription() {
        let description = `'${this.params.pattern}'`;
        if (this.params.include) {
            description += ` in ${this.params.include}`;
        }
        const pathParam = this.params.dir_path || '.';
        const resolvedPath = path.resolve(this.config.getTargetDir(), pathParam);
        if (resolvedPath === this.config.getTargetDir() || pathParam === '.') {
            description += ` within ./`;
        }
        else {
            const relativePath = makeRelative(resolvedPath, this.config.getTargetDir());
            description += ` within ${shortenPath(relativePath)}`;
        }
        return description;
    }
}
/**
 * Implementation of the Grep tool logic (moved from CLI)
 */
export class RipGrepTool extends BaseDeclarativeTool {
    config;
    static Name = GREP_TOOL_NAME;
    fileDiscoveryService;
    constructor(config, messageBus) {
        super(RipGrepTool.Name, 'SearchText', 'Searches for a regular expression pattern within file contents. Max 100 matches.', Kind.Search, {
            properties: {
                pattern: {
                    description: "The pattern to search for. By default, treated as a Rust-flavored regular expression. Use '\\b' for precise symbol matching (e.g., '\\bMatchMe\\b').",
                    type: 'string',
                },
                dir_path: {
                    description: "Directory or file to search. Directories are searched recursively. Relative paths are resolved against current working directory. Defaults to current working directory ('.') if omitted.",
                    type: 'string',
                },
                include: {
                    description: "Glob pattern to filter files (e.g., '*.ts', 'src/**'). Recommended for large repositories to reduce noise. Defaults to all files if omitted.",
                    type: 'string',
                },
                case_sensitive: {
                    description: 'If true, search is case-sensitive. Defaults to false (ignore case) if omitted.',
                    type: 'boolean',
                },
                fixed_strings: {
                    description: 'If true, treats the `pattern` as a literal string instead of a regular expression. Defaults to false (basic regex) if omitted.',
                    type: 'boolean',
                },
                context: {
                    description: 'Show this many lines of context around each match (equivalent to grep -C). Defaults to 0 if omitted.',
                    type: 'integer',
                },
                after: {
                    description: 'Show this many lines after each match (equivalent to grep -A). Defaults to 0 if omitted.',
                    type: 'integer',
                },
                before: {
                    description: 'Show this many lines before each match (equivalent to grep -B). Defaults to 0 if omitted.',
                    type: 'integer',
                },
                no_ignore: {
                    description: 'If true, searches all files including those usually ignored (like in .gitignore, build/, dist/, etc). Defaults to false if omitted.',
                    type: 'boolean',
                },
                max_matches_per_file: {
                    description: 'Optional: Maximum number of matches to return per file. Use this to prevent being overwhelmed by repetitive matches in large files.',
                    type: 'integer',
                    minimum: 1,
                },
                total_max_matches: {
                    description: 'Optional: Maximum number of total matches to return. Use this to limit the overall size of the response. Defaults to 100 if omitted.',
                    type: 'integer',
                    minimum: 1,
                },
            },
            required: ['pattern'],
            type: 'object',
        }, messageBus, true, // isOutputMarkdown
        false);
        this.config = config;
        this.fileDiscoveryService = new FileDiscoveryService(config.getTargetDir(), config.getFileFilteringOptions());
    }
    /**
     * Validates the parameters for the tool
     * @param params Parameters to validate
     * @returns An error message string if invalid, null otherwise
     */
    validateToolParamValues(params) {
        if (!params.fixed_strings) {
            try {
                new RegExp(params.pattern);
            }
            catch (error) {
                return `Invalid regular expression pattern provided: ${params.pattern}. Error: ${getErrorMessage(error)}`;
            }
        }
        if (params.max_matches_per_file !== undefined &&
            params.max_matches_per_file < 1) {
            return 'max_matches_per_file must be at least 1.';
        }
        if (params.total_max_matches !== undefined &&
            params.total_max_matches < 1) {
            return 'total_max_matches must be at least 1.';
        }
        // Only validate path if one is provided
        if (params.dir_path) {
            const resolvedPath = path.resolve(this.config.getTargetDir(), params.dir_path);
            const validationError = this.config.validatePathAccess(resolvedPath, 'read');
            if (validationError) {
                return validationError;
            }
            // Check existence and type
            try {
                const stats = fs.statSync(resolvedPath);
                if (!stats.isDirectory() && !stats.isFile()) {
                    return `Path is not a valid directory or file: ${resolvedPath}`;
                }
            }
            catch (error) {
                if (isNodeError(error) && error.code === 'ENOENT') {
                    return `Path does not exist: ${resolvedPath}`;
                }
                return `Failed to access path stats for ${resolvedPath}: ${getErrorMessage(error)}`;
            }
        }
        return null; // Parameters are valid
    }
    createInvocation(params, messageBus, _toolName, _toolDisplayName) {
        return new GrepToolInvocation(this.config, this.fileDiscoveryService, params, messageBus ?? this.messageBus, _toolName, _toolDisplayName);
    }
}
//# sourceMappingURL=ripGrep.js.map