/**
 * @license
 * Copyright 2026 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */

import stripAnsi from 'strip-ansi';
import { getPty, type PtyImplementation } from '../utils/getPty.js';
import { spawn as cpSpawn, type ChildProcess } from 'node:child_process';
import { TextDecoder } from 'node:util';
import type { Writable } from 'node:stream';
import os from 'node:os';
import fs, { mkdirSync } from 'node:fs';
import path from 'node:path';
import type { IPty } from '@lydell/node-pty';
import { getCachedEncodingForBuffer } from '../utils/systemEncoding.js';
import {
  getShellConfiguration,
  resolveExecutable,
  type ShellType,
} 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,
  type AnsiOutput,
} from '../utils/terminalSerializer.js';
import {
  sanitizeEnvironment,
  type EnvironmentSanitizationConfig,
} from './environmentSanitization.js';
import {
  NoopSandboxManager,
  type SandboxManager,
  type SandboxPermissions,
} from './sandboxManager.js';
import type { SandboxConfig } from '../config/config.js';
import { killProcessGroup } from '../utils/process-utils.js';
import {
  ExecutionLifecycleService,
  type ExecutionHandle,
  type ExecutionOutputEvent,
  type ExecutionResult,
} 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: string, shell: ShellType): string {
  if (shell !== 'bash') {
    return command;
  }

  const trimmed = command.trimStart();
  if (trimmed.startsWith(BASH_SHOPT_GUARD)) {
    return command;
  }

  return `${BASH_SHOPT_GUARD} ${command}`;
}

/** A structured result from a shell command execution. */
export type ShellExecutionResult = ExecutionResult;

/** A handle for an ongoing shell execution. */
export type ShellExecutionHandle = ExecutionHandle;

export interface ShellExecutionConfig {
  additionalPermissions?: SandboxPermissions;
  terminalWidth?: number;
  terminalHeight?: number;
  pager?: string;
  showColor?: boolean;
  defaultFg?: string;
  defaultBg?: string;
  sanitizationConfig: EnvironmentSanitizationConfig;
  sandboxManager: SandboxManager;
  // Used for testing
  disableDynamicLineTrimming?: boolean;
  scrollback?: number;
  maxSerializedLines?: number;
  sandboxConfig?: SandboxConfig;
  backgroundCompletionBehavior?: 'inject' | 'notify' | 'silent';
  originalCommand?: string;
  sessionId?: string;
}

/**
 * Describes a structured event emitted during shell command execution.
 */
export type ShellOutputEvent = ExecutionOutputEvent;

export type DestroyablePty = IPty & { destroy?: () => void };

interface ActivePty {
  ptyProcess: DestroyablePty;
  headlessTerminal: pkg.Terminal;
  maxSerializedLines?: number;
  command: string;
  sessionId?: string;
}

interface ActiveChildProcess {
  process: ChildProcess;
  state: {
    output: string;
    truncated: boolean;
    sniffChunks: Buffer[];
    binaryBytesReceived: number;
  };
  command: string;
  sessionId?: string;
}

const findLastContentLine = (
  buffer: pkg.IBuffer,
  startLine: number,
): number => {
  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: pkg.Terminal, startLine = 0): string => {
  const buffer = terminal.buffer.active;
  const lines: string[] = [];

  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: pkg.Terminal,
  stream: fs.WriteStream,
  startLine = 0,
): number => {
  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;
};

/**
 * A centralized service for executing shell commands with robust process
 * management, cross-platform compatibility, and streaming output capabilities.
 *
 */

export type BackgroundProcess = {
  pid: number;
  command: string;
  status: 'running' | 'exited';
  exitCode?: number | null;
  signal?: number | null;
};

export type BackgroundProcessRecord = Omit<BackgroundProcess, 'pid'> & {
  startTime: number;
  endTime?: number;
};

export class ShellExecutionService {
  private static activePtys = new Map<number, ActivePty>();
  private static activeChildProcesses = new Map<number, ActiveChildProcess>();
  private static backgroundLogPids = new Set<number>();
  private static backgroundLogStreams = new Map<number, fs.WriteStream>();
  private static backgroundProcessHistory = new Map<
    string, // sessionId
    Map<number, BackgroundProcessRecord>
  >();

  static getLogDir(): string {
    return path.join(Storage.getGlobalTempDir(), 'background-processes');
  }

  private static formatShellBackgroundCompletion(
    pid: number,
    behavior: string,
    output: string,
    error?: Error,
  ): string {
    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: number): string {
    return path.join(this.getLogDir(), `background-${pid}.log`);
  }

