/**
 * @license
 * Copyright 2026 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 fs, { mkdirSync } from 'node:fs';
import path from 'node:path';
import { getCachedEncodingForBuffer } from '../utils/systemEncoding.js';
import { getShellConfiguration, resolveExecutable, } from '../utils/shell-utils.js';
import { isBinary, truncateString } from '../utils/textUtils.js';
import pkg from '@xterm/headless';
import { debugLogger } from '../utils/debugLogger.js';
import { Storage } from '../config/storage.js';
import { serializeTerminalToObject, } from '../utils/terminalSerializer.js';
import { sanitizeEnvironment, } from './environmentSanitization.js';
import { NoopSandboxManager, } from './sandboxManager.js';
import { killProcessGroup } from '../utils/process-utils.js';
import { ExecutionLifecycleService, } from './executionLifecycleService.js';
const { Terminal } = pkg;
const MAX_CHILD_PROCESS_BUFFER_SIZE = 16 * 1024 * 1024; // 16MB
/**
 * An environment variable that is set for shell executions. This can be used
 * by downstream executables and scripts to identify that they were executed
 * from within Gemini CLI.
 */
export const GEMINI_CLI_IDENTIFICATION_ENV_VAR = 'GEMINI_CLI';
/**
 * The value of {@link GEMINI_CLI_IDENTIFICATION_ENV_VAR}
 */
