/**
 * @license
 * Copyright 2025 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */
import os from 'node:os';
import fs from 'node:fs';
import path from 'node:path';
import { quote } from 'shell-quote';
import { spawn, spawnSync, } from 'node:child_process';
import * as readline from 'node:readline';
import { Language, Parser, Query } from 'web-tree-sitter';
import { loadWasmBinary } from './fileUtils.js';
import { debugLogger } from './debugLogger.js';
export const SHELL_TOOL_NAMES = ['run_shell_command', 'ShellTool'];
export async function resolveExecutable(exe) {
    if (path.isAbsolute(exe)) {
        try {
            await fs.promises.access(exe, fs.constants.X_OK);
            return exe;
        }
        catch {
            return undefined;
        }
    }
    const paths = (process.env['PATH'] || '').split(path.delimiter);
    const extensions = os.platform() === 'win32' ? ['.exe', '.cmd', '.bat', ''] : [''];
    for (const p of paths) {
        for (const ext of extensions) {
            const fullPath = path.join(p, exe + ext);
            try {
                await fs.promises.access(fullPath, fs.constants.X_OK);
                return fullPath;
            }
            catch {
                continue;
            }
        }
    }
    return undefined;
}
let bashLanguage = null;
let treeSitterInitialization = null;
let treeSitterInitializationError = null;
class ShellParserInitializationError extends Error {
    constructor(cause) {
        super(`Failed to initialize bash parser: ${cause.message}`, { cause });
        this.name = 'ShellParserInitializationError';
    }
}
function toError(value) {
    if (value instanceof Error) {
        return value;
    }
    if (typeof value === 'string') {
        return new Error(value);
    }
    return new Error('Unknown tree-sitter initialization error', {
        cause: value,
    });
}
async function loadBashLanguage() {
    try {
        treeSitterInitializationError = null;
        const [treeSitterBinary, bashBinary] = await Promise.all([
            loadWasmBinary(() => 
            // eslint-disable-next-line @typescript-eslint/ban-ts-comment
            // @ts-ignore resolved by esbuild-plugin-wasm during bundling
            import('web-tree-sitter/tree-sitter.wasm?binary'), 'web-tree-sitter/tree-sitter.wasm'),
            loadWasmBinary(() => 
            // eslint-disable-next-line @typescript-eslint/ban-ts-comment
            // @ts-ignore resolved by esbuild-plugin-wasm during bundling
            import('tree-sitter-bash/tree-sitter-bash.wasm?binary'), 'tree-sitter-bash/tree-sitter-bash.wasm'),
        ]);
        await Parser.init({ wasmBinary: treeSitterBinary });
        bashLanguage = await Language.load(bashBinary);
    }
    catch (error) {
        bashLanguage = null;
        const normalized = toError(error);
        const initializationError = normalized instanceof ShellParserInitializationError
            ? normalized
            : new ShellParserInitializationError(normalized);
        treeSitterInitializationError = initializationError;
        throw initializationError;
    }
}
export async function initializeShellParsers() {
    if (!treeSitterInitialization) {
        treeSitterInitialization = loadBashLanguage().catch((error) => {
            treeSitterInitialization = null;
            // Log the error but don't throw, allowing the application to fall back to safe defaults (ASK_USER)
            // or regex checks where appropriate.
            debugLogger.debug('Failed to initialize shell parsers:', error);
        });
    }
    await treeSitterInitialization;
}
const POWERSHELL_COMMAND_ENV = '__GCLI_POWERSHELL_COMMAND__';
const PARSE_TIMEOUT_MICROS = 1000 * 1000; // 1 second
// Encode the parser script as UTF-16LE base64 so we can pass it via PowerShell's -EncodedCommand flag;
// this avoids brittle quoting/escaping when spawning PowerShell and ensures the script is received byte-for-byte.
const POWERSHELL_PARSER_SCRIPT = Buffer.from(`
$ErrorActionPreference = 'Stop'
$commandText = $env:${POWERSHELL_COMMAND_ENV}
if ([string]::IsNullOrEmpty($commandText)) {
  Write-Output '{"success":false}'
  exit 0
}
$tokens = $null
$errors = $null
$ast = [System.Management.Automation.Language.Parser]::ParseInput($commandText, [ref]$tokens, [ref]$errors)
if ($errors -and $errors.Count -gt 0) {
  Write-Output '{"success":false}'
  exit 0
}
$commandAsts = $ast.FindAll({ param($node) $node -is [System.Management.Automation.Language.CommandAst] }, $true)
$commandObjects = @()
$hasRedirection = $false
foreach ($commandAst in $commandAsts) {
  if ($commandAst.Redirections.Count -gt 0) {
    $hasRedirection = $true
  }
  $name = $commandAst.GetCommandName()
  if ([string]::IsNullOrWhiteSpace($name)) {
    continue
  }
  $commandObjects += [PSCustomObject]@{
    name = $name
    text = $commandAst.Extent.Text.Trim()
  }
}
[PSCustomObject]@{
  success = $true
  commands = $commandObjects
  hasRedirection = $hasRedirection
} | ConvertTo-Json -Compress
`, 'utf16le').toString('base64');
const REDIRECTION_NAMES = new Set([
    'redirection (<)',
    'redirection (>)',
    'heredoc (<<)',
    'herestring (<<<)',
]);
function createParser() {
    if (!bashLanguage) {
        if (treeSitterInitializationError) {
            throw treeSitterInitializationError;
        }
        return null;
    }
    try {
        const parser = new Parser();
        parser.setLanguage(bashLanguage);
        return parser;
    }
    catch {
        return null;
    }
}
function parseCommandTree(command, timeoutMicros = PARSE_TIMEOUT_MICROS) {
    const parser = createParser();
    if (!parser || !command.trim()) {
        return null;
    }
    const deadline = performance.now() + timeoutMicros / 1000;
    let timedOut = false;
    try {
        const tree = parser.parse(command, null, {
            progressCallback: () => {
                if (performance.now() > deadline) {
                    timedOut = true;
                    // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
                    return true; // Returning true cancels parsing, but type says void
                }
            },
        });
        if (timedOut) {
            debugLogger.error('Bash command parsing timed out for command:', command);
            // Returning a partial tree could be risky so we return null to be safe.
            return null;
        }
        return tree;
    }
    catch {
        return null;
    }
}
function normalizeCommandName(raw) {
    if (raw.length >= 2) {
        const first = raw[0];
        const last = raw[raw.length - 1];
        if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
            return raw.slice(1, -1);
        }
    }
    const trimmed = raw.trim();
    if (!trimmed) {
        return trimmed;
    }
    return trimmed.split(/[\\/]/).pop() ?? trimmed;
}
function extractNameFromNode(node) {
    switch (node.type) {
        case 'command': {
            const nameNode = node.childForFieldName('name');
            if (!nameNode) {
                return null;
            }
            return normalizeCommandName(nameNode.text);
        }
        case 'declaration_command':
        case 'unset_command':
        case 'test_command': {
            const firstChild = node.child(0);
            if (!firstChild) {
                return null;
            }
            return normalizeCommandName(firstChild.text);
        }
        case 'file_redirect': {
            // The first child might be a file descriptor (e.g., '2>').
            // We iterate to find the actual operator token.
            for (let i = 0; i < node.childCount; i++) {
                const child = node.child(i);
                if (child && child.text.includes('<')) {
                    return 'redirection (<)';
                }
                if (child && child.text.includes('>')) {
                    return 'redirection (>)';
                }
            }
            return 'redirection (>)';
        }
        case 'heredoc_redirect':
            return 'heredoc (<<)';
        case 'herestring_redirect':
            return 'herestring (<<<)';
        default:
            return null;
    }
}
function collectCommandDetails(root, source) {
    const stack = [root];
    const details = [];
    while (stack.length > 0) {
        const current = stack.pop();
        const name = extractNameFromNode(current);
        if (name) {
            details.push({
                name,
                text: source.slice(current.startIndex, current.endIndex).trim(),
                startIndex: current.startIndex,
            });
        }
        // Traverse all children to find all sub-components (commands, redirections, etc.)
        for (let i = current.childCount - 1; i >= 0; i -= 1) {
            const child = current.child(i);
            if (child) {
                stack.push(child);
            }
        }
    }
    return details;
}
function hasPromptCommandTransform(root) {
    const stack = [root];
    while (stack.length > 0) {
        const current = stack.pop();
        if (!current) {
            continue;
        }
        if (current.type === 'expansion') {
            for (let i = 0; i < current.childCount - 1; i += 1) {
                const operatorNode = current.child(i);
                const transformNode = current.child(i + 1);
                if (operatorNode?.text === '@' &&
                    transformNode?.text?.toLowerCase() === 'p') {
                    return true;
                }
            }
        }
        for (let i = current.namedChildCount - 1; i >= 0; i -= 1) {
            const child = current.namedChild(i);
            if (child) {
                stack.push(child);
            }
        }
    }
    return false;
}
function parseBashCommandDetails(command) {
    if (treeSitterInitializationError) {
        debugLogger.debug('Bash parser not initialized:', treeSitterInitializationError);
        return null;
    }
    if (!bashLanguage) {
        initializeShellParsers().catch(() => {
            // The failure path is surfaced via treeSitterInitializationError.
        });
        return null;
    }
    const tree = parseCommandTree(command);
    if (!tree) {
        return null;
    }
    const details = collectCommandDetails(tree.rootNode, command);
    const hasError = tree.rootNode.hasError ||
        details.length === 0 ||
        hasPromptCommandTransform(tree.rootNode);
    if (hasError) {
        let query = null;
        try {
            query = new Query(bashLanguage, '(ERROR) @error (MISSING) @missing');
            const captures = query.captures(tree.rootNode);
            const syntaxErrors = captures.map((capture) => {
                const { node, name } = capture;
                const type = name === 'missing' ? 'Missing' : 'Error';
                return `${type} node: "${node.text}" at ${node.startPosition.row}:${node.startPosition.column}`;
            });
            debugLogger.log('Bash command parsing error detected for command:', command, 'Syntax Errors:', syntaxErrors);
        }
        catch (_e) {
            // Ignore query errors
        }
        finally {
            query?.delete();
        }
    }
    return {
        details: details.sort((a, b) => a.startIndex - b.startIndex),
        hasError,
    };
}
function parsePowerShellCommandDetails(command, executable) {
    const trimmed = command.trim();
    if (!trimmed) {
        return {
            details: [],
            hasError: true,
        };
    }
    try {
        const result = spawnSync(executable, [
            '-NoLogo',
            '-NoProfile',
            '-NonInteractive',
            '-EncodedCommand',
            POWERSHELL_PARSER_SCRIPT,
        ], {
            env: {
                ...process.env,
                [POWERSHELL_COMMAND_ENV]: command,
            },
            encoding: 'utf-8',
        });
        if (result.error || result.status !== 0) {
            return null;
        }
        const output = (result.stdout ?? '').toString().trim();
        if (!output) {
            return { details: [], hasError: true };
        }
        let parsed = null;
        try {
            // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
            parsed = JSON.parse(output);
        }
        catch {
            return { details: [], hasError: true };
        }
        if (!parsed?.success) {
            return { details: [], hasError: true };
        }
        const details = (parsed.commands ?? [])
            .map((commandDetail) => {
            if (!commandDetail || typeof commandDetail.name !== 'string') {
                return null;
            }
            const name = normalizeCommandName(commandDetail.name);
            const text = typeof commandDetail.text === 'string'
                ? commandDetail.text.trim()
                : command;
            return {
                name,
                text,
                startIndex: 0,
            };
        })
            .filter((detail) => detail !== null);
        return {
            details,
            hasError: details.length === 0,
            hasRedirection: parsed.hasRedirection,
        };
    }
    catch {
        return null;
    }
}
export function parseCommandDetails(command) {
    const configuration = getShellConfiguration();
    if (configuration.shell === 'powershell') {
        return parsePowerShellCommandDetails(command, configuration.executable);
    }
    if (configuration.shell === 'bash') {
        return parseBashCommandDetails(command);
    }
    return null;
}
/**
 * Determines the appropriate shell configuration for the current platform.
 *
 * This ensures we can execute command strings predictably and securely across platforms
 * using the `spawn(executable, [...argsPrefix, commandString], { shell: false })` pattern.
 *
 * @returns The ShellConfiguration for the current environment.
 */
