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

import {
  vi,
  describe,
  it,
  expect,
  beforeEach,
  afterEach,
  type Mock,
} from 'vitest';

import EventEmitter from 'node:events';
import type { Readable } from 'node:stream';
import { type ChildProcess } from 'node:child_process';
import {
  ShellExecutionService,
  type ShellOutputEvent,
  type ShellExecutionConfig,
} from './shellExecutionService.js';
import { NoopSandboxManager } from './sandboxManager.js';
import { ExecutionLifecycleService } from './executionLifecycleService.js';
import type { AnsiOutput, AnsiToken } from '../utils/terminalSerializer.js';

// Hoisted Mocks
const mockPtySpawn = vi.hoisted(() => vi.fn());
const mockCpSpawn = vi.hoisted(() => vi.fn());
const mockIsBinary = vi.hoisted(() => vi.fn());
const mockPlatform = vi.hoisted(() => vi.fn());
const mockHomedir = vi.hoisted(() => vi.fn());
const mockMkdirSync = vi.hoisted(() => vi.fn());
const mockCreateWriteStream = vi.hoisted(() => vi.fn());
const mockGetPty = vi.hoisted(() => vi.fn());
const mockSerializeTerminalToObject = vi.hoisted(() => vi.fn());
const mockResolveExecutable = vi.hoisted(() => vi.fn());
const mockDebugLogger = vi.hoisted(() => ({
  log: vi.fn(),
  warn: vi.fn(),
  error: vi.fn(),
  debug: vi.fn(),
}));

// Top-level Mocks
vi.mock('../config/storage.js', () => ({
  Storage: {
    getGlobalTempDir: vi.fn().mockReturnValue('/mock/temp'),
  },
}));
vi.mock('../utils/debugLogger.js', () => ({
  debugLogger: mockDebugLogger,
}));
vi.mock('@lydell/node-pty', () => ({
  spawn: mockPtySpawn,
}));
vi.mock('node:fs', async (importOriginal) => {
  const actual = await importOriginal<typeof import('node:fs')>();
  return {
    ...actual,
    default: {
      ...actual,
      mkdirSync: mockMkdirSync,
      createWriteStream: mockCreateWriteStream,
    },
    mkdirSync: mockMkdirSync,
    createWriteStream: mockCreateWriteStream,
  };
});
vi.mock('../utils/shell-utils.js', async (importOriginal) => {
  const actual =
    await importOriginal<typeof import('../utils/shell-utils.js')>();
  return {
    ...actual,
    resolveExecutable: mockResolveExecutable,
  };
});
vi.mock('node:child_process', async (importOriginal) => {
  const actual = await importOriginal();
  return {
    ...(actual as object),
    spawn: mockCpSpawn,
  };
});
vi.mock('../utils/textUtils.js', () => ({
  isBinary: mockIsBinary,
}));
vi.mock('node:os', () => ({
  default: {
    platform: mockPlatform,
    homedir: mockHomedir,
    constants: {
      signals: {
        SIGTERM: 15,
        SIGKILL: 9,
      },
    },
  },
  platform: mockPlatform,
  homedir: mockHomedir,
  constants: {
    signals: {
      SIGTERM: 15,
      SIGKILL: 9,
    },
  },
}));
vi.mock('../utils/getPty.js', () => ({
  getPty: mockGetPty,
}));
vi.mock('../utils/terminalSerializer.js', () => ({
  // Avoid passing the heavy Terminal object to the spy to prevent OOM
  serializeTerminalToObject: (
    _terminal: unknown,
    ...args: [number | undefined, number | undefined]
  ) => mockSerializeTerminalToObject(...args),
  convertColorToHex: () => '#000000',
  ColorMode: { DEFAULT: 0, PALETTE: 1, RGB: 2 },
}));
vi.mock('../utils/systemEncoding.js', () => ({
  getCachedEncodingForBuffer: vi.fn().mockReturnValue('utf-8'),
}));

const mockProcessKill = vi
  .spyOn(process, 'kill')
  .mockImplementation(() => true);

const shellExecutionConfig: ShellExecutionConfig = {
  terminalWidth: 80,
  terminalHeight: 24,
  pager: 'cat',
  showColor: false,
  disableDynamicLineTrimming: true,
  sanitizationConfig: {
    enableEnvironmentVariableRedaction: false,
    allowedEnvironmentVariables: [],
    blockedEnvironmentVariables: [],
  },
  sandboxManager: new NoopSandboxManager(),
};

const createMockSerializeTerminalToObjectReturnValue = (
  text: string | string[],
): AnsiOutput => {
  const lines = Array.isArray(text) ? text : text.split('\n');
  const len = shellExecutionConfig.terminalHeight ?? 24;
  const expected: AnsiOutput = Array.from({ length: len }, (_, i) => [
    {
      text: (lines[i] || '').trim(),
      bold: false,
      italic: false,
      underline: false,
      dim: false,
      inverse: false,
      fg: '#ffffff',
      bg: '#000000',
    },
  ]);
  return expected;
};

const createExpectedAnsiOutput = (text: string | string[]): AnsiOutput => {
  const lines = Array.isArray(text) ? text : text.split('\n');
  const len = shellExecutionConfig.terminalHeight ?? 24;
  const expected: AnsiOutput = Array.from({ length: len }, (_, i) => [
    {
      text: expect.stringMatching((lines[i] || '').trim()),
      bold: false,
      italic: false,
      underline: false,
      dim: false,
      inverse: false,
      fg: '',
      bg: '',
    } as AnsiToken,
  ]);
  return expected;
};

