/**
 * @license
 * Copyright 2025 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */
import stripAnsi from 'strip-ansi';
import { getPty } from '../utils/getPty.js';
import { spawn as cpSpawn } from 'node:child_process';
import { TextDecoder } from 'node:util';
import os from 'node:os';
import { getCachedEncodingForBuffer } from '../utils/systemEncoding.js';
import { getShellConfiguration, resolveExecutable, } from '../utils/shell-utils.js';
import { isBinary } from '../utils/textUtils.js';
import pkg from '@xterm/headless';
import { serializeTerminalToObject, } from '../utils/terminalSerializer.js';
import { sanitizeEnvironment, } from './environmentSanitization.js';
const { Terminal } = pkg;
const SIGKILL_TIMEOUT_MS = 200;
const MAX_CHILD_PROCESS_BUFFER_SIZE = 16 * 1024 * 1024; // 16MB
// We want to allow shell outputs that are close to the context window in size.
// 300,000 lines is roughly equivalent to a large context window, ensuring
// we capture significant output from long-running commands.
export const SCROLLBACK_LIMIT = 300000;
const BASH_SHOPT_OPTIONS = 'promptvars nullglob extglob nocaseglob dotglob';
const BASH_SHOPT_GUARD = `shopt -u ${BASH_SHOPT_OPTIONS};`;
function ensurePromptvarsDisabled(command, shell) {
    if (shell !== 'bash') {
        return command;
    }
    const trimmed = command.trimStart();
    if (trimmed.startsWith(BASH_SHOPT_GUARD)) {
        return command;
    }
    return `${BASH_SHOPT_GUARD} ${command}`;
}
const getFullBufferText = (terminal) => {
    const buffer = terminal.buffer.active;
    const lines = [];
    for (let i = 0; i < buffer.length; i++) {
        const line = buffer.getLine(i);
        if (!line) {
            continue;
        }
        // If the NEXT line is wrapped, it means it's a continuation of THIS line.
        // We should not trim the right side of this line because trailing spaces
        // might be significant parts of the wrapped content.
        // If it's not wrapped, we trim normally.
        let trimRight = true;
        if (i + 1 < buffer.length) {
            const nextLine = buffer.getLine(i + 1);
            if (nextLine?.isWrapped) {
                trimRight = false;
            }
        }
        const lineContent = line.translateToString(trimRight);
        if (line.isWrapped && lines.length > 0) {
            lines[lines.length - 1] += lineContent;
        }
        else {
            lines.push(lineContent);
        }
    }
    // Remove trailing empty lines
    while (lines.length > 0 && lines[lines.length - 1] === '') {
        lines.pop();
    }
    return lines.join('\n');
};
/**
 * A centralized service for executing shell commands with robust process
 * management, cross-platform compatibility, and streaming output capabilities.
 *
 */