export function getShellConfiguration() {
    if (isWindows()) {
        const comSpec = process.env['ComSpec'];
        if (comSpec) {
            const executable = comSpec.toLowerCase();
            if (executable.endsWith('powershell.exe') ||
                executable.endsWith('pwsh.exe')) {
                return {
                    executable: comSpec,
                    argsPrefix: ['-NoProfile', '-Command'],
                    shell: 'powershell',
                };
            }
        }
        // Default to PowerShell for all other Windows configurations.
        return {
            executable: 'powershell.exe',
            argsPrefix: ['-NoProfile', '-Command'],
            shell: 'powershell',
        };
    }
    // Unix-like systems (Linux, macOS)
    return { executable: 'bash', argsPrefix: ['-c'], shell: 'bash' };
}
/**
 * Export the platform detection constant for use in process management (e.g., killing processes).
 */
export const isWindows = () => os.platform() === 'win32';
/**
 * Escapes a string so that it can be safely used as a single argument
 * in a shell command, preventing command injection.
 *
 * @param arg The argument string to escape.
 * @param shell The type of shell the argument is for.
 * @returns The shell-escaped string.
 */
export function escapeShellArg(arg, shell) {
    if (!arg) {
        return '';
    }
    switch (shell) {
        case 'powershell':
            // For PowerShell, wrap in single quotes and escape internal single quotes by doubling them.
            return `'${arg.replace(/'/g, "''")}'`;
        case 'cmd':
            // Simple Windows escaping for cmd.exe: wrap in double quotes and escape inner double quotes.
            return `"${arg.replace(/"/g, '""')}"`;
        case 'bash':
        default:
            // POSIX shell escaping using shell-quote.
            return quote([arg]);
    }
}
/**
 * Splits a shell command into a list of individual commands, respecting quotes.
 * This is used to separate chained commands (e.g., using &&, ||, ;).
 * @param command The shell command string to parse
 * @returns An array of individual command strings
 */