describe('ShellExecutionService', () => {
  let mockPtyProcess: EventEmitter & {
    pid: number;
    kill: Mock;
    onData: Mock;
    onExit: Mock;
    write: Mock;
    resize: Mock;
    destroy: Mock;
  };
  let mockHeadlessTerminal: {
    resize: Mock;
    scrollLines: Mock;
    buffer: {
      active: {
        viewportY: number;
        length: number;
        getLine: Mock;
      };
    };
  };
  let onOutputEventMock: Mock<(event: ShellOutputEvent) => void>;

  beforeEach(() => {
    vi.clearAllMocks();
    ExecutionLifecycleService.resetForTest();
    mockSerializeTerminalToObject.mockReturnValue([]);
    mockIsBinary.mockReturnValue(false);
    mockPlatform.mockReturnValue('linux');
    mockResolveExecutable.mockImplementation(async (exe: string) => exe);
    process.env['PATH'] = '/test/path';
    mockGetPty.mockResolvedValue({
      module: { spawn: mockPtySpawn },
      name: 'mock-pty',
    });

    onOutputEventMock = vi.fn();

    mockPtyProcess = new EventEmitter() as EventEmitter & {
      pid: number;
      kill: Mock;
      onData: Mock;
      onExit: Mock;
      write: Mock;
      resize: Mock;
      destroy: Mock;
    };
    mockPtyProcess.pid = 12345;
    mockPtyProcess.kill = vi.fn();
    mockPtyProcess.onData = vi.fn();
    mockPtyProcess.onExit = vi.fn();
    mockPtyProcess.write = vi.fn();
    mockPtyProcess.resize = vi.fn();
    mockPtyProcess.destroy = vi.fn();

    mockHeadlessTerminal = {
      resize: vi.fn(),
      scrollLines: vi.fn(),
      buffer: {
        active: {
          viewportY: 0,
          length: 0,
          getLine: vi.fn(),
        },
      },
    };

    mockPtySpawn.mockReturnValue(mockPtyProcess);
  });

  // Helper function to run a standard execution simulation
  const simulateExecution = async (
    command: string,
    simulation: (
      ptyProcess: typeof mockPtyProcess,
      ac: AbortController,
    ) => void | Promise<void>,
    config = shellExecutionConfig,
  ) => {
    const abortController = new AbortController();
    const handle = await ShellExecutionService.execute(
      command,
      '/test/dir',
      onOutputEventMock,
      abortController.signal,
      true,
      config,
    );

    await new Promise((resolve) => process.nextTick(resolve));
    await simulation(mockPtyProcess, abortController);
    const result = await handle.result;
    return { result, handle, abortController };
  };

  describe('Successful Execution', () => {
    it('should execute a command and capture output', async () => {
      mockSerializeTerminalToObject.mockReturnValue(
        createMockSerializeTerminalToObjectReturnValue('file1.txt'),
      );
      const { result, handle } = await simulateExecution('ls -l', (pty) => {
        pty.onData.mock.calls[0][0]('file1.txt\n');
        pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
      });

      expect(mockPtySpawn).toHaveBeenCalledWith(
        'bash',
        [
          '-c',
          'shopt -u promptvars nullglob extglob nocaseglob dotglob; ls -l',
        ],
        expect.any(Object),
      );
      expect(result.exitCode).toBe(0);
      expect(result.signal).toBeNull();
      expect(result.error).toBeNull();
      expect(result.aborted).toBe(false);
      expect(result.output.trim()).toBe('file1.txt');
      expect(handle.pid).toBe(12345);

      expect(onOutputEventMock).toHaveBeenCalledWith({
        type: 'data',
        chunk: createExpectedAnsiOutput('file1.txt'),
      });
    });

    it('should strip ANSI color codes from output', async () => {
      mockSerializeTerminalToObject.mockReturnValue(
        createMockSerializeTerminalToObjectReturnValue('aredword'),
      );
      const { result } = await simulateExecution('ls --color=auto', (pty) => {
        pty.onData.mock.calls[0][0]('a\u001b[31mred\u001b[0mword');
        pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
      });

      expect(result.output.trim()).toBe('aredword');
      expect(onOutputEventMock).toHaveBeenCalledWith(
        expect.objectContaining({
          type: 'data',
          chunk: createExpectedAnsiOutput('aredword'),
        }),
      );
    });

    it('should correctly decode multi-byte characters split across chunks', async () => {
      const { result } = await simulateExecution('echo "你好"', (pty) => {
        const multiByteChar = '你好';
        pty.onData.mock.calls[0][0](multiByteChar.slice(0, 1));
        pty.onData.mock.calls[0][0](multiByteChar.slice(1));
        pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
      });
      expect(result.output.trim()).toBe('你好');
    });

    it('should handle commands with no output', async () => {
      mockSerializeTerminalToObject.mockReturnValue(
        createMockSerializeTerminalToObjectReturnValue(''),
      );
      await simulateExecution('touch file', (pty) => {
        pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
      });

      expect(onOutputEventMock).toHaveBeenCalledWith(
        expect.objectContaining({
          chunk: createExpectedAnsiOutput(''),
        }),
      );
    });

    it('should capture large output (10000 lines)', async () => {
      const lineCount = 10000;
      const lines = Array.from({ length: lineCount }, (_, i) => `line ${i}`);
      const expectedOutput = lines.join('\n');

      const { result } = await simulateExecution(
        'large-output-command',
        (pty) => {
          // Send data in chunks to simulate realistic streaming
          // Use \r\n to ensure the terminal moves the cursor to the start of the line
          const chunkSize = 1000;
          for (let i = 0; i < lineCount; i += chunkSize) {
            const chunk = lines.slice(i, i + chunkSize).join('\r\n') + '\r\n';
            pty.onData.mock.calls[0][0](chunk);
          }
          pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
        },
        { ...shellExecutionConfig, maxSerializedLines: 100 },
      );

      expect(result.exitCode).toBe(0);
      // The terminal buffer output includes trailing spaces for each line (up to terminal width).
      // We trim each line to match our expected simple string.
      const processedOutput = result.output
        .split('\n')
        .map((l) => l.trimEnd())
        .join('\n')
        .trim();
      expect(processedOutput).toBe(expectedOutput);
      expect(result.output.split('\n').length).toBeGreaterThanOrEqual(
        lineCount,
      );
    });

    it('should not wrap long lines in the final output', async () => {
      // Set a small width to force wrapping
      const narrowConfig = { ...shellExecutionConfig, terminalWidth: 10 };
      const longString = '123456789012345'; // 15 chars, should wrap at 10

      const { result } = await simulateExecution(
        'long-line-command',
        (pty) => {
          pty.onData.mock.calls[0][0](longString);
          pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
        },
        narrowConfig,
      );

      expect(result.exitCode).toBe(0);
      expect(result.output.trim()).toBe(longString);
    });

    it('should not add extra padding but preserve explicit trailing whitespace', async () => {
      const { result } = await simulateExecution('cmd', (pty) => {
        // "value" should not get terminal-width padding
        // "value2    " should keep its spaces
        pty.onData.mock.calls[0][0]('value\r\nvalue2    ');
        pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
      });

      expect(result.output).toBe('value\nvalue2    ');
    });

    it('should truncate output exceeding the scrollback limit', async () => {
      const scrollbackLimit = 100;
      const totalLines = 150;
      // Generate lines: "line 0", "line 1", ...
      const lines = Array.from({ length: totalLines }, (_, i) => `line ${i}`);

      const { result } = await simulateExecution(
        'overflow-command',
        (pty) => {
          const chunk = lines.join('\r\n') + '\r\n';
          pty.onData.mock.calls[0][0](chunk);
          pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
        },
        { ...shellExecutionConfig, scrollback: scrollbackLimit },
      );

      expect(result.exitCode).toBe(0);

      // The terminal should keep the *last* 'scrollbackLimit' lines + lines in the viewport.
      // xterm.js scrollback is the number of lines *above* the viewport.
      // So total lines retained = scrollback + rows.
      // However, our `getFullBufferText` implementation iterates the *active* buffer.
      // In headless xterm, the buffer length grows.
      // Let's verify that we have fewer lines than totalLines.

      const outputLines = result.output
        .trim()
        .split('\n')
        .map((l) => l.trimEnd());

      // We expect the *start* of the output to be truncated.
      // The first retained line should be > "line 0".
      // Specifically, if we sent 150 lines and have space for roughly 100 + viewport(24),
      // we should miss the first ~26 lines.

      // Check that we lost some lines from the beginning
      expect(outputLines.length).toBeLessThan(totalLines);
      expect(outputLines[0]).not.toBe('line 0');

      // Check that we have the *last* lines
      expect(outputLines[outputLines.length - 1]).toBe(
        `line ${totalLines - 1}`,
      );
    });

    it('should call onPid with the process id', async () => {
      const abortController = new AbortController();
      const handle = await ShellExecutionService.execute(
        'ls -l',
        '/test/dir',
        onOutputEventMock,
        abortController.signal,
        true,
        shellExecutionConfig,
      );
      mockPtyProcess.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
      await handle.result;
      expect(handle.pid).toBe(12345);
    });
  });

  describe('pty interaction', () => {
    let activePtysGetSpy: { mockRestore: () => void };

    beforeEach(() => {
      activePtysGetSpy = vi
        .spyOn(ShellExecutionService['activePtys'], 'get')
        .mockReturnValue({
          // eslint-disable-next-line @typescript-eslint/no-explicit-any
          ptyProcess: mockPtyProcess as any,
          // eslint-disable-next-line @typescript-eslint/no-explicit-any
          headlessTerminal: mockHeadlessTerminal as any,
        });
    });

    afterEach(() => {
      activePtysGetSpy.mockRestore();
    });

    it('should write to the pty and trigger a render', async () => {
      vi.useFakeTimers();
      await simulateExecution('interactive-app', (pty) => {
        ShellExecutionService.writeToPty(pty.pid, 'input');
        pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
      });

      expect(mockPtyProcess.write).toHaveBeenCalledWith('input');
      // Use fake timers to check for the delayed render
      await vi.advanceTimersByTimeAsync(17);
      // The render will cause an output event
      expect(onOutputEventMock).toHaveBeenCalled();
      vi.useRealTimers();
    });

    it('should resize the pty and the headless terminal', async () => {
      await simulateExecution('ls -l', (pty) => {
        pty.onData.mock.calls[0][0]('file1.txt\n');
        ShellExecutionService.resizePty(pty.pid, 100, 40);
        pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
      });

      expect(mockPtyProcess.resize).toHaveBeenCalledWith(100, 40);
      expect(mockHeadlessTerminal.resize).toHaveBeenCalledWith(100, 40);
    });

    it('should not resize the pty if it is not active', async () => {
      const isPtyActiveSpy = vi
        .spyOn(ShellExecutionService, 'isPtyActive')
        .mockReturnValue(false);

      await simulateExecution('ls -l', (pty) => {
        ShellExecutionService.resizePty(pty.pid, 100, 40);
        pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
      });

      expect(mockPtyProcess.resize).not.toHaveBeenCalled();
      expect(mockHeadlessTerminal.resize).not.toHaveBeenCalled();
      isPtyActiveSpy.mockRestore();
    });

    it('should ignore errors when resizing an exited pty', async () => {
      const resizeError = new Error(
        'Cannot resize a pty that has already exited',
      );
      mockPtyProcess.resize.mockImplementation(() => {
        throw resizeError;
      });

      // We don't expect this test to throw an error
      await expect(
        simulateExecution('ls -l', (pty) => {
          ShellExecutionService.resizePty(pty.pid, 100, 40);
          pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
        }),
      ).resolves.not.toThrow();

      expect(mockPtyProcess.resize).toHaveBeenCalledWith(100, 40);
    });

    it('should re-throw other errors during resize', async () => {
      const otherError = new Error('Some other error');
      mockPtyProcess.resize.mockImplementation(() => {
        throw otherError;
      });

      await expect(
        simulateExecution('ls -l', (pty) => {
          ShellExecutionService.resizePty(pty.pid, 100, 40);
          pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
        }),
      ).rejects.toThrow('Some other error');
    });

    it('should scroll the headless terminal', async () => {
      await simulateExecution('ls -l', (pty) => {
        pty.onData.mock.calls[0][0]('file1.txt\n');
        ShellExecutionService.scrollPty(pty.pid, 10);
        pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
      });

      expect(mockHeadlessTerminal.scrollLines).toHaveBeenCalledWith(10);
    });

    it('should not throw when resizing a pty that has already exited (Windows)', () => {
      const resizeError = new Error(
        'Cannot resize a pty that has already exited',
      );
      mockPtyProcess.resize.mockImplementation(() => {
        throw resizeError;
      });

      // This should catch the specific error and not re-throw it.
      expect(() => {
        ShellExecutionService.resizePty(mockPtyProcess.pid, 100, 40);
      }).not.toThrow();

      expect(mockPtyProcess.resize).toHaveBeenCalledWith(100, 40);
      expect(mockHeadlessTerminal.resize).not.toHaveBeenCalled();
    });
  });

  describe('Failed Execution', () => {
    it('should capture a non-zero exit code', async () => {
      const { result } = await simulateExecution('a-bad-command', (pty) => {
        pty.onData.mock.calls[0][0]('command not found');
        pty.onExit.mock.calls[0][0]({ exitCode: 127, signal: null });
      });

      expect(result.exitCode).toBe(127);
      expect(result.output.trim()).toBe('command not found');
      expect(result.error).toBeNull();
    });

    it('should capture a termination signal', async () => {
      const { result } = await simulateExecution('long-process', (pty) => {
        pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: 15 });
      });

      expect(result.exitCode).toBe(0);
      expect(result.signal).toBe(15);
    });

    it('should handle a synchronous spawn error', async () => {
      mockGetPty.mockImplementation(() => null);

      mockCpSpawn.mockImplementation(() => {
        throw new Error('Simulated PTY spawn error');
      });

      const handle = await ShellExecutionService.execute(
        'any-command',
        '/test/dir',
        onOutputEventMock,
        new AbortController().signal,
        true,
        {
          ...shellExecutionConfig,
          sanitizationConfig: {
            enableEnvironmentVariableRedaction: true,
            allowedEnvironmentVariables: [],
            blockedEnvironmentVariables: [],
          },
        },
      );
      const result = await handle.result;

      expect(result.error).toBeInstanceOf(Error);
      expect(result.error?.message).toContain('Simulated PTY spawn error');
      expect(result.exitCode).toBe(1);
      expect(result.output).toBe('');
      expect(handle.pid).toBeUndefined();
    });
  });

  describe('Aborting Commands', () => {
    it('should abort a running process and set the aborted flag', async () => {
      const { result } = await simulateExecution(
        'sleep 10',
        (pty, abortController) => {
          abortController.abort();
          pty.onExit.mock.calls[0][0]({ exitCode: 1, signal: null });
        },
      );

      expect(result.aborted).toBe(true);
      // The process kill is mocked, so we just check that the flag is set.
    });

    it('should send SIGTERM and then SIGKILL on abort', async () => {
      const sigkillPromise = new Promise<void>((resolve) => {
        mockProcessKill.mockImplementation((pid, signal) => {
          if (signal === 'SIGKILL' && pid === -mockPtyProcess.pid) {
            resolve();
          }
          return true;
        });
      });

      const { result } = await simulateExecution(
        'long-running-process',
        async (pty, abortController) => {
          abortController.abort();
          await sigkillPromise; // Wait for SIGKILL to be sent before exiting.
          pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: 9 });
        },
      );

      expect(result.aborted).toBe(true);

      // Verify the calls were made in the correct order.
      const killCalls = mockProcessKill.mock.calls;
      const sigtermCallIndex = killCalls.findIndex(
        (call) => call[0] === -mockPtyProcess.pid && call[1] === 'SIGTERM',
      );
      const sigkillCallIndex = killCalls.findIndex(
        (call) => call[0] === -mockPtyProcess.pid && call[1] === 'SIGKILL',
      );

      expect(sigtermCallIndex).toBe(0);
      expect(sigkillCallIndex).toBe(1);
      expect(sigtermCallIndex).toBeLessThan(sigkillCallIndex);

      expect(result.signal).toBe(9);
    });

    it('should resolve without waiting for the processing chain on abort', async () => {
      const { result } = await simulateExecution(
        'long-output',
        (pty, abortController) => {
          // Simulate a lot of data being in the queue to be processed
          for (let i = 0; i < 1000; i++) {
            pty.onData.mock.calls[0][0]('some data');
          }
          abortController.abort();
          pty.onExit.mock.calls[0][0]({ exitCode: 1, signal: null });
        },
      );

      // The main assertion here is implicit: the `await` for the result above
      // should complete without timing out. This proves that the resolution
      // was not blocked by the long chain of data processing promises,
      // which is the desired behavior on abort.
      expect(result.aborted).toBe(true);
    });
  });

  describe('Backgrounding', () => {
    let mockWriteStream: { write: Mock; end: Mock; on: Mock };
    let mockBgChildProcess: EventEmitter & Partial<ChildProcess>;

    beforeEach(async () => {
      mockWriteStream = {
        write: vi.fn(),
        end: vi.fn().mockImplementation((cb) => cb?.()),
        on: vi.fn(),
      };

      mockMkdirSync.mockReturnValue(undefined);
      mockCreateWriteStream.mockReturnValue(
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        mockWriteStream as any,
      );
      mockHomedir.mockReturnValue('/mock/home');

      mockBgChildProcess = new EventEmitter() as EventEmitter &
        Partial<ChildProcess>;
      mockBgChildProcess.stdout = new EventEmitter() as Readable;
      mockBgChildProcess.stderr = new EventEmitter() as Readable;
      mockBgChildProcess.kill = vi.fn();
      Object.defineProperty(mockBgChildProcess, 'pid', {
        value: 99999,
        configurable: true,
      });
      mockCpSpawn.mockReturnValue(mockBgChildProcess);

      // Explicitly clear state between runs
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      (ShellExecutionService as any).backgroundLogStreams.clear();
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      (ShellExecutionService as any).activePtys.clear();
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      (ShellExecutionService as any).activeChildProcesses.clear();
    });

    afterEach(() => {
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      (ShellExecutionService as any).backgroundLogStreams.clear();
    });

    it('should move a running pty process to the background and start logging', async () => {
      const abortController = new AbortController();
      const handle = await ShellExecutionService.execute(
        'long-running-pty',
        '/',
        onOutputEventMock,
        abortController.signal,
        true,
        shellExecutionConfig,
      );

      // Use the registered onData listener
      const onDataListener = mockPtyProcess.onData.mock.calls[0][0];
      onDataListener('initial pty output');

      // Wait for async write to headless terminal
      await new Promise((resolve) => setTimeout(resolve, 100));

      mockSerializeTerminalToObject.mockReturnValue([
        [{ text: 'initial pty output', fg: '', bg: '' }],
      ]);

      // Background the process
      ShellExecutionService.background(handle.pid!);

      const result = await handle.result;
      expect(result.backgrounded).toBe(true);
      expect(result.output).toContain('initial pty output');

      expect(mockMkdirSync).toHaveBeenCalledWith(
        expect.stringContaining('background-processes'),
        { recursive: true },
      );

      // Verify initial output was written
      expect(
        mockWriteStream.write.mock.calls.some((call) =>
          call[0].includes('initial pty output'),
        ),
      ).toBe(true);

      await ShellExecutionService.kill(handle.pid!);
      expect(mockWriteStream.end).toHaveBeenCalled();
    });

    it('should continue logging after backgrounding for child_process', async () => {
      mockGetPty.mockResolvedValue(null); // Force child_process fallback

      const abortController = new AbortController();
      const handle = await ShellExecutionService.execute(
        'long-running-cp',
        '/',
        onOutputEventMock,
        abortController.signal,
        true,
        shellExecutionConfig,
      );

      // Trigger data before backgrounding
      mockBgChildProcess.stdout?.emit('data', Buffer.from('initial cp output'));
      await new Promise((resolve) => process.nextTick(resolve));

      ShellExecutionService.background(handle.pid!);

      const result = await handle.result;
      expect(result.backgrounded).toBe(true);
      expect(result.output).toBe('initial cp output');

      expect(
        mockWriteStream.write.mock.calls.some((call) =>
          call[0].includes('initial cp output'),
        ),
      ).toBe(true);

      // Subsequent output
      mockBgChildProcess.stdout?.emit('data', Buffer.from('more cp output'));
      await new Promise((resolve) => process.nextTick(resolve));
      expect(mockWriteStream.write).toHaveBeenCalledWith('more cp output');

      await ShellExecutionService.kill(handle.pid!);
      expect(mockWriteStream.end).toHaveBeenCalled();
    });

    it('should log a warning if background log setup fails', async () => {
      const abortController = new AbortController();
      const handle = await ShellExecutionService.execute(
        'failing-log-setup',
        '/',
        onOutputEventMock,
        abortController.signal,
        true,
        shellExecutionConfig,
      );

      // Mock mkdirSync to fail
      const error = new Error('Permission denied');
      mockMkdirSync.mockImplementationOnce(() => {
        throw error;
      });

      // Background the process
      ShellExecutionService.background(handle.pid!);

      const result = await handle.result;
      expect(result.backgrounded).toBe(true);
      expect(mockDebugLogger.warn).toHaveBeenCalledWith(
        'Failed to setup background logging:',
        error,
      );

      await ShellExecutionService.kill(handle.pid!);
    });
  });

  describe('Binary Output', () => {
    it('should detect binary output and switch to progress events', async () => {
      mockIsBinary.mockReturnValueOnce(true);
      const binaryChunk1 = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
      const binaryChunk2 = Buffer.from([0x0d, 0x0a, 0x1a, 0x0a]);

      await simulateExecution('cat image.png', (pty) => {
        pty.onData.mock.calls[0][0](binaryChunk1);
        pty.onData.mock.calls[0][0](binaryChunk2);
        pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
      });

      expect(onOutputEventMock).toHaveBeenCalledTimes(4);
      expect(onOutputEventMock.mock.calls[0][0]).toEqual({
        type: 'binary_detected',
      });
      expect(onOutputEventMock.mock.calls[1][0]).toEqual({
        type: 'binary_progress',
        bytesReceived: 4,
      });
      expect(onOutputEventMock.mock.calls[2][0]).toEqual({
        type: 'binary_progress',
        bytesReceived: 8,
      });
      expect(onOutputEventMock.mock.calls[3][0]).toEqual({
        type: 'exit',
        exitCode: 0,
        signal: null,
      });
    });

    it('should not emit data events after binary is detected', async () => {
      mockIsBinary.mockImplementation((buffer) => buffer.includes(0x00));

      await simulateExecution('cat mixed_file', (pty) => {
        pty.onData.mock.calls[0][0](Buffer.from([0x00, 0x01, 0x02]));
        pty.onData.mock.calls[0][0](Buffer.from('more text'));
        pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
      });

      const eventTypes = onOutputEventMock.mock.calls.map(
        (call: [ShellOutputEvent]) => call[0].type,
      );
      expect(eventTypes).toEqual([
        'binary_detected',
        'binary_progress',
        'binary_progress',
        'exit',
      ]);
    });
  });

  describe('Platform-Specific Behavior', () => {
    it('should use powershell.exe on Windows', async () => {
      mockPlatform.mockReturnValue('win32');
      await simulateExecution('dir "foo bar"', (pty) =>
        pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }),
      );

      expect(mockPtySpawn).toHaveBeenCalledWith(
        'powershell.exe',
        ['-NoProfile', '-Command', 'dir "foo bar"'],
        expect.any(Object),
      );
    });

    it('should use bash on Linux', async () => {
      mockPlatform.mockReturnValue('linux');
      await simulateExecution('ls "foo bar"', (pty) =>
        pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }),
      );

      expect(mockPtySpawn).toHaveBeenCalledWith(
        'bash',
        [
          '-c',
          'shopt -u promptvars nullglob extglob nocaseglob dotglob; ls "foo bar"',
        ],
        expect.any(Object),
      );
    });
  });

  describe('AnsiOutput rendering', () => {
    it('should call onOutputEvent with AnsiOutput when showColor is true', async () => {
      const coloredShellExecutionConfig = {
        ...shellExecutionConfig,
        showColor: true,
        defaultFg: '#ffffff',
        defaultBg: '#000000',
        disableDynamicLineTrimming: true,
      };
      const mockAnsiOutput = [
        [{ text: 'hello', fg: '#ffffff', bg: '#000000' }],
      ];
      mockSerializeTerminalToObject.mockReturnValue(mockAnsiOutput);

      await simulateExecution(
        'ls --color=auto',
        (pty) => {
          pty.onData.mock.calls[0][0]('a\u001b[31mred\u001b[0mword');
          pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
        },
        coloredShellExecutionConfig,
      );

      expect(mockSerializeTerminalToObject).toHaveBeenCalled();

      expect(onOutputEventMock).toHaveBeenCalledWith(
        expect.objectContaining({
          type: 'data',
          chunk: mockAnsiOutput,
        }),
      );
    });

    it('should call onOutputEvent with AnsiOutput when showColor is false', async () => {
      mockSerializeTerminalToObject.mockReturnValue(
        createMockSerializeTerminalToObjectReturnValue('aredword'),
      );
      await simulateExecution(
        'ls --color=auto',
        (pty) => {
          pty.onData.mock.calls[0][0]('a\u001b[31mred\u001b[0mword');
          pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
        },
        {
          ...shellExecutionConfig,
          showColor: false,
          disableDynamicLineTrimming: true,
        },
      );

      const expected = createExpectedAnsiOutput('aredword');

      expect(onOutputEventMock).toHaveBeenCalledWith(
        expect.objectContaining({
          type: 'data',
          chunk: expected,
        }),
      );
    });

    it('should handle multi-line output correctly when showColor is false', async () => {
      mockSerializeTerminalToObject.mockReturnValue(
        createMockSerializeTerminalToObjectReturnValue([
          'line 1',
          'line 2',
          'line 3',
        ]),
      );
      await simulateExecution(
        'ls --color=auto',
        (pty) => {
          pty.onData.mock.calls[0][0](
            'line 1\n\u001b[32mline 2\u001b[0m\nline 3',
          );
          pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
        },
        {
          ...shellExecutionConfig,
          showColor: false,
          disableDynamicLineTrimming: true,
        },
      );

      const expected = createExpectedAnsiOutput(['line 1', 'line 2', 'line 3']);

      expect(onOutputEventMock).toHaveBeenCalledWith(
        expect.objectContaining({
          type: 'data',
          chunk: expected,
        }),
      );
    });
  });

  describe('Resource Management', () => {
    it('should destroy the PTY process and clear activePtys on exit', async () => {
      await simulateExecution('ls -l', (pty) => {
        pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
      });

      expect(mockPtyProcess.destroy).toHaveBeenCalled();
      expect(ShellExecutionService['activePtys'].size).toBe(0);
    });

    it('should destroy the PTY process even if destroy throws', async () => {
      mockPtyProcess.destroy.mockImplementation(() => {
        throw new Error('Destroy failed');
      });

      await expect(
        simulateExecution('ls -l', (pty) => {
          pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
        }),
      ).resolves.not.toThrow();

      expect(ShellExecutionService['activePtys'].size).toBe(0);
    });

    it('should destroy the PTY when kill() is called', async () => {
      // Execute a command to populate activePtys
      const abortController = new AbortController();
      await ShellExecutionService.execute(
        'long-running',
        '/test/dir',
        onOutputEventMock,
        abortController.signal,
        true,
        shellExecutionConfig,
      );
      await new Promise((resolve) => process.nextTick(resolve));

      const pid = mockPtyProcess.pid;
      const activePty = ShellExecutionService['activePtys'].get(pid);
      expect(activePty).toBeTruthy();

      // Spy on the actual stored object's destroy
      const storedDestroySpy = vi.spyOn(
        activePty!.ptyProcess as never as { destroy: () => void },
        'destroy',
      );

      await ShellExecutionService.kill(pid);

      expect(storedDestroySpy).toHaveBeenCalled();
      expect(ShellExecutionService['activePtys'].has(pid)).toBe(false);
    });

    it('should destroy the PTY when an exception occurs after spawn in executeWithPty', async () => {
      // Simulate: spawn succeeds, but accessing ptyProcess.pid throws.
      // spawnedPty is set before the pid access, so the catch block should
      // call spawnedPty.destroy() to release the fd.
      const destroySpy = vi.fn();
      const faultyPty = {
        onData: vi.fn(),
        onExit: vi.fn(),
        write: vi.fn(),
        kill: vi.fn(),
        resize: vi.fn(),
        destroy: destroySpy,
        get pid(): number {
          throw new Error('Simulated post-spawn failure on pid access');
        },
      };
      mockPtySpawn.mockReturnValueOnce(faultyPty);

      const handle = await ShellExecutionService.execute(
        'will-fail-after-spawn',
        '/test/dir',
        onOutputEventMock,
        new AbortController().signal,
        true,
        shellExecutionConfig,
      );

      const result = await handle.result;
      expect(result.exitCode).toBe(1);
      expect(result.error).toBeTruthy();
      // The catch block must call destroy() on spawnedPty to prevent fd leak
      expect(destroySpy).toHaveBeenCalled();
    });
  });
});

