/**
 * @license
 * Copyright 2025 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */
import fs from 'fs';
import fsPromises from 'fs/promises';
import path from 'path';
import { EOL } from 'os';
import { spawn } from 'child_process';
import { globStream } from 'glob';
import { BaseDeclarativeTool, BaseToolInvocation, Kind, } from './tools.js';
import { makeRelative, shortenPath } from '../utils/paths.js';
import { getErrorMessage, isNodeError } from '../utils/errors.js';
import { isGitRepository } from '../utils/gitUtils.js';
import { ToolErrorType } from './tool-error.js';
import { limitOutputTokens, formatLimitedOutput, } from '../utils/toolOutputLimiter.js';
class GrepToolInvocation extends BaseToolInvocation {
    config;
    fileExclusions;
    constructor(config, params) {
        super(params);
        this.config = config;
        this.fileExclusions = config.getFileExclusions();
    }
    /**
     * Checks if a path is within the root directory and resolves it.
     * @param relativePath Path relative to the root directory (or undefined for root).
     * @returns The absolute path if valid and exists, or null if no path specified (to search all directories).
     * @throws {Error} If path is outside root, doesn't exist, or isn't a directory.
     */
    resolveAndValidatePath(relativePath) {
        // If no path specified, return null to indicate searching all workspace directories
        if (!relativePath) {
            return null;
        }
        const targetPath = path.resolve(this.config.getTargetDir(), relativePath);
        // Security Check: Ensure the resolved path is within workspace boundaries
        const workspaceContext = this.config.getWorkspaceContext();
        if (!workspaceContext.isPathWithinWorkspace(targetPath)) {
            const directories = workspaceContext.getDirectories();
            throw new Error(`Path validation failed: Attempted path "${relativePath}" resolves outside the allowed workspace directories: ${directories.join(', ')}`);
        }
        // Check existence and type after resolving
        try {
            const stats = fs.statSync(targetPath);
            if (!stats.isDirectory()) {
                throw new Error(`Path is not a directory: ${targetPath}`);
            }
        }
        catch (error) {
            if (isNodeError(error) && error.code !== 'ENOENT') {
                throw new Error(`Path does not exist: ${targetPath}`);
            }
            throw new Error(`Failed to access path stats for ${targetPath}: ${error}`);
        }
        return targetPath;
    }
    async execute(signal) {
        try {
            const workspaceContext = this.config.getWorkspaceContext();
            const searchDirAbs = this.resolveAndValidatePath(this.params.path);
            const searchDirDisplay = this.params.path || '.';
            // Get limits from parameters or ephemeral settings
            const ephemeralSettings = this.config.getEphemeralSettings();
            const maxResults = this.params.max_results ??
                ephemeralSettings['tool-output-max-items'] ??
                1000; // Higher default for grep than glob
            const maxFiles = this.params.max_files ?? 100;
            const maxPerFile = this.params.max_per_file ?? 50;
            // Determine which directories to search
            let searchDirectories;
            if (searchDirAbs === null) {
                // No path specified - search all workspace directories
                searchDirectories = workspaceContext.getDirectories();
            }
            else {
                // Specific path provided - search only that directory
                searchDirectories = [searchDirAbs];
            }
            // Collect matches from all search directories
            let allMatches = [];
            let totalMatchesFound = 0;
            const filesWithMatches = new Set();
            let wasLimited = false;
            for (const searchDir of searchDirectories) {
                if (allMatches.length >= maxResults) {
                    wasLimited = true;
                    break;
                }
                const matches = await this.performGrepSearch({
                    pattern: this.params.pattern,
                    path: searchDir,
                    include: this.params.include,
                    signal,
                    maxResults: maxResults - allMatches.length,
                    maxFiles: maxFiles - filesWithMatches.size,
                    maxPerFile,
                });
                // Track if we hit limits
                if (matches.wasLimited) {
                    wasLimited = true;
                }
                // Add directory prefix if searching multiple directories
                if (searchDirectories.length > 1) {
                    const dirName = path.basename(searchDir);
                    matches.results.forEach((match) => {
                        match.filePath = path.join(dirName, match.filePath);
                    });
                }
                // Track files and total matches
                matches.results.forEach((match) => {
                    filesWithMatches.add(match.filePath);
                });
                totalMatchesFound += matches.totalFound ?? matches.results.length;
                allMatches = allMatches.concat(matches.results);
            }
            let searchLocationDescription;
            if (searchDirAbs === null) {
                const numDirs = workspaceContext.getDirectories().length;
                searchLocationDescription =
                    numDirs > 1
                        ? `across ${numDirs} workspace directories`
                        : `in the workspace directory`;
            }
            else {
                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` };
            }
            // Apply max_files limit if needed
            let limitedMatches = allMatches;
            let limitMessage = '';
            if (filesWithMatches.size > maxFiles) {
                const filesToKeep = Array.from(filesWithMatches).slice(0, maxFiles);
                limitedMatches = allMatches.filter((match) => filesToKeep.includes(match.filePath));
                limitMessage = `\n\n**Note: Results limited to ${maxFiles} files out of ${filesWithMatches.size} files with matches.**`;
                wasLimited = true;
            }
            // Apply max_per_file limit if needed
            const matchesByFile = limitedMatches.reduce((acc, match) => {
                const fileKey = match.filePath;
                if (!acc[fileKey]) {
                    acc[fileKey] = [];
                }
                if (acc[fileKey].length < maxPerFile) {
                    acc[fileKey].push(match);
                }
                acc[fileKey].sort((a, b) => a.lineNumber - b.lineNumber);
                return acc;
            }, {});
            // Count actual matches shown
            const matchCount = Object.values(matchesByFile).reduce((sum, matches) => sum + matches.length, 0);
            // Build output content
            let llmContent = '';
            if (wasLimited || totalMatchesFound > matchCount) {
                llmContent = `Found ${totalMatchesFound} total matches, showing ${matchCount} for pattern "${this.params.pattern}" ${searchLocationDescription}${this.params.include ? ` (filter: "${this.params.include}")` : ''}:\n---\n`;
            }
            else {
                const matchTerm = matchCount === 1 ? 'match' : 'matches';
                llmContent = `Found ${matchCount} ${matchTerm} for pattern "${this.params.pattern}" ${searchLocationDescription}${this.params.include ? ` (filter: "${this.params.include}")` : ''}:\n---\n`;
            }
            for (const filePath in matchesByFile) {
                llmContent += `File: ${filePath}\n`;
                matchesByFile[filePath].forEach((match) => {
                    const trimmedLine = match.line.trim();
                    llmContent += `L${match.lineNumber}: ${trimmedLine}\n`;
                });
                llmContent += '---\n';
            }
            if (limitMessage) {
                llmContent += limitMessage;
            }
            // Apply token limiting as final safety check
            const limited = limitOutputTokens(llmContent.trim(), this.config, 'SearchText');
            if (limited.wasTruncated) {
                const formatted = formatLimitedOutput(limited);
                return {
                    llmContent: formatted.llmContent,
                    returnDisplay: formatted.returnDisplay,
                };
            }
            const displayCount = wasLimited || totalMatchesFound > matchCount
                ? `Found ${totalMatchesFound} matches (showing ${matchCount})`
                : `Found ${matchCount} ${matchCount === 1 ? 'match' : 'matches'}`;
            return {
                llmContent: llmContent.trim(),
                returnDisplay: displayCount,
            };
        }
        catch (error) {
            console.error(`Error during GrepLogic execution: ${error}`);
            const errorMessage = getErrorMessage(error);
            return {
                llmContent: `Error during grep search operation: ${errorMessage}`,
                returnDisplay: `Error: ${errorMessage}`,
                error: {
                    message: errorMessage,
                    type: ToolErrorType.GREP_EXECUTION_ERROR,
                },
            };
        }
    }
    /**
     * Checks if a command is available in the system's PATH.
     * @param {string} command The command name (e.g., 'git', 'grep').
     * @returns {Promise<boolean>} True if the command is available, false otherwise.
     */
    isCommandAvailable(command) {
        return new Promise((resolve) => {
            const checkCommand = process.platform === 'win32' ? 'where' : 'command';
            const checkArgs = process.platform === 'win32' ? [command] : ['-v', command];
            try {
                const child = spawn(checkCommand, checkArgs, {
                    stdio: 'ignore',
                    shell: process.platform === 'win32',
                });
                child.on('close', (code) => resolve(code === 0));
                child.on('error', () => resolve(false));
            }
            catch {
                resolve(false);
            }
        });
    }
    /**
     * Apply limits to search results
     */
    applyLimits(matches, maxResults, maxFiles, maxPerFile) {
        const filesWithMatches = new Map();
        const totalFound = matches.length;
        // Group by file and apply per-file limits
        for (const match of matches) {
            if (!filesWithMatches.has(match.filePath)) {
                filesWithMatches.set(match.filePath, []);
            }
            const fileMatches = filesWithMatches.get(match.filePath);
            if (fileMatches.length < maxPerFile) {
                fileMatches.push(match);
            }
        }
        // Apply file limit
        const limitedFiles = Array.from(filesWithMatches.entries()).slice(0, maxFiles);
        // Flatten and apply total results limit
        const results = [];
        for (const [, fileMatches] of limitedFiles) {
            for (const match of fileMatches) {
                if (results.length >= maxResults) {
                    break;
                }
                results.push(match);
            }
            if (results.length >= maxResults) {
                break;
            }
        }
        return {
            results,
            wasLimited: results.length < totalFound || filesWithMatches.size > maxFiles,
            totalFound: totalFound > results.length ? totalFound : undefined,
        };
    }
    /**
     * Parses the standard output of grep-like commands (git grep, system grep).
     * Expects format: filePath:lineNumber:lineContent
     * Handles colons within file paths and line content correctly.
     * @param {string} output The raw stdout string.
     * @param {string} basePath The absolute directory the search was run from, for relative paths.
     * @returns {GrepMatch[]} Array of match objects.
     */
    parseGrepOutput(output, basePath) {
        const results = [];
        if (!output)
            return results;
        const lines = output.split(EOL); // Use OS-specific end-of-line
        for (const line of lines) {
            if (!line.trim())
                continue;
            // Find the index of the first colon.
            const firstColonIndex = line.indexOf(':');
            if (firstColonIndex === -1)
                continue; // Malformed
            // Find the index of the second colon, searching *after* the first one.
            const secondColonIndex = line.indexOf(':', firstColonIndex + 1);
            if (secondColonIndex === -1)
                continue; // Malformed
            // Extract parts based on the found colon indices
            const filePathRaw = line.substring(0, firstColonIndex);
            const lineNumberStr = line.substring(firstColonIndex + 1, secondColonIndex);
            const lineContent = line.substring(secondColonIndex + 1);
            const lineNumber = parseInt(lineNumberStr, 10);
            if (!isNaN(lineNumber)) {
                const absoluteFilePath = path.resolve(basePath, filePathRaw);
                const relativeFilePath = path.relative(basePath, absoluteFilePath);
                results.push({
                    filePath: relativeFilePath || path.basename(absoluteFilePath),
                    lineNumber,
                    line: lineContent,
                });
            }
        }
        return results;
    }
    /**
     * Gets a description of the grep operation
     * @returns A string describing the grep
     */
    getDescription() {
        let description = `'${this.params.pattern}'`;
        if (this.params.include) {
            description += ` in ${this.params.include}`;
        }
        if (this.params.path) {
            const resolvedPath = path.resolve(this.config.getTargetDir(), this.params.path);
            if (resolvedPath === this.config.getTargetDir() ||
                this.params.path === '.') {
                description += ` within ./`;
            }
            else {
                const relativePath = makeRelative(resolvedPath, this.config.getTargetDir());
                description += ` within ${shortenPath(relativePath)}`;
            }
        }
        else {
            // When no path is specified, indicate searching all workspace directories
            const workspaceContext = this.config.getWorkspaceContext();
            const directories = workspaceContext.getDirectories();
            if (directories.length > 1) {
                description += ` across all workspace directories`;
            }
        }
        return description;
    }
    /**
     * Performs the actual search using the prioritized strategies.
     * @param options Search options including pattern, absolute path, and include glob.
     * @returns A promise resolving to search results with limit information.
     */
    async performGrepSearch(options) {
        const { pattern, path: absolutePath, include, maxResults = 1000, maxFiles = 100, maxPerFile = 50, } = options;
        let strategyUsed = 'none';
        try {
            // --- Strategy 1: git grep ---
            const isGit = isGitRepository(absolutePath);
            const gitAvailable = isGit && (await this.isCommandAvailable('git'));
            if (gitAvailable) {
                strategyUsed = 'git grep';
                const gitArgs = [
                    'grep',
                    '--untracked',
                    '-n',
                    '-E',
                    '--ignore-case',
                    pattern,
                ];
                if (include) {
                    gitArgs.push('--', include);
                }
                try {
                    const output = await new Promise((resolve, reject) => {
                        const child = spawn('git', gitArgs, {
                            cwd: absolutePath,
                            windowsHide: true,
                        });
                        const stdoutChunks = [];
                        const stderrChunks = [];
                        child.stdout.on('data', (chunk) => stdoutChunks.push(chunk));
                        child.stderr.on('data', (chunk) => stderrChunks.push(chunk));
                        child.on('error', (err) => reject(new Error(`Failed to start git grep: ${err.message}`)));
                        child.on('close', (code) => {
                            const stdoutData = Buffer.concat(stdoutChunks).toString('utf8');
                            const stderrData = Buffer.concat(stderrChunks).toString('utf8');
                            if (code === 0)
                                resolve(stdoutData);
                            else if (code === 1)
                                resolve(''); // No matches
                            else
                                reject(new Error(`git grep exited with code ${code}: ${stderrData}`));
                        });
                    });
                    const matches = this.parseGrepOutput(output, absolutePath);
                    return this.applyLimits(matches, maxResults, maxFiles, maxPerFile);
                }
                catch (gitError) {
                    console.debug(`GrepLogic: git grep failed: ${getErrorMessage(gitError)}. Falling back...`);
                }
            }
            // --- Strategy 2: System grep ---
            const grepAvailable = await this.isCommandAvailable('grep');
            if (grepAvailable) {
                strategyUsed = 'system grep';
                const grepArgs = ['-r', '-n', '-H', '-E'];
                // Extract directory names from exclusion patterns for grep --exclude-dir
                const globExcludes = this.fileExclusions.getGlobExcludes();
                const commonExcludes = globExcludes
                    .map((pattern) => {
                    let dir = pattern;
                    if (dir.startsWith('**/')) {
                        dir = dir.substring(3);
                    }
                    if (dir.endsWith('/**')) {
                        dir = dir.slice(0, -3);
                    }
                    else if (dir.endsWith('/')) {
                        dir = dir.slice(0, -1);
                    }
                    // Only consider patterns that are likely directories. This filters out file patterns.
                    if (dir && !dir.includes('/') && !dir.includes('*')) {
                        return dir;
                    }
                    return null;
                })
                    .filter((dir) => !!dir);
                commonExcludes.forEach((dir) => grepArgs.push(`--exclude-dir=${dir}`));
                if (include) {
                    grepArgs.push(`--include=${include}`);
                }
                grepArgs.push(pattern);
                grepArgs.push('.');
                try {
                    const output = await new Promise((resolve, reject) => {
                        const child = spawn('grep', grepArgs, {
                            cwd: absolutePath,
                            windowsHide: true,
                        });
                        const stdoutChunks = [];
                        const stderrChunks = [];
                        const onData = (chunk) => stdoutChunks.push(chunk);
                        const onStderr = (chunk) => {
                            const stderrStr = chunk.toString();
                            // Suppress common harmless stderr messages
                            if (!stderrStr.includes('Permission denied') &&
                                !/grep:.*: Is a directory/i.test(stderrStr)) {
                                stderrChunks.push(chunk);
                            }
                        };
                        const onError = (err) => {
                            cleanup();
                            reject(new Error(`Failed to start system grep: ${err.message}`));
                        };
                        const onClose = (code) => {
                            const stdoutData = Buffer.concat(stdoutChunks).toString('utf8');
                            const stderrData = Buffer.concat(stderrChunks)
                                .toString('utf8')
                                .trim();
                            cleanup();
                            if (code === 0)
                                resolve(stdoutData);
                            else if (code === 1)
                                resolve(''); // No matches
                            else {
                                if (stderrData)
                                    reject(new Error(`System grep exited with code ${code}: ${stderrData}`));
                                else
                                    resolve(''); // Exit code > 1 but no stderr, likely just suppressed errors
                            }
                        };
                        const cleanup = () => {
                            child.stdout.removeListener('data', onData);
                            child.stderr.removeListener('data', onStderr);
                            child.removeListener('error', onError);
                            child.removeListener('close', onClose);
                            if (child.connected) {
                                child.disconnect();
                            }
                        };
                        child.stdout.on('data', onData);
                        child.stderr.on('data', onStderr);
                        child.on('error', onError);
                        child.on('close', onClose);
                    });
                    const matches = this.parseGrepOutput(output, absolutePath);
                    return this.applyLimits(matches, maxResults, maxFiles, maxPerFile);
                }
                catch (grepError) {
                    console.debug(`GrepLogic: System grep failed: ${getErrorMessage(grepError)}. Falling back...`);
                }
            }
            // --- Strategy 3: Pure JavaScript Fallback ---
            console.debug('GrepLogic: Falling back to JavaScript grep implementation.');
            strategyUsed = 'javascript fallback';
            const globPattern = include ? include : '**/*';
            const ignorePatterns = this.fileExclusions.getGlobExcludes();
            const filesStream = globStream(globPattern, {
                cwd: absolutePath,
                dot: true,
                ignore: ignorePatterns,
                absolute: true,
                nodir: true,
                signal: options.signal,
            });
            const regex = new RegExp(pattern, 'i');
            const allMatches = [];
            const filesWithMatches = new Set();
            let totalFound = 0;
            for await (const filePath of filesStream) {
                // Check if we've hit file limit
                if (filesWithMatches.size >= maxFiles &&
                    !filesWithMatches.has(filePath)) {
                    continue;
                }
                // Check if we've hit total results limit
                if (allMatches.length >= maxResults) {
                    break;
                }
                const fileAbsolutePath = filePath;
                try {
                    const content = await fsPromises.readFile(fileAbsolutePath, 'utf8');
                    const lines = content.split(/\r?\n/);
                    let matchesInFile = 0;
                    lines.forEach((line, index) => {
                        if (regex.test(line)) {
                            totalFound++;
                            if (matchesInFile < maxPerFile &&
                                allMatches.length < maxResults) {
                                allMatches.push({
                                    filePath: path.relative(absolutePath, fileAbsolutePath) ||
                                        path.basename(fileAbsolutePath),
                                    lineNumber: index + 1,
                                    line,
                                });
                                matchesInFile++;
                                filesWithMatches.add(fileAbsolutePath);
                            }
                        }
                    });
                }
                catch (readError) {
                    // Ignore errors like permission denied or file gone during read
                    if (!isNodeError(readError) || readError.code !== 'ENOENT') {
                        console.debug(`GrepLogic: Could not read/process ${fileAbsolutePath}: ${getErrorMessage(readError)}`);
                    }
                }
            }
            return {
                results: allMatches,
                wasLimited: totalFound > allMatches.length,
                totalFound: totalFound > allMatches.length ? totalFound : undefined,
            };
        }
        catch (error) {
            console.error(`GrepLogic: Error in performGrepSearch (Strategy: ${strategyUsed}): ${getErrorMessage(error)}`);
            throw error; // Re-throw
        }
    }
}
// --- GrepLogic Class ---
/**
 * Implementation of the Grep tool logic (moved from CLI)
 */
export class GrepTool extends BaseDeclarativeTool {
    config;
    static Name = 'search_file_content'; // Keep static name
    constructor(config, _messageBus) {
        super(GrepTool.Name, 'SearchText', 'Searches for a regular expression pattern within the content of files in a specified directory (or current working directory). Can filter files by a glob pattern. Returns the lines containing matches, along with their file paths and line numbers.', Kind.Search, {
            properties: {
                pattern: {
                    description: "The regular expression (regex) pattern to search for within file contents (e.g., 'function\\s+myFunction', 'import\\s+\\{.*\\}\\s+from\\s+.*').",
                    type: 'string',
                },
                path: {
                    description: 'Optional: The absolute path to the directory to search within. If omitted, searches the current working directory.',
                    type: 'string',
                },
                include: {
                    description: "Optional: A glob pattern to filter which files are searched (e.g., '*.js', '*.{ts,tsx}', 'src/**'). If omitted, searches all files (respecting potential global ignores).",
                    type: 'string',
                },
                max_results: {
                    description: 'Optional: Maximum number of total matches to return. Defaults to tool-output-max-items setting or 1000.',
                    type: 'number',
                },
                max_files: {
                    description: 'Optional: Maximum number of files to include in results. Defaults to 100.',
                    type: 'number',
                },
                max_per_file: {
                    description: 'Optional: Maximum number of matches per file to return. Defaults to 50.',
                    type: 'number',
                },
            },
            required: ['pattern'],
            type: 'object',
        });
        this.config = config;
    }
    /**
     * Checks if a path is within the root directory and resolves it.
     * @param relativePath Path relative to the root directory (or undefined for root).
     * @returns The absolute path if valid and exists, or null if no path specified (to search all directories).
     * @throws {Error} If path is outside root, doesn't exist, or isn't a directory.
     */
    resolveAndValidatePath(relativePath) {
        // If no path specified, return null to indicate searching all workspace directories
        if (!relativePath) {
            return null;
        }
        const targetPath = path.resolve(this.config.getTargetDir(), relativePath);
        // Security Check: Ensure the resolved path is within workspace boundaries
        const workspaceContext = this.config.getWorkspaceContext();
        if (!workspaceContext.isPathWithinWorkspace(targetPath)) {
            const directories = workspaceContext.getDirectories();
            throw new Error(`Path validation failed: Attempted path "${relativePath}" resolves outside the allowed workspace directories: ${directories.join(', ')}`);
        }
        // Check existence and type after resolving
        try {
            const stats = fs.statSync(targetPath);
            if (!stats.isDirectory()) {
                throw new Error(`Path is not a directory: ${targetPath}`);
            }
        }
        catch (error) {
            if (isNodeError(error) && error.code !== 'ENOENT') {
                throw new Error(`Path does not exist: ${targetPath}`);
            }
            throw new Error(`Failed to access path stats for ${targetPath}: ${error}`);
        }
        return targetPath;
    }
    /**
     * Validates the parameters for the tool
     * @param params Parameters to validate
     * @returns An error message string if invalid, null otherwise
     */
    validateToolParamValues(params) {
        try {
            new RegExp(params.pattern);
        }
        catch (error) {
            return `Invalid regular expression pattern provided: ${params.pattern}. Error: ${getErrorMessage(error)}`;
        }
        // Only validate path if one is provided
        if (params.path) {
            try {
                this.resolveAndValidatePath(params.path);
            }
            catch (error) {
                return getErrorMessage(error);
            }
        }
        return null; // Parameters are valid
    }
    createInvocation(params, _messageBus) {
        return new GrepToolInvocation(this.config, params);
    }
}
//# sourceMappingURL=grep.js.map