/**
 * Checks if a command contains redirection operators.
 * Uses shell-specific parsers where possible, falling back to a broad regex check.
 */
export function hasRedirection(command) {
    const fallbackCheck = () => /[><]/.test(command);
    // If there are no redirection characters at all, we can skip parsing.
    if (!fallbackCheck()) {
        return false;
    }
    const configuration = getShellConfiguration();
    if (configuration.shell === 'powershell') {
        const parsed = parsePowerShellCommandDetails(command, configuration.executable);
        return parsed && !parsed.hasError
            ? !!parsed.hasRedirection
            : fallbackCheck();
    }
    if (configuration.shell === 'bash' && bashLanguage) {
        const tree = parseCommandTree(command);
        if (!tree)
            return fallbackCheck();
        const stack = [tree.rootNode];
        while (stack.length > 0) {
            const current = stack.pop();
            if (current.type === 'redirected_statement' ||
                current.type === 'file_redirect' ||
                current.type === 'heredoc_redirect' ||
                current.type === 'herestring_redirect') {
                return true;
            }
            for (let i = current.childCount - 1; i >= 0; i -= 1) {
                const child = current.child(i);
                if (child)
                    stack.push(child);
            }
        }
        return false;
    }
    return fallbackCheck();
}
export function splitCommands(command) {
    const parsed = parseCommandDetails(command);
    if (!parsed || parsed.hasError) {
        return [];
    }
    return parsed.details.map((detail) => detail.text).filter(Boolean);
}
/**
 * Extracts the root command from a given shell command string.
 * This is used to identify the base command for permission checks.
 * @param command The shell command string to parse
 * @returns The root command name, or undefined if it cannot be determined
 * @example getCommandRoot("ls -la /tmp") returns "ls"
 * @example getCommandRoot("git status && npm test") returns "git"
 */