describe('ShellExecutionService child_process fallback', () => {
  let mockChildProcess: EventEmitter & Partial<ChildProcess>;
  let onOutputEventMock: Mock<(event: ShellOutputEvent) => void>;

  beforeEach(() => {
    vi.clearAllMocks();

    mockIsBinary.mockReturnValue(false);
    mockPlatform.mockReturnValue('linux');
    mockGetPty.mockResolvedValue(null);

    onOutputEventMock = vi.fn();

    mockChildProcess = new EventEmitter() as EventEmitter &
      Partial<ChildProcess>;
    mockChildProcess.stdout = new EventEmitter() as Readable;
    mockChildProcess.stderr = new EventEmitter() as Readable;
    mockChildProcess.kill = vi.fn();

    Object.defineProperty(mockChildProcess, 'pid', {
      value: 12345,
      configurable: true,
    });

    mockCpSpawn.mockReturnValue(mockChildProcess);
  });

  // Helper function to run a standard execution simulation
  const simulateExecution = async (
    command: string,
    simulation: (
      cp: typeof mockChildProcess,
      ac: AbortController,
    ) => void | Promise<void>,
  ) => {
    const abortController = new AbortController();
    const handle = await ShellExecutionService.execute(
      command,
      '/test/dir',
      onOutputEventMock,
      abortController.signal,
      true,
      shellExecutionConfig,
    );

    await new Promise((resolve) => process.nextTick(resolve));
    await simulation(mockChildProcess, abortController);
    const result = await handle.result;
    return { result, handle, abortController };
  };

  describe('Successful Execution', () => {
    it('should execute a command and capture stdout and stderr', async () => {
      const { result, handle } = await simulateExecution('ls -l', (cp) => {
        cp.stdout?.emit('data', Buffer.from('file1.txt\n'));
        cp.stderr?.emit('data', Buffer.from('a warning'));
        cp.emit('exit', 0, null);
        cp.emit('close', 0, null);
      });

      expect(mockCpSpawn).toHaveBeenCalledWith(
        'bash',
        [
          '-c',
          'shopt -u promptvars nullglob extglob nocaseglob dotglob; ls -l',
        ],
        expect.objectContaining({ shell: false, detached: true }),
      );
      expect(result.exitCode).toBe(0);
      expect(result.signal).toBeNull();
      expect(result.error).toBeNull();
      expect(result.aborted).toBe(false);
      expect(result.output).toBe('file1.txt\na warning');
      expect(handle.pid).toBe(12345);

      expect(onOutputEventMock).toHaveBeenCalledWith({
        type: 'data',
        chunk: 'file1.txt\n',
      });
      expect(onOutputEventMock).toHaveBeenCalledWith({
        type: 'data',
        chunk: 'a warning',
      });
      expect(onOutputEventMock).toHaveBeenCalledWith({
        type: 'exit',
        exitCode: 0,
        signal: null,
      });
    });

    it('should strip ANSI color codes from output', async () => {
      const { result } = await simulateExecution('ls --color=auto', (cp) => {
        cp.stdout?.emit('data', Buffer.from('a\u001b[31mred\u001b[0mword'));
        cp.emit('exit', 0, null);
        cp.emit('close', 0, null);
      });

      expect(result.output.trim()).toBe('aredword');
      expect(onOutputEventMock).toHaveBeenCalledWith({
        type: 'data',
        chunk: 'a\u001b[31mred\u001b[0mword',
      });
      expect(onOutputEventMock).toHaveBeenCalledWith({
        type: 'exit',
        exitCode: 0,
        signal: null,
      });
    });

    it('should correctly decode multi-byte characters split across chunks', async () => {
      const { result } = await simulateExecution('echo "你好"', (cp) => {
        const multiByteChar = Buffer.from('你好', 'utf-8');
        cp.stdout?.emit('data', multiByteChar.slice(0, 2));
        cp.stdout?.emit('data', multiByteChar.slice(2));
        cp.emit('exit', 0, null);
        cp.emit('close', 0, null);
      });
      expect(result.output.trim()).toBe('你好');
    });

    it('should handle commands with no output', async () => {
      const { result } = await simulateExecution('touch file', (cp) => {
        cp.emit('exit', 0, null);
        cp.emit('close', 0, null);
      });

      expect(result.output.trim()).toBe('');
      expect(onOutputEventMock).toHaveBeenCalledWith({
        type: 'exit',
        exitCode: 0,
        signal: null,
      });
    });

    it('should truncate stdout using a sliding window and show a warning', async () => {
      const MAX_SIZE = 16 * 1024 * 1024;
      const chunk1 = 'a'.repeat(MAX_SIZE / 2 - 5);
      const chunk2 = 'b'.repeat(MAX_SIZE / 2 - 5);
      const chunk3 = 'c'.repeat(20);

      const { result } = await simulateExecution('large-output', (cp) => {
        cp.stdout?.emit('data', Buffer.from(chunk1));
        cp.stdout?.emit('data', Buffer.from(chunk2));
        cp.stdout?.emit('data', Buffer.from(chunk3));
        cp.emit('exit', 0, null);
      });

      const truncationMessage =
        '[GEMINI_CLI_WARNING: Output truncated. The buffer is limited to 16MB.]';
      expect(result.output).toContain(truncationMessage);

      const outputWithoutMessage = result.output
        .substring(0, result.output.indexOf(truncationMessage))
        .trimEnd();

      expect(outputWithoutMessage.length).toBe(MAX_SIZE);

      const expectedStart = (chunk1 + chunk2 + chunk3).slice(-MAX_SIZE);
      expect(
        outputWithoutMessage.startsWith(expectedStart.substring(0, 10)),
      ).toBe(true);
      expect(outputWithoutMessage.endsWith('c'.repeat(20))).toBe(true);
    }, 120000);
  });

  describe('Failed Execution', () => {
    it('should capture a non-zero exit code and format output correctly', async () => {
      const { result } = await simulateExecution('a-bad-command', (cp) => {
        cp.stderr?.emit('data', Buffer.from('command not found'));
        cp.emit('exit', 127, null);
        cp.emit('close', 127, null);
      });

      expect(result.exitCode).toBe(127);
      expect(result.output.trim()).toBe('command not found');
      expect(result.error).toBeNull();
    });

    it('should capture a termination signal', async () => {
      const { result } = await simulateExecution('long-process', (cp) => {
        cp.emit('exit', null, 'SIGTERM');
        cp.emit('close', null, 'SIGTERM');
      });

      expect(result.exitCode).toBeNull();
      expect(result.signal).toBe(15);
    });

    it('should handle a spawn error', async () => {
      const spawnError = new Error('spawn EACCES');
      const { result } = await simulateExecution('protected-cmd', (cp) => {
        cp.emit('error', spawnError);
        cp.emit('exit', 1, null);
        cp.emit('close', 1, null);
      });

      expect(result.error).toBe(spawnError);
      expect(result.exitCode).toBe(1);
    });

    it('handles errors that do not fire the exit event', async () => {
      const error = new Error('spawn abc ENOENT');
      const { result } = await simulateExecution('touch cat.jpg', (cp) => {
        cp.emit('error', error); // No exit event is fired.
        cp.emit('close', 1, null);
      });

      expect(result.error).toBe(error);
      expect(result.exitCode).toBe(1);
    });
  });

  describe('Aborting Commands', () => {
    describe.each([
      {
        platform: 'linux',
        expectedSignal: 'SIGTERM',
        expectedExit: { signal: 'SIGKILL' as const },
      },
      {
        platform: 'win32',
        expectedCommand: 'taskkill',
        expectedExit: { code: 1 },
      },
    ])(
      'on $platform',
      ({ platform, expectedSignal, expectedCommand, expectedExit }) => {
        it('should abort a running process and set the aborted flag', async () => {
          mockPlatform.mockReturnValue(platform);

          const { result } = await simulateExecution(
            'sleep 10',
            (cp, abortController) => {
              abortController.abort();
              if (expectedExit.signal) {
                cp.emit('exit', null, expectedExit.signal);
                cp.emit('close', null, expectedExit.signal);
              }
              if (typeof expectedExit.code === 'number') {
                cp.emit('exit', expectedExit.code, null);
                cp.emit('close', expectedExit.code, null);
              }
            },
          );

          expect(result.aborted).toBe(true);

          if (platform === 'linux') {
            expect(mockProcessKill).toHaveBeenCalledWith(
              -mockChildProcess.pid!,
              expectedSignal,
            );
          } else {
            expect(mockCpSpawn).toHaveBeenCalledWith(
              expectedCommand,
              ['/pid', String(mockChildProcess.pid), '/f', '/t'],
              expect.anything(),
            );
          }
        });
      },
    );

    it('should gracefully attempt SIGKILL on linux if SIGTERM fails', async () => {
      mockPlatform.mockReturnValue('linux');
      vi.useFakeTimers();

      // Don't await the result inside the simulation block for this specific test.
      // We need to control the timeline manually.
      const abortController = new AbortController();
      const handle = await ShellExecutionService.execute(
        'unresponsive_process',
        '/test/dir',
        onOutputEventMock,
        abortController.signal,
        true,
        {
          ...shellExecutionConfig,
          sanitizationConfig: {
            enableEnvironmentVariableRedaction: true,
            allowedEnvironmentVariables: [],
            blockedEnvironmentVariables: [],
          },
        },
      );

      abortController.abort();

      // Check the first kill signal
      expect(mockProcessKill).toHaveBeenCalledWith(
        -mockChildProcess.pid!,
        'SIGTERM',
      );

      // Now, advance time past the timeout
      await vi.advanceTimersByTimeAsync(250);

      // Check the second kill signal
      expect(mockProcessKill).toHaveBeenCalledWith(
        -mockChildProcess.pid!,
        'SIGKILL',
      );

      // Finally, simulate the process exiting and await the result
      mockChildProcess.emit('exit', null, 'SIGKILL');
      mockChildProcess.emit('close', null, 'SIGKILL');
      const result = await handle.result;

      vi.useRealTimers();

      expect(result.aborted).toBe(true);
      expect(result.signal).toBe(9);
    });
  });

  describe('Binary Output', () => {
    it('should detect binary output and switch to progress events', async () => {
      mockIsBinary.mockReturnValueOnce(true);
      const binaryChunk1 = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
      const binaryChunk2 = Buffer.from([0x0d, 0x0a, 0x1a, 0x0a]);

      await simulateExecution('cat image.png', (cp) => {
        cp.stdout?.emit('data', binaryChunk1);
        cp.stdout?.emit('data', binaryChunk2);
        cp.emit('exit', 0, null);
      });

      expect(onOutputEventMock).toHaveBeenCalledTimes(4);
      expect(onOutputEventMock.mock.calls[0][0]).toEqual({
        type: 'binary_detected',
      });
      expect(onOutputEventMock.mock.calls[1][0]).toEqual({
        type: 'binary_progress',
        bytesReceived: 4,
      });
      expect(onOutputEventMock.mock.calls[2][0]).toEqual({
        type: 'binary_progress',
        bytesReceived: 8,
      });
      expect(onOutputEventMock.mock.calls[3][0]).toEqual({
        type: 'exit',
        exitCode: 0,
        signal: null,
      });
    });

    it('should not emit data events after binary is detected', async () => {
      mockIsBinary.mockImplementation((buffer) => buffer.includes(0x00));

      await simulateExecution('cat mixed_file', (cp) => {
        cp.stdout?.emit('data', Buffer.from([0x00, 0x01, 0x02]));
        cp.stdout?.emit('data', Buffer.from('more text'));
        cp.emit('exit', 0, null);
        cp.emit('close', 0, null);
      });

      const eventTypes = onOutputEventMock.mock.calls.map(
        (call: [ShellOutputEvent]) => call[0].type,
      );
      expect(eventTypes).toEqual([
        'binary_detected',
        'binary_progress',
        'binary_progress',
        'exit',
      ]);
    });
  });

  describe('Platform-Specific Behavior', () => {
    it('should use powershell.exe on Windows', async () => {
      mockPlatform.mockReturnValue('win32');
      await simulateExecution('dir "foo bar"', (cp) => {
        cp.emit('exit', 0, null);
      });

      expect(mockCpSpawn).toHaveBeenCalledWith(
        'powershell.exe',
        ['-NoProfile', '-Command', 'dir "foo bar"'],
        expect.objectContaining({
          shell: false,
          detached: false,
          windowsVerbatimArguments: false,
        }),
      );
    });

    it('should use bash and detached process group on Linux', async () => {
      mockPlatform.mockReturnValue('linux');
      await simulateExecution('ls "foo bar"', (cp) => {
        cp.emit('exit', 0, null);
      });

      expect(mockCpSpawn).toHaveBeenCalledWith(
        'bash',
        [
          '-c',
          'shopt -u promptvars nullglob extglob nocaseglob dotglob; ls "foo bar"',
        ],
        expect.objectContaining({
          shell: false,
          detached: true,
        }),
      );
    });
  });
});