export class ShellExecutionService {
    static activePtys = new Map();
    /**
     * Executes a shell command using `node-pty`, capturing all output and lifecycle events.
     *
     * @param commandToExecute The exact command string to run.
     * @param cwd The working directory to execute the command in.
     * @param onOutputEvent A callback for streaming structured events about the execution, including data chunks and status updates.
     * @param abortSignal An AbortSignal to terminate the process and its children.
     * @returns An object containing the process ID (pid) and a promise that
     *          resolves with the complete execution result.
     */
    static async execute(commandToExecute, cwd, onOutputEvent, abortSignal, shouldUseNodePty, shellExecutionConfig) {
        if (shouldUseNodePty) {
            const ptyInfo = await getPty();
            if (ptyInfo) {
                try {
                    return await this.executeWithPty(commandToExecute, cwd, onOutputEvent, abortSignal, shellExecutionConfig, ptyInfo);
                }
                catch (_e) {
                    // Fallback to child_process
                }
            }
        }
        return this.childProcessFallback(commandToExecute, cwd, onOutputEvent, abortSignal, shellExecutionConfig.sanitizationConfig);
    }
    static appendAndTruncate(currentBuffer, chunk, maxSize) {
        const chunkLength = chunk.length;
        const currentLength = currentBuffer.length;
        const newTotalLength = currentLength + chunkLength;
        if (newTotalLength <= maxSize) {
            return { newBuffer: currentBuffer + chunk, truncated: false };
        }
        // Truncation is needed.
        if (chunkLength >= maxSize) {
            // The new chunk is larger than or equal to the max buffer size.
            // The new buffer will be the tail of the new chunk.
            return {
                newBuffer: chunk.substring(chunkLength - maxSize),
                truncated: true,
            };
        }
        // The combined buffer exceeds the max size, but the new chunk is smaller than it.
        // We need to truncate the current buffer from the beginning to make space.
        const charsToTrim = newTotalLength - maxSize;
        const truncatedBuffer = currentBuffer.substring(charsToTrim);
        return { newBuffer: truncatedBuffer + chunk, truncated: true };
    }
    static childProcessFallback(commandToExecute, cwd, onOutputEvent, abortSignal, sanitizationConfig) {
        try {
            const isWindows = os.platform() === 'win32';
            const { executable, argsPrefix, shell } = getShellConfiguration();
            const guardedCommand = ensurePromptvarsDisabled(commandToExecute, shell);
            const spawnArgs = [...argsPrefix, guardedCommand];
            const child = cpSpawn(executable, spawnArgs, {
                cwd,
                stdio: ['ignore', 'pipe', 'pipe'],
                windowsVerbatimArguments: isWindows ? false : undefined,
                shell: false,
                detached: !isWindows,
                env: {
                    ...sanitizeEnvironment(process.env, sanitizationConfig),
                    GEMINI_CLI: '1',
                    TERM: 'xterm-256color',
                    PAGER: 'cat',
                    GIT_PAGER: 'cat',
                },
            });
            const result = new Promise((resolve) => {
                let stdoutDecoder = null;
                let stderrDecoder = null;
                let stdout = '';
                let stderr = '';
                let stdoutTruncated = false;
                let stderrTruncated = false;
                const outputChunks = [];
                let error = null;
                let exited = false;
                let isStreamingRawContent = true;
                const MAX_SNIFF_SIZE = 4096;
                let sniffedBytes = 0;
                const handleOutput = (data, stream) => {
                    if (!stdoutDecoder || !stderrDecoder) {
                        const encoding = getCachedEncodingForBuffer(data);
                        try {
                            stdoutDecoder = new TextDecoder(encoding);
                            stderrDecoder = new TextDecoder(encoding);
                        }
                        catch {
                            stdoutDecoder = new TextDecoder('utf-8');
                            stderrDecoder = new TextDecoder('utf-8');
                        }
                    }
                    outputChunks.push(data);
                    if (isStreamingRawContent && sniffedBytes < MAX_SNIFF_SIZE) {
                        const sniffBuffer = Buffer.concat(outputChunks.slice(0, 20));
                        sniffedBytes = sniffBuffer.length;
                        if (isBinary(sniffBuffer)) {
                            isStreamingRawContent = false;
                        }
                    }
                    if (isStreamingRawContent) {
                        const decoder = stream === 'stdout' ? stdoutDecoder : stderrDecoder;
                        const decodedChunk = decoder.decode(data, { stream: true });
                        if (stream === 'stdout') {
                            const { newBuffer, truncated } = this.appendAndTruncate(stdout, decodedChunk, MAX_CHILD_PROCESS_BUFFER_SIZE);
                            stdout = newBuffer;
                            if (truncated) {
                                stdoutTruncated = true;
                            }
                        }
                        else {
                            const { newBuffer, truncated } = this.appendAndTruncate(stderr, decodedChunk, MAX_CHILD_PROCESS_BUFFER_SIZE);
                            stderr = newBuffer;
                            if (truncated) {
                                stderrTruncated = true;
                            }
                        }
                    }
                };
                const handleExit = (code, signal) => {
                    const { finalBuffer } = cleanup();
                    // Ensure we don't add an extra newline if stdout already ends with one.
                    const separator = stdout.endsWith('\n') ? '' : '\n';
                    let combinedOutput = stdout + (stderr ? (stdout ? separator : '') + stderr : '');
                    if (stdoutTruncated || stderrTruncated) {
                        const truncationMessage = `\n[GEMINI_CLI_WARNING: Output truncated. The buffer is limited to ${MAX_CHILD_PROCESS_BUFFER_SIZE / (1024 * 1024)}MB.]`;
                        combinedOutput += truncationMessage;
                    }
                    const finalStrippedOutput = stripAnsi(combinedOutput).trim();
                    if (isStreamingRawContent) {
                        if (finalStrippedOutput) {
                            onOutputEvent({ type: 'data', chunk: finalStrippedOutput });
                        }
                    }
                    else {
                        onOutputEvent({ type: 'binary_detected' });
                    }
                    resolve({
                        rawOutput: finalBuffer,
                        output: finalStrippedOutput,
                        exitCode: code,
                        signal: signal ? os.constants.signals[signal] : null,
                        error,
                        aborted: abortSignal.aborted,
                        pid: undefined,
                        executionMethod: 'child_process',
                    });
                };
                child.stdout.on('data', (data) => handleOutput(data, 'stdout'));
                child.stderr.on('data', (data) => handleOutput(data, 'stderr'));
                child.on('error', (err) => {
                    error = err;
                    handleExit(1, null);
                });
                const abortHandler = async () => {
                    if (child.pid && !exited) {
                        if (isWindows) {
                            cpSpawn('taskkill', ['/pid', child.pid.toString(), '/f', '/t']);
                        }
                        else {
                            try {
                                process.kill(-child.pid, 'SIGTERM');
                                await new Promise((res) => setTimeout(res, SIGKILL_TIMEOUT_MS));
                                if (!exited) {
                                    process.kill(-child.pid, 'SIGKILL');
                                }
                            }
                            catch (_e) {
                                if (!exited)
                                    child.kill('SIGKILL');
                            }
                        }
                    }
                };
                abortSignal.addEventListener('abort', abortHandler, { once: true });
                child.on('exit', (code, signal) => {
                    if (child.pid) {
                        this.activePtys.delete(child.pid);
                    }
                    handleExit(code, signal);
                });
                function cleanup() {
                    exited = true;
                    abortSignal.removeEventListener('abort', abortHandler);
                    if (stdoutDecoder) {
                        const remaining = stdoutDecoder.decode();
                        if (remaining) {
                            stdout += remaining;
                        }
                    }
                    if (stderrDecoder) {
                        const remaining = stderrDecoder.decode();
                        if (remaining) {
                            stderr += remaining;
                        }
                    }
                    const finalBuffer = Buffer.concat(outputChunks);
                    return { stdout, stderr, finalBuffer };
                }
            });
            return { pid: undefined, result };
        }
        catch (e) {
            const error = e;
            return {
                pid: undefined,
                result: Promise.resolve({
                    error,
                    rawOutput: Buffer.from(''),
                    output: '',
                    exitCode: 1,
                    signal: null,
                    aborted: false,
                    pid: undefined,
                    executionMethod: 'none',
                }),
            };
        }
    }
    static async executeWithPty(commandToExecute, cwd, onOutputEvent, abortSignal, shellExecutionConfig, ptyInfo) {
        if (!ptyInfo) {
            // This should not happen, but as a safeguard...
            throw new Error('PTY implementation not found');
        }
        try {
            const cols = shellExecutionConfig.terminalWidth ?? 80;
            const rows = shellExecutionConfig.terminalHeight ?? 30;
            const { executable, argsPrefix, shell } = getShellConfiguration();
            const resolvedExecutable = await resolveExecutable(executable);
            if (!resolvedExecutable) {
                throw new Error(`Shell executable "${executable}" not found in PATH or at absolute location. Please ensure the shell is installed and available in your environment.`);
            }
            const guardedCommand = ensurePromptvarsDisabled(commandToExecute, shell);
            const args = [...argsPrefix, guardedCommand];
            const ptyProcess = ptyInfo.module.spawn(executable, args, {
                cwd,
                name: 'xterm-256color',
                cols,
                rows,
                env: {
                    ...sanitizeEnvironment(process.env, shellExecutionConfig.sanitizationConfig),
                    GEMINI_CLI: '1',
                    TERM: 'xterm-256color',
                    PAGER: shellExecutionConfig.pager ?? 'cat',
                    GIT_PAGER: shellExecutionConfig.pager ?? 'cat',
                },
                handleFlowControl: true,
            });
            const result = new Promise((resolve) => {
                const headlessTerminal = new Terminal({
                    allowProposedApi: true,
                    cols,
                    rows,
                    scrollback: shellExecutionConfig.scrollback ?? SCROLLBACK_LIMIT,
                });
                headlessTerminal.scrollToTop();
                this.activePtys.set(ptyProcess.pid, { ptyProcess, headlessTerminal });
                let processingChain = Promise.resolve();
                let decoder = null;
                let output = null;
                const outputChunks = [];
                const error = null;
                let exited = false;
                let isStreamingRawContent = true;
                const MAX_SNIFF_SIZE = 4096;
                let sniffedBytes = 0;
                let isWriting = false;
                let hasStartedOutput = false;
                let renderTimeout = null;
                const renderFn = () => {
                    renderTimeout = null;
                    if (!isStreamingRawContent) {
                        return;
                    }
                    if (!shellExecutionConfig.disableDynamicLineTrimming) {
                        if (!hasStartedOutput) {
                            const bufferText = getFullBufferText(headlessTerminal);
                            if (bufferText.trim().length === 0) {
                                return;
                            }
                            hasStartedOutput = true;
                        }
                    }
                    const buffer = headlessTerminal.buffer.active;
                    let newOutput;
                    if (shellExecutionConfig.showColor) {
                        newOutput = serializeTerminalToObject(headlessTerminal);
                    }
                    else {
                        newOutput = (serializeTerminalToObject(headlessTerminal) || []).map((line) => line.map((token) => {
                            token.fg = '';
                            token.bg = '';
                            return token;
                        }));
                    }
                    let lastNonEmptyLine = -1;
                    for (let i = newOutput.length - 1; i >= 0; i--) {
                        const line = newOutput[i];
                        if (line
                            .map((segment) => segment.text)
                            .join('')
                            .trim().length > 0) {
                            lastNonEmptyLine = i;
                            break;
                        }
                    }
                    if (buffer.cursorY > lastNonEmptyLine) {
                        lastNonEmptyLine = buffer.cursorY;
                    }
                    const trimmedOutput = newOutput.slice(0, lastNonEmptyLine + 1);
                    const finalOutput = shellExecutionConfig.disableDynamicLineTrimming
                        ? newOutput
                        : trimmedOutput;
                    // Using stringify for a quick deep comparison.
                    if (JSON.stringify(output) !== JSON.stringify(finalOutput)) {
                        output = finalOutput;
                        onOutputEvent({
                            type: 'data',
                            chunk: finalOutput,
                        });
                    }
                };
                const render = (finalRender = false) => {
                    if (finalRender) {
                        if (renderTimeout) {
                            clearTimeout(renderTimeout);
                        }
                        renderFn();
                        return;
                    }
                    if (renderTimeout) {
                        return;
                    }
                    renderTimeout = setTimeout(() => {
                        renderFn();
                        renderTimeout = null;
                    }, 68);
                };
                headlessTerminal.onScroll(() => {
                    if (!isWriting) {
                        render();
                    }
                });
                const handleOutput = (data) => {
                    processingChain = processingChain.then(() => new Promise((resolve) => {
                        if (!decoder) {
                            const encoding = getCachedEncodingForBuffer(data);
                            try {
                                decoder = new TextDecoder(encoding);
                            }
                            catch {
                                decoder = new TextDecoder('utf-8');
                            }
                        }
                        outputChunks.push(data);
                        if (isStreamingRawContent && sniffedBytes < MAX_SNIFF_SIZE) {
                            const sniffBuffer = Buffer.concat(outputChunks.slice(0, 20));
                            sniffedBytes = sniffBuffer.length;
                            if (isBinary(sniffBuffer)) {
                                isStreamingRawContent = false;
                                onOutputEvent({ type: 'binary_detected' });
                            }
                        }
                        if (isStreamingRawContent) {
                            const decodedChunk = decoder.decode(data, { stream: true });
                            if (decodedChunk.length === 0) {
                                resolve();
                                return;
                            }
                            isWriting = true;
                            headlessTerminal.write(decodedChunk, () => {
                                render();
                                isWriting = false;
                                resolve();
                            });
                        }
                        else {
                            const totalBytes = outputChunks.reduce((sum, chunk) => sum + chunk.length, 0);
                            onOutputEvent({
                                type: 'binary_progress',
                                bytesReceived: totalBytes,
                            });
                            resolve();
                        }
                    }));
                };
                ptyProcess.onData((data) => {
                    const bufferData = Buffer.from(data, 'utf-8');
                    handleOutput(bufferData);
                });
                ptyProcess.onExit(({ exitCode, signal }) => {
                    exited = true;
                    abortSignal.removeEventListener('abort', abortHandler);
                    this.activePtys.delete(ptyProcess.pid);
                    // Attempt to destroy the PTY to ensure FD is closed
                    try {
                        ptyProcess.destroy?.();
                    }
                    catch {
                        // Ignore errors during cleanup
                    }
                    const finalize = () => {
                        render(true);
                        const finalBuffer = Buffer.concat(outputChunks);
                        resolve({
                            rawOutput: finalBuffer,
                            output: getFullBufferText(headlessTerminal),
                            exitCode,
                            signal: signal ?? null,
                            error,
                            aborted: abortSignal.aborted,
                            pid: ptyProcess.pid,
                            executionMethod: ptyInfo?.name ?? 'node-pty',
                        });
                    };
                    if (abortSignal.aborted) {
                        finalize();
                        return;
                    }
                    const processingComplete = processingChain.then(() => 'processed');
                    const abortFired = new Promise((res) => {
                        if (abortSignal.aborted) {
                            res('aborted');
                            return;
                        }
                        abortSignal.addEventListener('abort', () => res('aborted'), {
                            once: true,
                        });
                    });
                    // eslint-disable-next-line @typescript-eslint/no-floating-promises
                    Promise.race([processingComplete, abortFired]).then(() => {
                        finalize();
                    });
                });
                const abortHandler = async () => {
                    if (ptyProcess.pid && !exited) {
                        if (os.platform() === 'win32') {
                            ptyProcess.kill();
                        }
                        else {
                            try {
                                // Kill the entire process group
                                process.kill(-ptyProcess.pid, 'SIGTERM');
                                await new Promise((res) => setTimeout(res, SIGKILL_TIMEOUT_MS));
                                if (!exited) {
                                    process.kill(-ptyProcess.pid, 'SIGKILL');
                                }
                            }
                            catch (_e) {
                                // Fallback to killing just the process if the group kill fails
                                ptyProcess.kill('SIGTERM');
                                await new Promise((res) => setTimeout(res, SIGKILL_TIMEOUT_MS));
                                if (!exited) {
                                    ptyProcess.kill('SIGKILL');
                                }
                            }
                        }
                    }
                };
                abortSignal.addEventListener('abort', abortHandler, { once: true });
            });
            return { pid: ptyProcess.pid, result };
        }
        catch (e) {
            const error = e;
            if (error.message.includes('posix_spawnp failed')) {
                onOutputEvent({
                    type: 'data',
                    chunk: '[GEMINI_CLI_WARNING] PTY execution failed, falling back to child_process. This may be due to sandbox restrictions.\n',
                });
                throw e;
            }
            else {
                return {
                    pid: undefined,
                    result: Promise.resolve({
                        error,
                        rawOutput: Buffer.from(''),
                        output: '',
                        exitCode: 1,
                        signal: null,
                        aborted: false,
                        pid: undefined,
                        executionMethod: 'none',
                    }),
                };
            }
        }
    }
    /**
     * Writes a string to the pseudo-terminal (PTY) of a running process.
     *
     * @param pid The process ID of the target PTY.
     * @param input The string to write to the terminal.
     */
    static writeToPty(pid, input) {
        if (!this.isPtyActive(pid)) {
            return;
        }
        const activePty = this.activePtys.get(pid);
        if (activePty) {
            activePty.ptyProcess.write(input);
        }
    }
    static isPtyActive(pid) {
        try {
            // process.kill with signal 0 is a way to check for the existence of a process.
            // It doesn't actually send a signal.
            return process.kill(pid, 0);
        }
        catch (_) {
            return false;
        }
    }
    /**
     * Resizes the pseudo-terminal (PTY) of a running process.
     *
     * @param pid The process ID of the target PTY.
     * @param cols The new number of columns.
     * @param rows The new number of rows.
     */
    static resizePty(pid, cols, rows) {
        if (!this.isPtyActive(pid)) {
            return;
        }
        const activePty = this.activePtys.get(pid);
        if (activePty) {
            try {
                activePty.ptyProcess.resize(cols, rows);
                activePty.headlessTerminal.resize(cols, rows);
            }
            catch (e) {
                // Ignore errors if the pty has already exited, which can happen
                // due to a race condition between the exit event and this call.
                const err = e;
                const isEsrch = err.code === 'ESRCH';
                const isWindowsPtyError = err.message?.includes('Cannot resize a pty that has already exited');
                if (isEsrch || isWindowsPtyError) {
                    // On Unix, we get an ESRCH error.
                    // On Windows, we get a message-based error.
                    // In both cases, it's safe to ignore.
                }
                else {
                    throw e;
                }
            }
        }
    }
    /**
     * Scrolls the pseudo-terminal (PTY) of a running process.
     *
     * @param pid The process ID of the target PTY.
     * @param lines The number of lines to scroll.
     */
    static scrollPty(pid, lines) {
        if (!this.isPtyActive(pid)) {
            return;
        }
        const activePty = this.activePtys.get(pid);
        if (activePty) {
            try {
                activePty.headlessTerminal.scrollLines(lines);
                if (activePty.headlessTerminal.buffer.active.viewportY < 0) {
                    activePty.headlessTerminal.scrollToTop();
                }
            }
            catch (e) {
                // Ignore errors if the pty has already exited, which can happen
                // due to a race condition between the exit event and this call.
                if (e instanceof Error && 'code' in e && e.code === 'ESRCH') {
                    // ignore
                }
                else {
                    throw e;
                }
            }
        }
    }
}
//# sourceMappingURL=shellExecutionService.js.map