export function getCommandRoot(command) {
    const parsed = parseCommandDetails(command);
    if (!parsed || parsed.hasError || parsed.details.length === 0) {
        return undefined;
    }
    return parsed.details[0]?.name;
}
export function getCommandRoots(command) {
    if (!command) {
        return [];
    }
    const parsed = parseCommandDetails(command);
    if (!parsed || parsed.hasError) {
        return [];
    }
    return parsed.details
        .map((detail) => detail.name)
        .filter((name) => !REDIRECTION_NAMES.has(name))
        .filter(Boolean);
}
export function stripShellWrapper(command) {
    const pattern = /^\s*(?:(?:sh|bash|zsh)\s+-c|cmd\.exe\s+\/c|powershell(?:\.exe)?\s+(?:-NoProfile\s+)?-Command|pwsh(?:\.exe)?\s+(?:-NoProfile\s+)?-Command)\s+/i;
    const match = command.match(pattern);
    if (match) {
        let newCommand = command.substring(match[0].length).trim();
        if ((newCommand.startsWith('"') && newCommand.endsWith('"')) ||
            (newCommand.startsWith("'") && newCommand.endsWith("'"))) {
            newCommand = newCommand.substring(1, newCommand.length - 1);
        }
        return newCommand;
    }
    return command.trim();
}
/**
 * Detects command substitution patterns in a shell command, following bash quoting rules:
 * - Single quotes ('): Everything literal, no substitution possible
 * - Double quotes ("): Command substitution with $() and backticks unless escaped with \
 * - No quotes: Command substitution with $(), <(), and backticks
 * @param command The shell command string to check
 * @returns true if command substitution would be executed by bash
 */