describe('ShellExecutionService execution method selection', () => {
  let onOutputEventMock: Mock<(event: ShellOutputEvent) => void>;
  let mockPtyProcess: EventEmitter & {
    pid: number;
    kill: Mock;
    onData: Mock;
    onExit: Mock;
    write: Mock;
    resize: Mock;
  };
  let mockChildProcess: EventEmitter & Partial<ChildProcess>;

  beforeEach(() => {
    vi.clearAllMocks();
    onOutputEventMock = vi.fn();

    // Mock for pty
    mockPtyProcess = new EventEmitter() as EventEmitter & {
      pid: number;
      kill: Mock;
      onData: Mock;
      onExit: Mock;
      write: Mock;
      resize: Mock;
    };
    mockPtyProcess.pid = 12345;
    mockPtyProcess.kill = vi.fn();
    mockPtyProcess.onData = vi.fn();
    mockPtyProcess.onExit = vi.fn();
    mockPtyProcess.write = vi.fn();
    mockPtyProcess.resize = vi.fn();

    mockPtySpawn.mockReturnValue(mockPtyProcess);
    mockGetPty.mockResolvedValue({
      module: { spawn: mockPtySpawn },
      name: 'mock-pty',
    });

    // Mock for child_process
    mockChildProcess = new EventEmitter() as EventEmitter &
      Partial<ChildProcess>;
    mockChildProcess.stdout = new EventEmitter() as Readable;
    mockChildProcess.stderr = new EventEmitter() as Readable;
    mockChildProcess.kill = vi.fn();
    Object.defineProperty(mockChildProcess, 'pid', {
      value: 54321,
      configurable: true,
    });
    mockCpSpawn.mockReturnValue(mockChildProcess);
  });

  it('should use node-pty when shouldUseNodePty is true and pty is available', async () => {
    mockSerializeTerminalToObject.mockReturnValue([]);
    const abortController = new AbortController();
    const handle = await ShellExecutionService.execute(
      'test command',
      '/test/dir',
      onOutputEventMock,
      abortController.signal,
      true, // shouldUseNodePty
      shellExecutionConfig,
    );

    // Simulate exit to allow promise to resolve
    mockPtyProcess.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
    const result = await handle.result;

    expect(mockGetPty).toHaveBeenCalled();
    expect(mockPtySpawn).toHaveBeenCalled();
    expect(mockCpSpawn).not.toHaveBeenCalled();
    expect(result.executionMethod).toBe('mock-pty');
  });

  it('should use child_process when shouldUseNodePty is false', async () => {
    const abortController = new AbortController();
    const handle = await ShellExecutionService.execute(
      'test command',
      '/test/dir',
      onOutputEventMock,
      abortController.signal,
      false, // shouldUseNodePty
      {
        ...shellExecutionConfig,
        sanitizationConfig: {
          enableEnvironmentVariableRedaction: true,
          allowedEnvironmentVariables: [],
          blockedEnvironmentVariables: [],
        },
      },
    );

    // Simulate exit to allow promise to resolve
    mockChildProcess.emit('exit', 0, null);
    const result = await handle.result;

    expect(mockGetPty).not.toHaveBeenCalled();
    expect(mockPtySpawn).not.toHaveBeenCalled();
    expect(mockCpSpawn).toHaveBeenCalled();
    expect(result.executionMethod).toBe('child_process');
  });

  it('should fall back to child_process if pty is not available even if shouldUseNodePty is true', async () => {
    mockGetPty.mockResolvedValue(null);

    const abortController = new AbortController();
    const handle = await ShellExecutionService.execute(
      'test command',
      '/test/dir',
      onOutputEventMock,
      abortController.signal,
      true, // shouldUseNodePty
      shellExecutionConfig,
    );

    // Simulate exit to allow promise to resolve
    mockChildProcess.emit('exit', 0, null);
    const result = await handle.result;

    expect(mockGetPty).toHaveBeenCalled();
    expect(mockPtySpawn).not.toHaveBeenCalled();
    expect(mockCpSpawn).toHaveBeenCalled();
    expect(result.executionMethod).toBe('child_process');
  });
});