  private static syncBackgroundLog(pid: number, content: string): void {
    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));
    }
  }

  private static async cleanupLogStream(pid: number): Promise<void> {
    const stream = this.backgroundLogStreams.get(pid);
    if (stream) {
      await new Promise<void>((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: string,
    cwd: string,
    onOutputEvent: (event: ShellOutputEvent) => void,
    abortSignal: AbortSignal,
    shouldUseNodePty: boolean,
    shellExecutionConfig: ShellExecutionConfig,
  ): Promise<ShellExecutionHandle> {
    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,
    );
  }

  private static appendAndTruncate(
    currentBuffer: string,
    chunk: string,
    maxSize: number,
  ): { newBuffer: string; truncated: boolean } {
    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 };
  }

  private static async prepareExecution(
    commandToExecute: string,
    cwd: string,
    shellExecutionConfig: ShellExecutionConfig,
    isInteractive: boolean,
  ): Promise<{
    program: string;
    args: string[];
    env: NodeJS.ProcessEnv;
    cwd: string;
    cleanup?: () => void;
  }> {
    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: string[] = [];
    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: Record<string, string | undefined> = {
      ...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,
    };
  }

  private static async childProcessFallback(
    commandToExecute: string,
    cwd: string,
    onOutputEvent: (event: ShellOutputEvent) => void,
    abortSignal: AbortSignal,
    shellExecutionConfig: ShellExecutionConfig,
    isInteractive: boolean,
  ): Promise<ShellExecutionHandle> {
    let cmdCleanup: (() => void) | undefined;
    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: [] as Buffer[],
        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 as Writable | null;
              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:
        | ((result: ShellExecutionResult) => void)
        | undefined;
      const result =
        lifecycleHandle?.result ??
        new Promise<ShellExecutionResult>((resolve) => {
          resolveWithoutPid = resolve;
        });

      let stdoutDecoder: TextDecoder | null = null;
      let stderrDecoder: TextDecoder | null = null;
      let error: Error | null = null;
      let exited = false;

      let isStreamingRawContent = true;
      const MAX_SNIFF_SIZE = 4096;
      let sniffedBytes = 0;

      const handleOutput = (data: Buffer, stream: 'stdout' | 'stderr') => {
        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: ShellOutputEvent = { 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: ShellOutputEvent = {
              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: ShellOutputEvent = {
            type: 'binary_progress',
            bytesReceived: totalBytes,
          };
          onOutputEvent(event);
          if (child.pid) {
            ExecutionLifecycleService.emitEvent(child.pid, event);
          }
        }
      };

      const handleExit = (
        code: number | null,
        signal: NodeJS.Signals | null,
      ) => {
        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: ShellExecutionResult = {
          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: ShellOutputEvent = {
            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('close', (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: ShellOutputEvent = {
                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: ShellOutputEvent = {
                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 as Error;
      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).
   */
  private static destroyPtyProcess(ptyProcess: DestroyablePty): void {
    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).
   */
  private static cleanupPtyEntry(pid: number): void {
    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);
  }

  private static async executeWithPty(
    commandToExecute: string,
    cwd: string,
    onOutputEvent: (event: ShellOutputEvent) => void,
    abortSignal: AbortSignal,
    shellExecutionConfig: ShellExecutionConfig,
    ptyInfo: PtyImplementation,
  ): Promise<ShellExecutionHandle> {
    if (!ptyInfo) {
      // This should not happen, but as a safeguard...
      throw new Error('PTY implementation not found');
    }
    let spawnedPty: DestroyablePty | undefined;
    let cmdCleanup: (() => void) | undefined;

    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 as DestroyablePty;
      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: TextDecoder | null = null;
      let output: string | AnsiOutput | null = null;
      const sniffChunks: Buffer[] = [];
      let binaryBytesReceived = 0;
      const error: Error | null = null;
      let exited = false;

      let isStreamingRawContent = true;
      const MAX_SNIFF_SIZE = 4096;
      let sniffedBytes = 0;
      let isWriting = false;
      let hasStartedOutput = false;
      let renderTimeout: NodeJS.Timeout | null = 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: AnsiOutput;
        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: ShellOutputEvent = {
            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: Buffer) => {
        processingChain = processingChain.then(
          () =>
            new Promise<void>((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: ShellOutputEvent = { 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: ShellOutputEvent = {
                  type: 'binary_progress',
                  bytesReceived: totalBytes,
                };
                onOutputEvent(event);
                ExecutionLifecycleService.emitEvent(ptyPid, event);
                resolveChunk();
              }
            }),
        );
      };

      ptyProcess.onData((data: string) => {
        const bufferData = Buffer.from(data, 'utf-8');
        handleOutput(bufferData);
      });

      ptyProcess.onExit(
        ({ exitCode, signal }: { exitCode: number; signal?: number }) => {
          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: ShellOutputEvent = {
              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<'aborted'>((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 as Error;
      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: number, input: string): void {
    ExecutionLifecycleService.writeInput(pid, input);
  }

  static isPtyActive(pid: number): boolean {
    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: number,
    callback: (exitCode: number, signal?: number) => void,
  ): () => void {
    return ExecutionLifecycleService.onExit(pid, callback);
  }

  /**
   * Kills a process by its PID.
   *
   * @param pid The process ID to kill.
   */
  static async kill(pid: number): Promise<void> {
    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: number, sessionId?: string, command?: string): void {
    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<
        number,
        {
          command: string;
          status: 'running' | 'exited';
          exitCode?: number | null;
          signal?: number | null;
          startTime: number;
          endTime?: number;
        }
      >();

    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: number,
    listener: (event: ShellOutputEvent) => void,
  ): () => void {
    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: number, cols: number, rows: number): void {
    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 as { code?: string; message?: string };
        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: ShellOutputEvent = { 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: number, lines: number): void {
    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: string): BackgroundProcess[] {
    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(): void {
    this.activePtys.clear();
    this.activeChildProcesses.clear();
    this.backgroundLogPids.clear();
    this.backgroundLogStreams.clear();
    this.backgroundProcessHistory.clear();
  }
}