export const GEMINI_CLI_IDENTIFICATION_ENV_VAR_VALUE = '1';
// 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 findLastContentLine = (buffer, startLine) => {
    const lineCount = buffer.length;
    for (let i = lineCount - 1; i >= startLine; i--) {
        const line = buffer.getLine(i);
        if (line && line.translateToString(true).length > 0) {
            return i;
        }
    }
    return -1;
};
const getFullBufferText = (terminal, startLine = 0) => {
    const buffer = terminal.buffer.active;
    const lines = [];
    const lastContentLine = findLastContentLine(buffer, startLine);
    if (lastContentLine === -1 || lastContentLine < startLine)
        return '';
    for (let i = startLine; i <= lastContentLine; i++) {
        const line = buffer.getLine(i);
        if (!line) {
            lines.push('');
            continue;
        }
        let trimRight = true;
        if (i + 1 <= lastContentLine) {
            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);
        }
    }
    return lines.join('\n');
};
const writeBufferToLogStream = (terminal, stream, startLine = 0) => {
    const buffer = terminal.buffer.active;
    const lastContentLine = findLastContentLine(buffer, startLine);
    if (lastContentLine === -1 || lastContentLine < startLine)
        return startLine;
    for (let i = startLine; i <= lastContentLine; i++) {
        const line = buffer.getLine(i);
        if (!line) {
            stream.write('\n');
            continue;
        }
        let trimRight = true;
        if (i + 1 <= lastContentLine) {
            const nextLine = buffer.getLine(i + 1);
            if (nextLine?.isWrapped) {
                trimRight = false;
            }
        }
        const lineContent = line.translateToString(trimRight);
        const stripped = stripAnsi(lineContent);
        if (line.isWrapped) {
            stream.write(stripped);
        }
        else {
            if (i > startLine) {
                stream.write('\n');
            }
            stream.write(stripped);
        }
    }
    // Ensure it ends with a newline if we wrote anything and the next line is not wrapped
    if (lastContentLine >= startLine) {
        const nextLine = terminal.buffer.active.getLine(lastContentLine + 1);
        if (!nextLine?.isWrapped) {
            stream.write('\n');
        }
    }
    return lastContentLine + 1;
};
export class ShellExecutionService {
    static activePtys = new Map();
    static activeChildProcesses = new Map();
    static backgroundLogPids = new Set();
    static backgroundLogStreams = new Map();
    static backgroundProcessHistory = new Map();
    static getLogDir() {
        return path.join(Storage.getGlobalTempDir(), 'background-processes');
    }
    static formatShellBackgroundCompletion(pid, behavior, output, error) {
        const logPath = ShellExecutionService.getLogFilePath(pid);
        const status = error ? `with error: ${error.message}` : 'successfully';
        if (behavior === 'inject') {
            const truncated = truncateString(output, 5000);
            return `[Background command completed ${status}. Output saved to ${logPath}]\n\n${truncated}`;
        }
        return `[Background command completed ${status}. Output saved to ${logPath}]`;
    }
    static getLogFilePath(pid) {
        return path.join(this.getLogDir(), `background-${pid}.log`);
    }
    static syncBackgroundLog(pid, content) {
        if (!this.backgroundLogPids.has(pid))
            return;
        const stream = this.backgroundLogStreams.get(pid);
        if (stream && content) {
            // Strip ANSI escape codes before logging
            stream.write(stripAnsi(content));
        }
    }
    static async cleanupLogStream(pid) {
        const stream = this.backgroundLogStreams.get(pid);
        if (stream) {
            await new Promise((resolve) => {
                stream.end(() => resolve());
            });
            this.backgroundLogStreams.delete(pid);
        }
        this.backgroundLogPids.delete(pid);
    }
    /**
     * 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 {
                    // Fallback to child_process
                }
            }
        }
        return this.childProcessFallback(commandToExecute, cwd, onOutputEvent, abortSignal, shellExecutionConfig, shouldUseNodePty);
    }
    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 async prepareExecution(commandToExecute, cwd, shellExecutionConfig, isInteractive) {
        const sandboxManager = shellExecutionConfig.sandboxManager ?? new NoopSandboxManager();
        // 1. Determine Shell Configuration
        const isWindows = os.platform() === 'win32';
        const isStrictSandbox = isWindows &&
            shellExecutionConfig.sandboxConfig?.enabled &&
            shellExecutionConfig.sandboxConfig?.command === 'windows-native' &&
            !shellExecutionConfig.sandboxConfig?.networkAccess;
        let { executable, argsPrefix, shell } = getShellConfiguration();
        if (isStrictSandbox) {
            shell = 'cmd';
            argsPrefix = ['/c'];
            executable = 'cmd.exe';
        }
        const resolvedExecutable = (await resolveExecutable(executable)) ?? executable;
        const guardedCommand = ensurePromptvarsDisabled(commandToExecute, shell);
        const spawnArgs = [...argsPrefix, guardedCommand];
        // 2. Prepare Environment
        const gitConfigKeys = [];
        if (!isInteractive) {
            for (const key in process.env) {
                if (key.startsWith('GIT_CONFIG_')) {
                    gitConfigKeys.push(key);
                }
            }
        }
        const sanitizationConfig = {
            ...shellExecutionConfig.sanitizationConfig,
            allowedEnvironmentVariables: [
                ...(shellExecutionConfig.sanitizationConfig
                    .allowedEnvironmentVariables || []),
                ...gitConfigKeys,
            ],
        };
        const sanitizedEnv = sanitizeEnvironment(process.env, sanitizationConfig);
        const baseEnv = {
            ...sanitizedEnv,
            [GEMINI_CLI_IDENTIFICATION_ENV_VAR]: GEMINI_CLI_IDENTIFICATION_ENV_VAR_VALUE,
            TERM: 'xterm-256color',
            PAGER: shellExecutionConfig.pager ?? 'cat',
            GIT_PAGER: shellExecutionConfig.pager ?? 'cat',
        };
        if (!isInteractive) {
            // Ensure all GIT_CONFIG_* variables are preserved even if they were redacted
            for (const key of gitConfigKeys) {
                baseEnv[key] = process.env[key];
            }
            const gitConfigCount = parseInt(baseEnv['GIT_CONFIG_COUNT'] || '0', 10);
            const newKey = `GIT_CONFIG_KEY_${gitConfigCount}`;
            const newValue = `GIT_CONFIG_VALUE_${gitConfigCount}`;
            // Ensure these new keys are allowed through sanitization
            sanitizationConfig.allowedEnvironmentVariables.push('GIT_CONFIG_COUNT', newKey, newValue);
            Object.assign(baseEnv, {
                GIT_TERMINAL_PROMPT: '0',
                GIT_ASKPASS: '',
                SSH_ASKPASS: '',
                GH_PROMPT_DISABLED: '1',
                GCM_INTERACTIVE: 'never',
                DISPLAY: '',
                DBUS_SESSION_BUS_ADDRESS: '',
                GIT_CONFIG_COUNT: (gitConfigCount + 1).toString(),
                [newKey]: 'credential.helper',
                [newValue]: '',
            });
        }
        // 3. Prepare Sandboxed Command
        const sandboxedCommand = await sandboxManager.prepareCommand({
            command: resolvedExecutable,
            args: spawnArgs,
            env: baseEnv,
            cwd,
            policy: {
                ...shellExecutionConfig,
                ...(shellExecutionConfig.sandboxConfig || {}),
                sanitizationConfig,
                additionalPermissions: shellExecutionConfig.additionalPermissions,
            },
        });
        return {
            program: sandboxedCommand.program,
            args: sandboxedCommand.args,
            env: sandboxedCommand.env,
            cwd: sandboxedCommand.cwd ?? cwd,
            cleanup: sandboxedCommand.cleanup,
        };
    }
    static async childProcessFallback(commandToExecute, cwd, onOutputEvent, abortSignal, shellExecutionConfig, isInteractive) {
        let cmdCleanup;
        try {
            const isWindows = os.platform() === 'win32';
            const prepared = await this.prepareExecution(commandToExecute, cwd, shellExecutionConfig, isInteractive);
            cmdCleanup = prepared.cleanup;
            const { program: finalExecutable, args: finalArgs, env: finalEnv, cwd: finalCwd, } = prepared;
            // Bun's child_process does not properly call setsid() for detached
            // processes, leaving children in the parent's session without a
            // controlling terminal. They receive SIGHUP immediately. Disable
            // detached mode in Bun; killProcessGroup already falls back to
            // direct-pid kill when the group kill fails.
            const isBun = 'bun' in process.versions;
            const child = cpSpawn(finalExecutable, finalArgs, {
                cwd: finalCwd,
                stdio: ['ignore', 'pipe', 'pipe'],
                windowsVerbatimArguments: isWindows ? false : undefined,
                shell: false,
                detached: !isWindows && !isBun,
                env: finalEnv,
            });
            const state = {
                output: '',
                truncated: false,
                sniffChunks: [],
                binaryBytesReceived: 0,
            };
            if (child.pid !== undefined) {
                this.activeChildProcesses.set(child.pid, {
                    process: child,
                    state,
                    command: shellExecutionConfig.originalCommand ?? commandToExecute,
                    sessionId: shellExecutionConfig.sessionId,
                });
            }
            const lifecycleHandle = child.pid
                ? ExecutionLifecycleService.attachExecution(child.pid, {
                    executionMethod: 'child_process',
                    getBackgroundOutput: () => state.output,
                    getSubscriptionSnapshot: () => state.output || undefined,
                    writeInput: (input) => {
                        const stdin = child.stdin;
                        if (stdin) {
                            stdin.write(input);
                        }
                    },
                    kill: () => {
                        if (child.pid) {
                            killProcessGroup({ pid: child.pid }).catch(() => { });
                            this.activeChildProcesses.delete(child.pid);
                        }
                    },
                    isActive: () => {
                        if (!child.pid) {
                            return false;
                        }
                        try {
                            return process.kill(child.pid, 0);
                        }
                        catch {
                            return false;
                        }
                    },
                    formatInjection: (output, error) => ShellExecutionService.formatShellBackgroundCompletion(child.pid, shellExecutionConfig.backgroundCompletionBehavior || 'silent', output, error ?? undefined),
                    completionBehavior: shellExecutionConfig.backgroundCompletionBehavior || 'silent',
                })
                : undefined;
            let resolveWithoutPid;
            const result = lifecycleHandle?.result ??
                new Promise((resolve) => {
                    resolveWithoutPid = resolve;
                });
            let stdoutDecoder = null;
            let stderrDecoder = null;
            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');
                    }
                }
                if (isStreamingRawContent && sniffedBytes < MAX_SNIFF_SIZE) {
                    state.sniffChunks.push(data);
                }
                else if (!isStreamingRawContent) {
                    state.binaryBytesReceived += data.length;
                }
                if (isStreamingRawContent && sniffedBytes < MAX_SNIFF_SIZE) {
                    const sniffBuffer = Buffer.concat(state.sniffChunks);
                    sniffedBytes = sniffBuffer.length;
                    if (isBinary(sniffBuffer)) {
                        isStreamingRawContent = false;
                        state.binaryBytesReceived = sniffBuffer.length;
                        const event = { type: 'binary_detected' };
                        onOutputEvent(event);
                        if (child.pid) {
                            ExecutionLifecycleService.emitEvent(child.pid, event);
                        }
                    }
                }
                if (isStreamingRawContent) {
                    const decoder = stream === 'stdout' ? stdoutDecoder : stderrDecoder;
                    const decodedChunk = decoder.decode(data, { stream: true });
                    const { newBuffer, truncated } = this.appendAndTruncate(state.output, decodedChunk, MAX_CHILD_PROCESS_BUFFER_SIZE);
                    state.output = newBuffer;
                    if (truncated) {
                        state.truncated = true;
                    }
                    if (decodedChunk) {
                        const event = {
                            type: 'data',
                            chunk: decodedChunk,
                        };
                        onOutputEvent(event);
                        if (child.pid) {
                            ExecutionLifecycleService.emitEvent(child.pid, event);
                            if (ShellExecutionService.backgroundLogPids.has(child.pid)) {
                                ShellExecutionService.syncBackgroundLog(child.pid, decodedChunk);
                            }
                        }
                    }
                }
                else {
                    const totalBytes = state.binaryBytesReceived;
                    const event = {
                        type: 'binary_progress',
                        bytesReceived: totalBytes,
                    };
                    onOutputEvent(event);
                    if (child.pid) {
                        ExecutionLifecycleService.emitEvent(child.pid, event);
                    }
                }
            };
            const handleExit = (code, signal) => {
                cleanup();
                cmdCleanup?.();
                let combinedOutput = state.output;
                if (state.truncated) {
                    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();
                const exitCode = code;
                const exitSignal = signal && os.constants.signals
                    ? (os.constants.signals[signal] ?? null)
                    : null;
                const resultPayload = {
                    rawOutput: Buffer.from(''),
                    output: finalStrippedOutput,
                    exitCode,
                    signal: exitSignal,
                    error,
                    aborted: abortSignal.aborted,
                    pid: child.pid,
                    executionMethod: 'child_process',
                };
                if (child.pid) {
                    const pid = child.pid;
                    const event = {
                        type: 'exit',
                        exitCode,
                        signal: exitSignal,
                    };
                    const sessionId = shellExecutionConfig.sessionId ?? 'default';
                    const history = ShellExecutionService.backgroundProcessHistory.get(sessionId);
                    const historyItem = history?.get(pid);
                    if (historyItem) {
                        historyItem.status = 'exited';
                        historyItem.exitCode = exitCode ?? undefined;
                        historyItem.signal = exitSignal ?? undefined;
                        historyItem.endTime = Date.now();
                    }
                    onOutputEvent(event);
                    // eslint-disable-next-line @typescript-eslint/no-floating-promises
                    ShellExecutionService.cleanupLogStream(pid).then(() => {
                        ShellExecutionService.activeChildProcesses.delete(pid);
                    });
                    ExecutionLifecycleService.completeWithResult(pid, resultPayload);
                }
                else {
                    resolveWithoutPid?.(resultPayload);
                }
            };
            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) {
                    await killProcessGroup({
                        pid: child.pid,
                        escalate: true,
                        isExited: () => exited,
                    });
                }
            };
            abortSignal.addEventListener('abort', abortHandler, { once: true });
            child.on('exit', (code, signal) => {
                handleExit(code, signal);
            });
            function cleanup() {
                exited = true;
                abortSignal.removeEventListener('abort', abortHandler);
                if (stdoutDecoder) {
                    const remaining = stdoutDecoder.decode();
                    if (remaining) {
                        state.output += remaining;
                        if (isStreamingRawContent) {
                            const event = {
                                type: 'data',
                                chunk: remaining,
                            };
                            onOutputEvent(event);
                            if (child.pid) {
                                ExecutionLifecycleService.emitEvent(child.pid, event);
                            }
                        }
                    }
                }
                if (stderrDecoder) {
                    const remaining = stderrDecoder.decode();
                    if (remaining) {
                        state.output += remaining;
                        if (isStreamingRawContent) {
                            const event = {
                                type: 'data',
                                chunk: remaining,
                            };
                            onOutputEvent(event);
                            if (child.pid) {
                                ExecutionLifecycleService.emitEvent(child.pid, event);
                            }
                        }
                    }
                }
                return;
            }
            return { pid: child.pid, result };
        }
        catch (e) {
            // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
            const error = e;
            cmdCleanup?.();
            return {
                pid: undefined,
                result: Promise.resolve({
                    error,
                    rawOutput: Buffer.from(''),
                    output: '',
                    exitCode: 1,
                    signal: null,
                    aborted: false,
                    pid: undefined,
                    executionMethod: 'none',
                }),
            };
        }
    }
    /**
     * Destroys a PTY process to release its file descriptors.
     * This is critical to prevent system-wide PTY exhaustion (see #15945).
     */
    static destroyPtyProcess(ptyProcess) {
        try {
            if (typeof ptyProcess?.destroy === 'function') {
                ptyProcess.destroy();
            }
            else if (typeof ptyProcess?.kill === 'function') {
                // Fallback: if destroy() is unavailable, kill() may still close FDs
                ptyProcess.kill();
            }
        }
        catch {
            // Ignore errors during PTY cleanup — process may already be dead
        }
    }
    /**
     * Cleans up all resources associated with a PTY entry:
     * the PTY process (file descriptors) and the headless terminal (memory buffers).
     */
    static cleanupPtyEntry(pid) {
        const entry = this.activePtys.get(pid);
        if (!entry)
            return;
        this.destroyPtyProcess(entry.ptyProcess);
        try {
            entry.headlessTerminal.dispose();
        }
        catch {
            // Ignore errors during terminal cleanup
        }
        this.activePtys.delete(pid);
    }
    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');
        }
        let spawnedPty;
        let cmdCleanup;
        try {
            const cols = shellExecutionConfig.terminalWidth ?? 80;
            const rows = shellExecutionConfig.terminalHeight ?? 30;
            const prepared = await this.prepareExecution(commandToExecute, cwd, shellExecutionConfig, true);
            cmdCleanup = prepared.cleanup;
            const { program: finalExecutable, args: finalArgs, env: finalEnv, cwd: finalCwd, } = prepared;
            // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
            const ptyProcess = ptyInfo.module.spawn(finalExecutable, finalArgs, {
                cwd: finalCwd,
                name: 'xterm-256color',
                cols,
                rows,
                env: finalEnv,
                handleFlowControl: true,
            });
            // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
            spawnedPty = ptyProcess;
            const ptyPid = Number(ptyProcess.pid);
            const headlessTerminal = new Terminal({
                allowProposedApi: true,
                cols,
                rows,
                scrollback: shellExecutionConfig.scrollback ?? SCROLLBACK_LIMIT,
            });
            headlessTerminal.scrollToTop();
            this.activePtys.set(ptyPid, {
                // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
                ptyProcess,
                headlessTerminal,
                maxSerializedLines: shellExecutionConfig.maxSerializedLines,
                command: shellExecutionConfig.originalCommand ?? commandToExecute,
                sessionId: shellExecutionConfig.sessionId,
            });
            const result = ExecutionLifecycleService.attachExecution(ptyPid, {
                executionMethod: ptyInfo?.name ?? 'node-pty',
                writeInput: (input) => {
                    if (!ExecutionLifecycleService.isActive(ptyPid)) {
                        return;
                    }
                    ptyProcess.write(input);
                },
                kill: () => {
                    killProcessGroup({
                        pid: ptyPid,
                        // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
                        pty: ptyProcess,
                    }).catch(() => { });
                },
                isActive: () => {
                    try {
                        return process.kill(ptyPid, 0);
                    }
                    catch {
                        return false;
                    }
                },
                getBackgroundOutput: () => getFullBufferText(headlessTerminal),
                getSubscriptionSnapshot: () => {
                    const endLine = headlessTerminal.buffer.active.length;
                    const startLine = Math.max(0, endLine - (shellExecutionConfig.maxSerializedLines ?? 2000));
                    const bufferData = serializeTerminalToObject(headlessTerminal, startLine, endLine);
                    return bufferData.length > 0 ? bufferData : undefined;
                },
                formatInjection: (output, error) => ShellExecutionService.formatShellBackgroundCompletion(ptyPid, shellExecutionConfig.backgroundCompletionBehavior || 'silent', output, error ?? undefined),
                completionBehavior: shellExecutionConfig.backgroundCompletionBehavior || 'silent',
            }).result;
            let processingChain = Promise.resolve();
            let decoder = null;
            let output = null;
            const sniffChunks = [];
            let binaryBytesReceived = 0;
            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;
                const endLine = buffer.length;
                const startLine = Math.max(0, endLine - (shellExecutionConfig.maxSerializedLines ?? 2000));
                let newOutput;
                if (shellExecutionConfig.showColor) {
                    newOutput = serializeTerminalToObject(headlessTerminal, startLine, endLine);
                }
                else {
                    newOutput = (serializeTerminalToObject(headlessTerminal, startLine, endLine) ||
                        []).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;
                    }
                }
                const absoluteCursorY = buffer.baseY + buffer.cursorY;
                const cursorRelativeIndex = absoluteCursorY - startLine;
                if (cursorRelativeIndex > lastNonEmptyLine) {
                    lastNonEmptyLine = cursorRelativeIndex;
                }
                const trimmedOutput = newOutput.slice(0, lastNonEmptyLine + 1);
                const finalOutput = shellExecutionConfig.disableDynamicLineTrimming
                    ? newOutput
                    : trimmedOutput;
                if (output !== finalOutput) {
                    output = finalOutput;
                    const event = {
                        type: 'data',
                        chunk: finalOutput,
                    };
                    onOutputEvent(event);
                    ExecutionLifecycleService.emitEvent(ptyPid, event);
                }
            };
            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((resolveChunk) => {
                    if (!decoder) {
                        const encoding = getCachedEncodingForBuffer(data);
                        try {
                            decoder = new TextDecoder(encoding);
                        }
                        catch {
                            decoder = new TextDecoder('utf-8');
                        }
                    }
                    if (isStreamingRawContent && sniffedBytes < MAX_SNIFF_SIZE) {
                        sniffChunks.push(data);
                    }
                    else if (!isStreamingRawContent) {
                        binaryBytesReceived += data.length;
                    }
                    if (isStreamingRawContent && sniffedBytes < MAX_SNIFF_SIZE) {
                        const sniffBuffer = Buffer.concat(sniffChunks);
                        sniffedBytes = sniffBuffer.length;
                        if (isBinary(sniffBuffer)) {
                            isStreamingRawContent = false;
                            binaryBytesReceived = sniffBuffer.length;
                            const event = { type: 'binary_detected' };
                            onOutputEvent(event);
                            ExecutionLifecycleService.emitEvent(ptyPid, event);
                        }
                    }
                    if (isStreamingRawContent) {
                        const decodedChunk = decoder.decode(data, { stream: true });
                        if (decodedChunk.length === 0) {
                            resolveChunk();
                            return;
                        }
                        if (ShellExecutionService.backgroundLogPids.has(ptyPid)) {
                            ShellExecutionService.syncBackgroundLog(ptyPid, decodedChunk);
                        }
                        isWriting = true;
                        headlessTerminal.write(decodedChunk, () => {
                            render();
                            isWriting = false;
                            resolveChunk();
                        });
                    }
                    else {
                        const totalBytes = binaryBytesReceived;
                        const event = {
                            type: 'binary_progress',
                            bytesReceived: totalBytes,
                        };
                        onOutputEvent(event);
                        ExecutionLifecycleService.emitEvent(ptyPid, event);
                        resolveChunk();
                    }
                }));
            };
            ptyProcess.onData((data) => {
                const bufferData = Buffer.from(data, 'utf-8');
                handleOutput(bufferData);
            });
            ptyProcess.onExit(({ exitCode, signal }) => {
                exited = true;
                abortSignal.removeEventListener('abort', abortHandler);
                // Immediately destroy the PTY to release its master FD.
                // The headless terminal is kept alive until finalize() extracts
                // its buffer contents, then disposed to free memory.
                ShellExecutionService.destroyPtyProcess(ptyProcess);
                const finalize = () => {
                    render(true);
                    cmdCleanup?.();
                    const event = {
                        type: 'exit',
                        exitCode,
                        signal: signal ?? null,
                    };
                    const sessionId = shellExecutionConfig.sessionId ?? 'default';
                    const history = ShellExecutionService.backgroundProcessHistory.get(sessionId);
                    const historyItem = history?.get(ptyPid);
                    if (historyItem) {
                        historyItem.status = 'exited';
                        historyItem.exitCode = exitCode;
                        historyItem.signal = signal ?? null;
                        historyItem.endTime = Date.now();
                    }
                    onOutputEvent(event);
                    const endLine = headlessTerminal.buffer.active.length;
                    const startLine = Math.max(0, endLine - (shellExecutionConfig.maxSerializedLines ?? 2000));
                    const ansiOutputSnapshot = serializeTerminalToObject(headlessTerminal, startLine, endLine);
                    const finalOutput = getFullBufferText(headlessTerminal);
                    // Dispose the headless terminal to free scrollback buffers.
                    // This must happen after getFullBufferText() extracts the output.
                    try {
                        headlessTerminal.dispose();
                    }
                    catch {
                        // Ignore errors during terminal cleanup
                    }
                    // eslint-disable-next-line @typescript-eslint/no-floating-promises
                    ShellExecutionService.cleanupLogStream(ptyPid).then(() => {
                        ShellExecutionService.activePtys.delete(ptyPid);
                    });
                    ExecutionLifecycleService.completeWithResult(ptyPid, {
                        rawOutput: Buffer.from(''),
                        output: finalOutput,
                        ansiOutput: ansiOutputSnapshot,
                        exitCode,
                        signal: signal ?? null,
                        error,
                        aborted: abortSignal.aborted,
                        pid: ptyPid,
                        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) {
                    await killProcessGroup({
                        pid: ptyPid,
                        escalate: true,
                        isExited: () => exited,
                        // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
                        pty: ptyProcess,
                    });
                }
            };
            abortSignal.addEventListener('abort', abortHandler, { once: true });
            return { pid: ptyPid, result };
        }
        catch (e) {
            // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
            const error = e;
            cmdCleanup?.();
            if (spawnedPty) {
                ShellExecutionService.destroyPtyProcess(spawnedPty);
            }
            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) {
        ExecutionLifecycleService.writeInput(pid, input);
    }
    static isPtyActive(pid) {
        return ExecutionLifecycleService.isActive(pid);
    }
    /**
     * Registers a callback to be invoked when the process with the given PID exits.
     * This attaches directly to the PTY's exit event.
     *
     * @param pid The process ID to watch.
     * @param callback The function to call on exit.
     * @returns An unsubscribe function.
     */
    static onExit(pid, callback) {
        return ExecutionLifecycleService.onExit(pid, callback);
    }
    /**
     * Kills a process by its PID.
     *
     * @param pid The process ID to kill.
     */
    static async kill(pid) {
        await this.cleanupLogStream(pid);
        this.activeChildProcesses.delete(pid);
        ExecutionLifecycleService.kill(pid);
        this.cleanupPtyEntry(pid);
    }
    /**
     * Moves a running shell command to the background.
     * This resolves the execution promise but keeps the PTY active.
     *
     * @param pid The process ID of the target PTY.
     */
    static background(pid, sessionId, command) {
        const activePty = this.activePtys.get(pid);
        const activeChild = this.activeChildProcesses.get(pid);
        const resolvedSessionId = sessionId ?? activePty?.sessionId ?? activeChild?.sessionId;
        const resolvedCommand = command ??
            activePty?.command ??
            activeChild?.command ??
            'unknown command';
        if (!resolvedSessionId) {
            throw new Error('Session ID is required for background operations');
        }
        const MAX_BACKGROUND_PROCESS_HISTORY_SIZE = 100;
        const history = this.backgroundProcessHistory.get(resolvedSessionId) ??
            new Map();
        if (history.size >= MAX_BACKGROUND_PROCESS_HISTORY_SIZE) {
            const oldestPid = history.keys().next().value;
            if (oldestPid !== undefined) {
                history.delete(oldestPid);
            }
        }
        history.set(pid, {
            command: resolvedCommand,
            status: 'running',
            startTime: Date.now(),
        });
        this.backgroundProcessHistory.set(resolvedSessionId, history);
        // Set up background logging
        const logPath = this.getLogFilePath(pid);
        const logDir = this.getLogDir();
        try {
            mkdirSync(logDir, { recursive: true, mode: 0o700 });
            const stream = fs.createWriteStream(logPath, { flags: 'wx' });
            stream.on('error', (err) => {
                debugLogger.warn('Background log stream error:', err);
            });
            this.backgroundLogStreams.set(pid, stream);
            if (activePty) {
                writeBufferToLogStream(activePty.headlessTerminal, stream, 0);
            }
            else if (activeChild) {
                const output = activeChild.state.output;
                if (output) {
                    stream.write(stripAnsi(output) + '\n');
                }
            }
        }
        catch (e) {
            debugLogger.warn('Failed to setup background logging:', e);
        }
        this.backgroundLogPids.add(pid);
        ExecutionLifecycleService.background(pid);
    }
    static subscribe(pid, listener) {
        return ExecutionLifecycleService.subscribe(pid, listener);
    }
    /**
     * 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.
                // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
                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;
                }
            }
        }
        // Force emit the new state after resize
        if (activePty) {
            const endLine = activePty.headlessTerminal.buffer.active.length;
            const startLine = Math.max(0, endLine - (activePty.maxSerializedLines ?? 2000));
            const bufferData = serializeTerminalToObject(activePty.headlessTerminal, startLine, endLine);
            const event = { type: 'data', chunk: bufferData };
            ExecutionLifecycleService.emitEvent(pid, event);
        }
    }
    /**
     * 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;
                }
            }
        }
    }
    static listBackgroundProcesses(sessionId) {
        if (!sessionId) {
            throw new Error('Session ID is required');
        }
        const history = this.backgroundProcessHistory.get(sessionId);
        if (!history)
            return [];
        return Array.from(history.entries()).map(([pid, info]) => ({
            pid,
            command: info.command,
            status: info.status,
            exitCode: info.exitCode,
            signal: info.signal,
        }));
    }
    /**
     * Resets the internal state of the ShellExecutionService.
     * This is intended for use in tests to ensure isolation.
     */
    static resetForTest() {
        this.activePtys.clear();
        this.activeChildProcesses.clear();
        this.backgroundLogPids.clear();
        this.backgroundLogStreams.clear();
        this.backgroundProcessHistory.clear();
    }
}
//# sourceMappingURL=shellExecutionService.js.map