describe('ShellExecutionService environment variables', () => {
  let mockPtyProcess: EventEmitter & {
    pid: number;
    kill: Mock;
    onData: Mock;
    onExit: Mock;
    write: Mock;
    resize: Mock;
  };
  let mockChildProcess: EventEmitter & Partial<ChildProcess>;

  beforeEach(() => {
    vi.clearAllMocks();
    vi.resetModules(); // Reset modules to ensure process.env changes are fresh

    // Mock for pty
    mockPtyProcess = new EventEmitter() as EventEmitter & {
      pid: number;
      kill: Mock;
      onData: Mock;
      onExit: Mock;
      write: Mock;
      resize: Mock;
    };
    mockPtyProcess.pid = 12345;
    mockPtyProcess.kill = vi.fn();
    mockPtyProcess.onData = vi.fn();
    mockPtyProcess.onExit = vi.fn();
    mockPtyProcess.write = vi.fn();
    mockPtyProcess.resize = vi.fn();

    mockPtySpawn.mockReturnValue(mockPtyProcess);
    mockGetPty.mockResolvedValue({
      module: { spawn: mockPtySpawn },
      name: 'mock-pty',
    });

    // Mock for child_process
    mockChildProcess = new EventEmitter() as EventEmitter &
      Partial<ChildProcess>;
    mockChildProcess.stdout = new EventEmitter() as Readable;
    mockChildProcess.stderr = new EventEmitter() as Readable;
    mockChildProcess.kill = vi.fn();
    Object.defineProperty(mockChildProcess, 'pid', {
      value: 54321,
      configurable: true,
    });
    mockCpSpawn.mockReturnValue(mockChildProcess);

    // Default exit behavior for mocks
    mockPtyProcess.onExit.mockImplementationOnce(({ exitCode, signal }) => {
      // Small delay to allow async ops to complete
      setTimeout(() => mockPtyProcess.emit('exit', { exitCode, signal }), 0);
    });
    mockChildProcess.on('exit', (code, signal) => {
      // Small delay to allow async ops to complete
      setTimeout(() => mockChildProcess.emit('close', code, signal), 0);
    });
  });

  afterEach(() => {
    // Clean up process.env after each test
    vi.unstubAllEnvs();
  });

  it('should use a sanitized environment when in a GitHub run', async () => {
    // Mock the environment to simulate a GitHub Actions run
    vi.stubEnv('GITHUB_SHA', 'test-sha');
    vi.stubEnv('MY_SENSITIVE_VAR', 'secret-value'); // This should be stripped out
    vi.stubEnv('PATH', '/test/path'); // An essential var that should be kept
    vi.stubEnv('GEMINI_CLI_TEST_VAR', 'test-value'); // A test var that should be kept

    vi.resetModules();
    const { ShellExecutionService } = await import(
      './shellExecutionService.js'
    );

    // Test pty path
    await ShellExecutionService.execute(
      'test-pty-command',
      '/',
      vi.fn(),
      new AbortController().signal,
      true,
      shellExecutionConfig,
    );

    const ptyEnv = mockPtySpawn.mock.calls[0][2].env;
    expect(ptyEnv).not.toHaveProperty('MY_SENSITIVE_VAR');
    expect(ptyEnv).toHaveProperty('PATH', '/test/path');
    expect(ptyEnv).toHaveProperty('GEMINI_CLI_TEST_VAR', 'test-value');

    // Ensure pty process exits for next test
    mockPtyProcess.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
    await new Promise(process.nextTick);

    // Test child_process path
    mockGetPty.mockResolvedValue(null); // Force fallback
    await ShellExecutionService.execute(
      'test-cp-command',
      '/',
      vi.fn(),
      new AbortController().signal,
      true,
      {
        ...shellExecutionConfig,
        sanitizationConfig: {
          enableEnvironmentVariableRedaction: false,
          allowedEnvironmentVariables: [],
          blockedEnvironmentVariables: [],
        },
      },
    );

    const cpEnv = mockCpSpawn.mock.calls[0][2].env;
    expect(cpEnv).not.toHaveProperty('MY_SENSITIVE_VAR');
    expect(cpEnv).toHaveProperty('PATH', '/test/path');
    expect(cpEnv).toHaveProperty('GEMINI_CLI_TEST_VAR', 'test-value');

    // Ensure child_process exits
    mockChildProcess.emit('exit', 0, null);
    mockChildProcess.emit('close', 0, null);
    await new Promise(process.nextTick);
  });

  it('should use a sanitized environment when in a GitHub run (SURFACE=Github)', async () => {
    // Mock the environment to simulate a GitHub Actions run via SURFACE variable
    vi.stubEnv('SURFACE', 'Github');
    vi.stubEnv('MY_SENSITIVE_VAR', 'secret-value'); // This should be stripped out
    vi.stubEnv('PATH', '/test/path'); // An essential var that should be kept
    vi.stubEnv('GEMINI_CLI_TEST_VAR', 'test-value'); // A test var that should be kept

    vi.resetModules();
    const { ShellExecutionService } = await import(
      './shellExecutionService.js'
    );

    // Test pty path
    await ShellExecutionService.execute(
      'test-pty-command-surface',
      '/',
      vi.fn(),
      new AbortController().signal,
      true,
      shellExecutionConfig,
    );

    const ptyEnv = mockPtySpawn.mock.calls[0][2].env;
    expect(ptyEnv).not.toHaveProperty('MY_SENSITIVE_VAR');
    expect(ptyEnv).toHaveProperty('PATH', '/test/path');
    expect(ptyEnv).toHaveProperty('GEMINI_CLI_TEST_VAR', 'test-value');

    // Ensure pty process exits for next test
    mockPtyProcess.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
    await new Promise(process.nextTick);

    // Test child_process path
    mockGetPty.mockResolvedValue(null); // Force fallback
    await ShellExecutionService.execute(
      'test-cp-command-surface',
      '/',
      vi.fn(),
      new AbortController().signal,
      true,
      {
        ...shellExecutionConfig,
        sanitizationConfig: {
          enableEnvironmentVariableRedaction: false,
          allowedEnvironmentVariables: [],
          blockedEnvironmentVariables: [],
        },
      },
    );

    const cpEnv = mockCpSpawn.mock.calls[0][2].env;
    expect(cpEnv).not.toHaveProperty('MY_SENSITIVE_VAR');
    expect(cpEnv).toHaveProperty('PATH', '/test/path');
    expect(cpEnv).toHaveProperty('GEMINI_CLI_TEST_VAR', 'test-value');

    // Ensure child_process exits
    mockChildProcess.emit('exit', 0, null);
    mockChildProcess.emit('close', 0, null);
    await new Promise(process.nextTick);
  });

  it('should include the full process.env when not in a GitHub run', async () => {
    vi.stubEnv('MY_TEST_VAR', 'test-value');
    vi.stubEnv('GITHUB_SHA', '');
    vi.stubEnv('SURFACE', '');
    vi.resetModules();
    const { ShellExecutionService } = await import(
      './shellExecutionService.js'
    );

    // Test pty path
    await ShellExecutionService.execute(
      'test-pty-command-no-github',
      '/',
      vi.fn(),
      new AbortController().signal,
      true,
      shellExecutionConfig,
    );
    expect(mockPtySpawn).toHaveBeenCalled();
    const ptyEnv = mockPtySpawn.mock.calls[0][2].env;
    expect(ptyEnv).toHaveProperty('MY_TEST_VAR', 'test-value');
    expect(ptyEnv).toHaveProperty('GEMINI_CLI', '1');

    // Ensure pty process exits
    mockPtyProcess.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
    await new Promise(process.nextTick);

    // Test child_process path (forcing fallback by making pty unavailable)
    mockGetPty.mockResolvedValue(null);
    await ShellExecutionService.execute(
      'test-cp-command-no-github',
      '/',
      vi.fn(),
      new AbortController().signal,
      true, // Still tries pty, but it will fall back
      shellExecutionConfig,
    );
    expect(mockCpSpawn).toHaveBeenCalled();
    const cpEnv = mockCpSpawn.mock.calls[0][2].env;
    expect(cpEnv).toHaveProperty('MY_TEST_VAR', 'test-value');
    expect(cpEnv).toHaveProperty('GEMINI_CLI', '1');

    // Ensure child_process exits
    mockChildProcess.emit('exit', 0, null);
    mockChildProcess.emit('close', 0, null);
    await new Promise(process.nextTick);
  });

  it('should call prepareCommand on sandboxManager when provided', async () => {
    const mockSandboxManager = {
      prepareCommand: vi.fn().mockResolvedValue({
        program: 'sandboxed-bash',
        args: ['-c', 'ls'],
        env: { SANDBOXED: 'true' },
      }),
      isKnownSafeCommand: vi.fn().mockReturnValue(false),
      isDangerousCommand: vi.fn().mockReturnValue(false),
      parseDenials: vi.fn().mockReturnValue(undefined),
    };

    const configWithSandbox: ShellExecutionConfig = {
      ...shellExecutionConfig,
      sandboxManager: mockSandboxManager,
    };

    mockResolveExecutable.mockResolvedValue('/bin/bash/resolved');
    const mockChild = new EventEmitter() as unknown as ChildProcess;
    mockChild.stdout = new EventEmitter() as unknown as Readable;
    mockChild.stderr = new EventEmitter() as unknown as Readable;
    Object.assign(mockChild, { pid: 123 });
    mockCpSpawn.mockReturnValue(mockChild);

    const handle = await ShellExecutionService.execute(
      'ls',
      '/test/cwd',
      () => {},
      new AbortController().signal,
      false, // child_process path
      configWithSandbox,
    );

    expect(mockResolveExecutable).toHaveBeenCalledWith(expect.any(String));
    expect(mockSandboxManager.prepareCommand).toHaveBeenCalledWith(
      expect.objectContaining({
        command: '/bin/bash/resolved',
        args: expect.arrayContaining([expect.stringContaining('ls')]),
        cwd: '/test/cwd',
      }),
    );
    expect(mockCpSpawn).toHaveBeenCalledWith(
      'sandboxed-bash',
      ['-c', 'ls'],
      expect.objectContaining({
        env: expect.objectContaining({ SANDBOXED: 'true' }),
      }),
    );

    // Clean up
    mockChild.emit('exit', 0, null);
    mockChild.emit('close', 0, null);
    await handle.result;
  });

  it('should include headless git and gh environment variables in non-interactive mode and append git config safely', async () => {
    vi.resetModules();
    vi.stubEnv('GIT_CONFIG_COUNT', '2');
    vi.stubEnv('GIT_CONFIG_KEY_0', 'core.editor');
    vi.stubEnv('GIT_CONFIG_VALUE_0', 'vim');
    vi.stubEnv('GIT_CONFIG_KEY_1', 'pull.rebase');
    vi.stubEnv('GIT_CONFIG_VALUE_1', 'true');

    const { ShellExecutionService } = await import(
      './shellExecutionService.js'
    );

    mockGetPty.mockResolvedValue(null); // Force child_process fallback
    await ShellExecutionService.execute(
      'test-cp-headless-git',
      '/',
      vi.fn(),
      new AbortController().signal,
      false, // non-interactive
      shellExecutionConfig,
    );

    expect(mockCpSpawn).toHaveBeenCalled();
    const cpEnv = mockCpSpawn.mock.calls[0][2].env;
    expect(cpEnv).toHaveProperty('GIT_TERMINAL_PROMPT', '0');
    expect(cpEnv).toHaveProperty('GIT_ASKPASS', '');
    expect(cpEnv).toHaveProperty('SSH_ASKPASS', '');
    expect(cpEnv).toHaveProperty('GH_PROMPT_DISABLED', '1');
    expect(cpEnv).toHaveProperty('GCM_INTERACTIVE', 'never');
    expect(cpEnv).toHaveProperty('DISPLAY', '');
    expect(cpEnv).toHaveProperty('DBUS_SESSION_BUS_ADDRESS', '');

    // Existing values should be preserved
    expect(cpEnv).toHaveProperty('GIT_CONFIG_KEY_0', 'core.editor');
    expect(cpEnv).toHaveProperty('GIT_CONFIG_VALUE_0', 'vim');
    expect(cpEnv).toHaveProperty('GIT_CONFIG_KEY_1', 'pull.rebase');
    expect(cpEnv).toHaveProperty('GIT_CONFIG_VALUE_1', 'true');

    // The new credential.helper override should be appended at index 2
    expect(cpEnv).toHaveProperty('GIT_CONFIG_COUNT', '3');
    expect(cpEnv).toHaveProperty('GIT_CONFIG_KEY_2', 'credential.helper');
    expect(cpEnv).toHaveProperty('GIT_CONFIG_VALUE_2', '');

    // Ensure child_process exits
    mockChildProcess.emit('exit', 0, null);
    mockChildProcess.emit('close', 0, null);
    await new Promise(process.nextTick);

    vi.unstubAllEnvs();
  });

  it('should NOT include headless git and gh environment variables in interactive fallback mode', async () => {
    vi.resetModules();
    vi.stubEnv('GIT_TERMINAL_PROMPT', undefined);
    vi.stubEnv('GIT_ASKPASS', undefined);
    vi.stubEnv('SSH_ASKPASS', undefined);
    vi.stubEnv('GH_PROMPT_DISABLED', undefined);
    vi.stubEnv('GCM_INTERACTIVE', undefined);
    vi.stubEnv('GIT_CONFIG_COUNT', undefined);

    const { ShellExecutionService } = await import(
      './shellExecutionService.js'
    );

    mockGetPty.mockResolvedValue(null); // Force child_process fallback
    await ShellExecutionService.execute(
      'test-cp-interactive-fallback',
      '/',
      vi.fn(),
      new AbortController().signal,
      true, // isInteractive (shouldUseNodePty)
      shellExecutionConfig,
    );

    expect(mockCpSpawn).toHaveBeenCalled();
    const cpEnv = mockCpSpawn.mock.calls[0][2].env;
    expect(cpEnv).not.toHaveProperty('GIT_TERMINAL_PROMPT');
    expect(cpEnv).not.toHaveProperty('GIT_ASKPASS');
    expect(cpEnv).not.toHaveProperty('SSH_ASKPASS');
    expect(cpEnv).not.toHaveProperty('GH_PROMPT_DISABLED');
    expect(cpEnv).not.toHaveProperty('GCM_INTERACTIVE');
    expect(cpEnv).not.toHaveProperty('GIT_CONFIG_COUNT');

    // Ensure child_process exits
    mockChildProcess.emit('exit', 0, null);
    mockChildProcess.emit('close', 0, null);
    await new Promise(process.nextTick);

    vi.unstubAllEnvs();
  });
});