/**
 * Determines whether a given shell command is allowed to execute based on
 * the tool's configuration including allowlists and blocklists.
 *
 * This function operates in "default allow" mode. It is a wrapper around
 * `checkCommandPermissions`.
 *
 * @param command The shell command string to validate.
 * @param config The application configuration.
 * @returns An object with 'allowed' boolean and optional 'reason' string if not allowed.
 */
export const spawnAsync = (command, args, options) => new Promise((resolve, reject) => {
    const child = spawn(command, args, options);
    let stdout = '';
    let stderr = '';
    child.stdout.on('data', (data) => {
        stdout += data.toString();
    });
    child.stderr.on('data', (data) => {
        stderr += data.toString();
    });
    child.on('close', (code) => {
        if (code === 0) {
            resolve({ stdout, stderr });
        }
        else {
            reject(new Error(`Command failed with exit code ${code}:\n${stderr}`));
        }
    });
    child.on('error', (err) => {
        reject(err);
    });
});
/**
 * Executes a command and yields lines of output as they appear.
 * Use for large outputs where buffering is not feasible.
 *
 * @param command The executable to run
 * @param args Arguments for the executable
 * @param options Spawn options (cwd, env, etc.)
 */
export async function* execStreaming(command, args, options) {
    const child = spawn(command, args, {
        ...options,
        // ensure we don't open a window on windows if possible/relevant
        windowsHide: true,
    });
    const rl = readline.createInterface({
        input: child.stdout,
        terminal: false,
    });
    const errorChunks = [];
    let stderrTotalBytes = 0;
    const MAX_STDERR_BYTES = 20 * 1024; // 20KB limit
    child.stderr.on('data', (chunk) => {
        if (stderrTotalBytes < MAX_STDERR_BYTES) {
            errorChunks.push(chunk);
            stderrTotalBytes += chunk.length;
        }
    });
    let error = null;
    child.on('error', (err) => {
        error = err;
    });
    const onAbort = () => {
        // If manually aborted by signal, we kill immediately.
        if (!child.killed)
            child.kill();
    };
    if (options?.signal?.aborted) {
        onAbort();
    }
    else {
        options?.signal?.addEventListener('abort', onAbort);
    }
    let finished = false;
    try {
        for await (const line of rl) {
            if (options?.signal?.aborted)
                break;
            yield line;
        }
        finished = true;
    }
    finally {
        rl.close();
        options?.signal?.removeEventListener('abort', onAbort);
        // Ensure process is killed when the generator is closed (consumer breaks loop)
        let killedByGenerator = false;
        if (!finished && child.exitCode === null && !child.killed) {
            try {
                child.kill();
            }
            catch (_e) {
                // ignore error if process is already dead
            }
            killedByGenerator = true;
        }
        // Ensure we wait for the process to exit to check codes
        await new Promise((resolve, reject) => {
            // If an error occurred before we got here (e.g. spawn failure), reject immediately.
            if (error) {
                reject(error);
                return;
            }
            function checkExit(code) {
                // If we aborted or killed it manually, we treat it as success (stop waiting)
                if (options?.signal?.aborted || killedByGenerator) {
                    resolve();
                    return;
                }
                const allowed = options?.allowedExitCodes ?? [0];
                if (code !== null && allowed.includes(code)) {
                    resolve();
                }
                else {
                    // If we have an accumulated error or explicit error event
                    if (error)
                        reject(error);
                    else {
                        const stderr = Buffer.concat(errorChunks).toString('utf8');
                        const truncatedMsg = stderrTotalBytes >= MAX_STDERR_BYTES ? '...[truncated]' : '';
                        reject(new Error(`Process exited with code ${code}: ${stderr}${truncatedMsg}`));
                    }
                }
            }
            if (child.exitCode !== null) {
                checkExit(child.exitCode);
            }
            else {
                child.on('close', (code) => checkExit(code));
                child.on('error', (err) => reject(err));
            }
        });
    }
}
//# sourceMappingURL=shell-utils.js.map