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

import { act } from 'react';
import { render } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import {
  vi,
  describe,
  it,
  expect,
  beforeEach,
  afterEach,
  type Mock,
} from 'vitest';
import { NoopSandboxManager, escapeShellArg } from '@google/gemini-cli-core';

const mockIsBinary = vi.hoisted(() => vi.fn());
const mockShellExecutionService = vi.hoisted(() => vi.fn());
const mockShellKill = vi.hoisted(() => vi.fn());
const mockShellBackground = vi.hoisted(() => vi.fn());
const mockShellSubscribe = vi.hoisted(() =>
  vi.fn<
    (pid: number, listener: (event: ShellOutputEvent) => void) => () => void
  >(() => vi.fn()),
); // Returns unsubscribe
const mockShellOnExit = vi.hoisted(() =>
  vi.fn<
    (
      pid: number,
      callback: (exitCode: number, signal?: number) => void,
    ) => () => void
  >(() => vi.fn()),
);
const mockLifecycleSubscribe = vi.hoisted(() =>
  vi.fn<
    (pid: number, listener: (event: ShellOutputEvent) => void) => () => void
  >(() => vi.fn()),
);
const mockLifecycleOnExit = vi.hoisted(() =>
  vi.fn<
    (
      pid: number,
      callback: (exitCode: number, signal?: number) => void,
    ) => () => void
  >(() => vi.fn()),
);
const mockLifecycleKill = vi.hoisted(() => vi.fn());
const mockLifecycleBackground = vi.hoisted(() => vi.fn());
const mockLifecycleOnBackground = vi.hoisted(() => vi.fn());
const mockLifecycleOffBackground = vi.hoisted(() => vi.fn());

vi.mock('@google/gemini-cli-core', async (importOriginal) => {
  const actual =
    await importOriginal<typeof import('@google/gemini-cli-core')>();
  return {
    ...actual,
    ShellExecutionService: {
      execute: mockShellExecutionService,
      kill: mockShellKill,
      background: mockShellBackground,
      subscribe: mockShellSubscribe,
      onExit: mockShellOnExit,
    },
    ExecutionLifecycleService: {
      subscribe: mockLifecycleSubscribe,
      onExit: mockLifecycleOnExit,
      kill: mockLifecycleKill,
      background: mockLifecycleBackground,
      onBackground: mockLifecycleOnBackground,
      offBackground: mockLifecycleOffBackground,
    },
    isBinary: mockIsBinary,
  };
});
vi.mock('node:fs', async (importOriginal) => {
  const actual = await importOriginal<typeof import('node:fs')>();
  const mockFs = {
    ...actual,
    existsSync: vi.fn(),
    mkdtempSync: vi.fn(),
    unlinkSync: vi.fn(),
    readFileSync: vi.fn(),
    rmSync: vi.fn(),
  };
  return {
    ...mockFs,
    default: mockFs,
  };
});
vi.mock('node:os', async (importOriginal) => {
  const actual = await importOriginal<typeof import('node:os')>();
  const mocked = {
    ...actual,
    homedir: vi.fn(() => '/home/user'),
    platform: vi.fn(() => 'linux'),
    tmpdir: vi.fn(() => '/tmp'),
  };
  return {
    ...mocked,
    default: mocked,
  };
});
vi.mock('node:crypto');

import {
  useExecutionLifecycle,
  OUTPUT_UPDATE_INTERVAL_MS,
} from './useExecutionLifecycle.js';
import {
  type Config,
  type GeminiClient,
  type ShellExecutionResult,
  type ShellOutputEvent,
  type AnsiOutput,
  CoreToolCallStatus,
} from '@google/gemini-cli-core';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import * as crypto from 'node:crypto';

describe('useExecutionLifecycle', () => {
  let addItemToHistoryMock: Mock;
  let setPendingHistoryItemMock: Mock;
  let onExecMock: Mock;
  let onDebugMessageMock: Mock;
  let mockConfig: Config;
  let mockGeminiClient: GeminiClient;

  let mockShellOutputCallback: (event: ShellOutputEvent) => void;
  let resolveExecutionPromise: (result: ShellExecutionResult) => void;

  let setShellInputFocusedMock: Mock;

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

    addItemToHistoryMock = vi.fn();
    setPendingHistoryItemMock = vi.fn();
    onExecMock = vi.fn();
    onDebugMessageMock = vi.fn();
    setShellInputFocusedMock = vi.fn();
    mockConfig = {
      getTargetDir: () => '/test/dir',
      getEnableInteractiveShell: () => false,
      getSessionId: () => 'test-session-id',
      getShellExecutionConfig: () => ({
        terminalHeight: 20,
        terminalWidth: 80,
        sandboxManager: new NoopSandboxManager(),
        sanitizationConfig: {
          allowedEnvironmentVariables: [],
          blockedEnvironmentVariables: [],
          enableEnvironmentVariableRedaction: false,
        },
      }),
    } as unknown as Config;
    mockGeminiClient = { addHistory: vi.fn() } as unknown as GeminiClient;

    vi.mocked(os.platform).mockReturnValue('linux');
    vi.mocked(os.tmpdir).mockReturnValue('/tmp');
    (vi.mocked(crypto.randomBytes) as Mock).mockReturnValue(
      Buffer.from('abcdef', 'hex'),
    );
    mockIsBinary.mockReturnValue(false);
    vi.mocked(fs.existsSync).mockReturnValue(false);
    vi.mocked(fs.mkdtempSync).mockReturnValue('/tmp/gemini-shell-abcdef');

    mockShellExecutionService.mockImplementation((_cmd, _cwd, callback) => {
      mockShellOutputCallback = callback;
      return Promise.resolve({
        pid: 12345,
        result: new Promise((resolve) => {
          resolveExecutionPromise = resolve;
        }),
      });
    });
  });

  const renderProcessorHook = async () => {
    let hookResult: ReturnType<typeof useExecutionLifecycle>;
    let renderCount = 0;
    function TestComponent({
      isWaitingForConfirmation,
    }: {
      isWaitingForConfirmation?: boolean;
    }) {
      renderCount++;
      hookResult = useExecutionLifecycle(
        addItemToHistoryMock,
        setPendingHistoryItemMock,
        onExecMock,
        onDebugMessageMock,
        mockConfig,
        mockGeminiClient,
        setShellInputFocusedMock,
        undefined,
        undefined,
        undefined,
        isWaitingForConfirmation,
      );
      return null;
    }
    const { rerender } = await render(<TestComponent />);
    return {
      result: {
        get current() {
          return hookResult;
        },
      },
      getRenderCount: () => renderCount,
      rerender: (isWaitingForConfirmation?: boolean) =>
        rerender(
          <TestComponent isWaitingForConfirmation={isWaitingForConfirmation} />,
        ),
    };
  };

  const createMockServiceResult = (
    overrides: Partial<ShellExecutionResult> = {},
  ): ShellExecutionResult => ({
    rawOutput: Buffer.from(overrides.output || ''),
    output: 'Success',
    exitCode: 0,
    signal: null,
    error: null,
    aborted: false,
    pid: 12345,
    executionMethod: 'child_process',
    ...overrides,
  });

  it('should initiate command execution and set pending state', async () => {
    const { result } = await renderProcessorHook();

    await act(async () => {
      result.current.handleShellCommand('ls -l', new AbortController().signal);
    });

    expect(addItemToHistoryMock).toHaveBeenCalledWith(
      { type: 'user_shell', text: 'ls -l' },
      expect.any(Number),
    );
    expect(setPendingHistoryItemMock).toHaveBeenCalledWith({
      type: 'tool_group',
      tools: [
        expect.objectContaining({
          name: 'Shell Command',
          status: CoreToolCallStatus.Executing,
        }),
      ],
    });
    const tmpFile = path.join('/tmp/gemini-shell-abcdef', 'pwd.tmp');
    const escapedTmpFile = escapeShellArg(tmpFile, 'bash');
    const wrappedCommand = `{\nls -l\n}\n__code=$?; pwd > ${escapedTmpFile}; exit $__code`;
    expect(mockShellExecutionService).toHaveBeenCalledWith(
      wrappedCommand,
      '/test/dir',
      expect.any(Function),
      expect.any(Object),
      false,
      expect.objectContaining({
        sessionId: 'test-session-id',
      }),
    );
    expect(onExecMock).toHaveBeenCalledWith(expect.any(Promise));
  });

  it('should pass the config sessionId into shell execution config', async () => {
    const { result } = await renderProcessorHook();

    await act(async () => {
      result.current.handleShellCommand('top', new AbortController().signal);
    });

    expect(mockShellExecutionService).toHaveBeenCalledWith(
      expect.any(String),
      '/test/dir',
      expect.any(Function),
      expect.any(Object),
      false,
      expect.objectContaining({
        sessionId: 'test-session-id',
      }),
    );
  });

  it('should handle successful execution and update history correctly', async () => {
    const { result } = await renderProcessorHook();

    act(() => {
      result.current.handleShellCommand(
        'echo "ok"',
        new AbortController().signal,
      );
    });
    const execPromise = onExecMock.mock.calls[0][0];

    act(() => {
      resolveExecutionPromise(createMockServiceResult({ output: 'ok' }));
    });
    await act(async () => await execPromise);

    expect(setPendingHistoryItemMock).toHaveBeenCalledWith(null);
    expect(addItemToHistoryMock).toHaveBeenCalledTimes(2); // Initial + final
    expect(addItemToHistoryMock.mock.calls[1][0]).toEqual(
      expect.objectContaining({
        tools: [
          expect.objectContaining({
            status: CoreToolCallStatus.Success,
            resultDisplay: 'ok',
          }),
        ],
      }),
    );
    expect(mockGeminiClient.addHistory).toHaveBeenCalled();
    expect(setShellInputFocusedMock).toHaveBeenCalledWith(false);
  });

  it('should handle command failure and display error status', async () => {
    const { result } = await renderProcessorHook();

    act(() => {
      result.current.handleShellCommand(
        'bad-cmd',
        new AbortController().signal,
      );
    });
    const execPromise = onExecMock.mock.calls[0][0];

    act(() => {
      resolveExecutionPromise(
        createMockServiceResult({ exitCode: 127, output: 'not found' }),
      );
    });
    await act(async () => await execPromise);

    const finalHistoryItem = addItemToHistoryMock.mock.calls[1][0];
    expect(finalHistoryItem.tools[0].status).toBe(CoreToolCallStatus.Error);
    expect(finalHistoryItem.tools[0].resultDisplay).toContain(
      'Command exited with code 127',
    );
    expect(finalHistoryItem.tools[0].resultDisplay).toContain('not found');
    expect(setShellInputFocusedMock).toHaveBeenCalledWith(false);
  });

  describe('UI Streaming and Throttling', () => {
    beforeEach(() => {
      vi.useFakeTimers({ toFake: ['Date'] });
    });
    afterEach(() => {
      vi.useRealTimers();
    });

    it('should update UI for text streams (non-interactive)', async () => {
      const { result } = await renderProcessorHook();
      await act(async () => {
        result.current.handleShellCommand(
          'stream',
          new AbortController().signal,
        );
      });

      const tmpFile = path.join('/tmp/gemini-shell-abcdef', 'pwd.tmp');
      const escapedTmpFile = escapeShellArg(tmpFile, 'bash');
      const wrappedCommand = `{\nstream\n}\n__code=$?; pwd > ${escapedTmpFile}; exit $__code`;
      expect(mockShellExecutionService).toHaveBeenCalledWith(
        wrappedCommand,
        '/test/dir',
        expect.any(Function),
        expect.any(Object),
        false, // enableInteractiveShell
        expect.any(Object),
      );

      // Wait for the async PID update to happen.
      // Call 1: Initial, Call 2: PID update
      await waitFor(() => {
        expect(setPendingHistoryItemMock).toHaveBeenCalledTimes(2);
      });

      // Get the state after the PID update to feed into the stream updaters
      const pidUpdateFn = setPendingHistoryItemMock.mock.calls[1][0];
      const initialState = setPendingHistoryItemMock.mock.calls[0][0];
      const stateAfterPid = pidUpdateFn(initialState);

      // Simulate first output chunk
      act(() => {
        mockShellOutputCallback({
          type: 'data',
          chunk: 'hello',
        });
      });
      // A UI update should have occurred.
      expect(setPendingHistoryItemMock).toHaveBeenCalledTimes(3);

      const streamUpdateFn1 = setPendingHistoryItemMock.mock.calls[2][0];
      const stateAfterStream1 = streamUpdateFn1(stateAfterPid);
      expect(stateAfterStream1.tools[0].resultDisplay).toBe('hello');

      // Simulate second output chunk
      act(() => {
        mockShellOutputCallback({
          type: 'data',
          chunk: ' world',
        });
      });
      // Another UI update should have occurred.
      expect(setPendingHistoryItemMock).toHaveBeenCalledTimes(4);

      const streamUpdateFn2 = setPendingHistoryItemMock.mock.calls[3][0];
      const stateAfterStream2 = streamUpdateFn2(stateAfterStream1);
      expect(stateAfterStream2.tools[0].resultDisplay).toBe('hello world');
    });

    it('should show binary progress messages correctly', async () => {
      const { result } = await renderProcessorHook();
      act(() => {
        result.current.handleShellCommand(
          'cat img',
          new AbortController().signal,
        );
      });

      // Should immediately show the detection message
      act(() => {
        mockShellOutputCallback({ type: 'binary_detected' });
      });
      await act(async () => {
        await vi.advanceTimersByTimeAsync(OUTPUT_UPDATE_INTERVAL_MS + 1);
      });
      // Send another event to trigger the update
      act(() => {
        mockShellOutputCallback({ type: 'binary_progress', bytesReceived: 0 });
      });

      // The state update is functional, so we test it by executing it.
      const updaterFn1 = setPendingHistoryItemMock.mock.lastCall?.[0];
      if (!updaterFn1) {
        throw new Error('setPendingHistoryItem was not called');
      }
      const initialState = setPendingHistoryItemMock.mock.calls[0][0];
      const stateAfterBinaryDetected = updaterFn1(initialState);

      expect(stateAfterBinaryDetected).toEqual(
        expect.objectContaining({
          tools: [
            expect.objectContaining({
              resultDisplay: '[Binary output detected. Halting stream...]',
            }),
          ],
        }),
      );

      // Now test progress updates
      await act(async () => {
        await vi.advanceTimersByTimeAsync(OUTPUT_UPDATE_INTERVAL_MS + 1);
      });
      act(() => {
        mockShellOutputCallback({
          type: 'binary_progress',
          bytesReceived: 2048,
        });
      });

      const updaterFn2 = setPendingHistoryItemMock.mock.lastCall?.[0];
      if (!updaterFn2) {
        throw new Error('setPendingHistoryItem was not called');
      }
      const stateAfterProgress = updaterFn2(stateAfterBinaryDetected);
      expect(stateAfterProgress).toEqual(
        expect.objectContaining({
          tools: [
            expect.objectContaining({
              resultDisplay: '[Receiving binary output... 2.0 KB received]',
            }),
          ],
        }),
      );
    });
  });

  it('should not wrap the command on Windows', async () => {
    vi.mocked(os.platform).mockReturnValue('win32');
    const { result } = await renderProcessorHook();

    await act(async () => {
      result.current.handleShellCommand('dir', new AbortController().signal);
    });

    expect(mockShellExecutionService).toHaveBeenCalledWith(
      'dir',
      '/test/dir',
      expect.any(Function),
      expect.any(Object),
      false,
      expect.any(Object),
    );

    await act(async () => {
      resolveExecutionPromise(createMockServiceResult());
    });
    await act(async () => await onExecMock.mock.calls[0][0]);
  });

  it('should handle command abort and display cancelled status', async () => {
    const { result } = await renderProcessorHook();
    const abortController = new AbortController();

    act(() => {
      result.current.handleShellCommand('sleep 5', abortController.signal);
    });
    const execPromise = onExecMock.mock.calls[0][0];

    act(() => {
      abortController.abort();
      resolveExecutionPromise(
        createMockServiceResult({ aborted: true, output: 'Canceled' }),
      );
    });
    await act(async () => await execPromise);

    // With the new logic, cancelled commands are not added to history by this hook
    // to avoid duplication/flickering, as they are handled by useGeminiStream.
    expect(addItemToHistoryMock).toHaveBeenCalledTimes(1);
    expect(setPendingHistoryItemMock).toHaveBeenCalledWith(null);
    expect(setShellInputFocusedMock).toHaveBeenCalledWith(false);
  });

  it('should handle binary output result correctly', async () => {
    const { result } = await renderProcessorHook();
    const binaryBuffer = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
    mockIsBinary.mockReturnValue(true);

    act(() => {
      result.current.handleShellCommand(
        'cat image.png',
        new AbortController().signal,
      );
    });
    const execPromise = onExecMock.mock.calls[0][0];

    act(() => {
      resolveExecutionPromise(
        createMockServiceResult({ rawOutput: binaryBuffer }),
      );
    });
    await act(async () => await execPromise);

    const finalHistoryItem = addItemToHistoryMock.mock.calls[1][0];
    expect(finalHistoryItem.tools[0].status).toBe(CoreToolCallStatus.Success);
    expect(finalHistoryItem.tools[0].resultDisplay).toBe(
      '[Command produced binary output, which is not shown.]',
    );
  });

  it('should prepend warnings to AnsiOutput array', async () => {
    const { result } = await renderProcessorHook();

    act(() => {
      result.current.handleShellCommand('ls', new AbortController().signal);
    });
    const execPromise = onExecMock.mock.calls[0][0];

    const ansiOutput: AnsiOutput = [
      [
        {
          text: 'ansi line 1',
          fg: '',
          bg: '',
          bold: false,
          dim: false,
          italic: false,
          underline: false,
          inverse: false,
          isUninitialized: false,
        },
      ],
    ];

    act(() => {
      resolveExecutionPromise(
        createMockServiceResult({
          exitCode: 1,
          output: 'ansi line 1',
          ansiOutput,
        }),
      );
    });
    await act(async () => await execPromise);

    expect(setPendingHistoryItemMock).toHaveBeenCalledWith(null);
    expect(addItemToHistoryMock).toHaveBeenCalledTimes(2);

    const historyCall = addItemToHistoryMock.mock.calls[1][0];
    const display = historyCall.tools[0].resultDisplay;

    expect(Array.isArray(display)).toBe(true);
    expect(display.length).toBe(3); // Error line, empty line, original output
    expect(display[0][0].text).toBe('Command exited with code 1.');
    expect(display[2][0].text).toBe('ansi line 1');
  });

  it('should handle promise rejection and show an error', async () => {
    const { result } = await renderProcessorHook();
    const testError = new Error('Unexpected failure');
    mockShellExecutionService.mockImplementation(() => ({
      pid: 12345,
      result: Promise.reject(testError),
    }));

    act(() => {
      result.current.handleShellCommand(
        'a-command',
        new AbortController().signal,
      );
    });
    const execPromise = onExecMock.mock.calls[0][0];

    await act(async () => await execPromise);

    expect(setPendingHistoryItemMock).toHaveBeenCalledWith(null);
    expect(addItemToHistoryMock).toHaveBeenCalledTimes(2);
    expect(addItemToHistoryMock.mock.calls[1][0]).toEqual({
      type: 'error',
      text: 'An unexpected error occurred: Unexpected failure',
    });
    expect(setShellInputFocusedMock).toHaveBeenCalledWith(false);
  });

  it('should handle synchronous errors during execution and clean up resources', async () => {
    const testError = new Error('Synchronous spawn error');
    mockShellExecutionService.mockImplementation(() => {
      throw testError;
    });
    // Mock that the temp file was created before the error was thrown
    vi.mocked(fs.existsSync).mockReturnValue(true);

    const { result } = await renderProcessorHook();

    act(() => {
      result.current.handleShellCommand(
        'a-command',
        new AbortController().signal,
      );
    });
    const execPromise = onExecMock.mock.calls[0][0];

    await act(async () => await execPromise);

    expect(setPendingHistoryItemMock).toHaveBeenCalledWith(null);
    expect(addItemToHistoryMock).toHaveBeenCalledTimes(2);
    expect(addItemToHistoryMock.mock.calls[1][0]).toEqual({
      type: 'error',
      text: 'An unexpected error occurred: Synchronous spawn error',
    });
    const tmpFile = path.join('/tmp/gemini-shell-abcdef', 'pwd.tmp');
    // Verify that the temporary file was cleaned up
    expect(vi.mocked(fs.unlinkSync)).toHaveBeenCalledWith(tmpFile);
    expect(setShellInputFocusedMock).toHaveBeenCalledWith(false);
  });

  describe('Directory Change Warning', () => {
    it('should show a warning if the working directory changes', async () => {
      const tmpFile = path.join('/tmp/gemini-shell-abcdef', 'pwd.tmp');
      vi.mocked(fs.existsSync).mockReturnValue(true);
      vi.mocked(fs.readFileSync).mockReturnValue('/test/dir/new'); // A different directory

      const { result } = await renderProcessorHook();
      act(() => {
        result.current.handleShellCommand(
          'cd new',
          new AbortController().signal,
        );
      });
      const execPromise = onExecMock.mock.calls[0][0];

      act(() => {
        resolveExecutionPromise(createMockServiceResult());
      });
      await act(async () => await execPromise);

      const finalHistoryItem = addItemToHistoryMock.mock.calls[1][0];
      expect(finalHistoryItem.tools[0].resultDisplay).toContain(
        "WARNING: shell mode is stateless; the directory change to '/test/dir/new' will not persist.",
      );
      expect(vi.mocked(fs.unlinkSync)).toHaveBeenCalledWith(tmpFile);
    });

    it('should NOT show a warning if the directory does not change', async () => {
      vi.mocked(fs.existsSync).mockReturnValue(true);
      vi.mocked(fs.readFileSync).mockReturnValue('/test/dir'); // The same directory

      const { result } = await renderProcessorHook();
      act(() => {
        result.current.handleShellCommand('ls', new AbortController().signal);
      });
      const execPromise = onExecMock.mock.calls[0][0];

      act(() => {
        resolveExecutionPromise(createMockServiceResult());
      });
      await act(async () => await execPromise);

      const finalHistoryItem = addItemToHistoryMock.mock.calls[1][0];
      expect(finalHistoryItem.tools[0].resultDisplay).not.toContain('WARNING');
    });
  });

  describe('ActiveShellPtyId management', () => {
    beforeEach(() => {
      // The real service returns a promise that resolves with the pid and result promise
      mockShellExecutionService.mockImplementation((_cmd, _cwd, callback) => {
        mockShellOutputCallback = callback;
        return Promise.resolve({
          pid: 12345,
          result: new Promise((resolve) => {
            resolveExecutionPromise = resolve;
          }),
        });
      });
    });

    it('should have activeShellPtyId as null initially', async () => {
      const { result } = await renderProcessorHook();
      expect(result.current.activeShellPtyId).toBeNull();
    });

    it('should set activeShellPtyId when a command with a PID starts', async () => {
      const { result } = await renderProcessorHook();

      await act(async () => {
        result.current.handleShellCommand('ls', new AbortController().signal);
      });

      expect(result.current.activeShellPtyId).toBe(12345);
    });

    it('should update the pending history item with the ptyId', async () => {
      const { result } = await renderProcessorHook();

      await act(async () => {
        result.current.handleShellCommand('ls', new AbortController().signal);
      });

      await waitFor(() => {
        // Wait for the second call which is the functional update
        expect(setPendingHistoryItemMock).toHaveBeenCalledTimes(2);
      });

      // The state update is functional, so we test it by executing it.
      const updaterFn = setPendingHistoryItemMock.mock.lastCall?.[0];
      expect(typeof updaterFn).toBe('function');

      // The initial state is the first call to setPendingHistoryItem
      const initialState = setPendingHistoryItemMock.mock.calls[0][0];
      const stateAfterPid = updaterFn(initialState);

      expect(stateAfterPid.tools[0].ptyId).toBe(12345);
    });

    it('should reset activeShellPtyId to null after successful execution', async () => {
      const { result } = await renderProcessorHook();

      await act(async () => {
        result.current.handleShellCommand('ls', new AbortController().signal);
      });
      const execPromise = onExecMock.mock.calls[0][0];

      expect(result.current.activeShellPtyId).toBe(12345);

      await act(async () => {
        resolveExecutionPromise(createMockServiceResult());
      });
      await act(async () => await execPromise);

      expect(result.current.activeShellPtyId).toBeNull();
    });

    it('should reset activeShellPtyId to null after failed execution', async () => {
      const { result } = await renderProcessorHook();

      await act(async () => {
        result.current.handleShellCommand(
          'bad-cmd',
          new AbortController().signal,
        );
      });
      const execPromise = onExecMock.mock.calls[0][0];

      expect(result.current.activeShellPtyId).toBe(12345);

      await act(async () => {
        resolveExecutionPromise(createMockServiceResult({ exitCode: 1 }));
      });
      await act(async () => await execPromise);

      expect(result.current.activeShellPtyId).toBeNull();
    });

    it('should reset activeShellPtyId to null if execution promise rejects', async () => {
      let rejectResultPromise: (reason?: unknown) => void;
      mockShellExecutionService.mockImplementation(() =>
        Promise.resolve({
          pid: 12345,
          result: new Promise((_, reject) => {
            rejectResultPromise = reject;
          }),
        }),
      );
      const { result } = await renderProcessorHook();

      await act(async () => {
        result.current.handleShellCommand('cmd', new AbortController().signal);
      });
      const execPromise = onExecMock.mock.calls[0][0];

      expect(result.current.activeShellPtyId).toBe(12345);

      await act(async () => {
        rejectResultPromise(new Error('Failure'));
      });

      await act(async () => await execPromise);

      expect(result.current.activeShellPtyId).toBeNull();
    });

    it('should not set activeShellPtyId on synchronous execution error and should remain null', async () => {
      mockShellExecutionService.mockImplementation(() => {
        throw new Error('Sync Error');
      });
      const { result } = await renderProcessorHook();

      expect(result.current.activeShellPtyId).toBeNull(); // Pre-condition

      act(() => {
        result.current.handleShellCommand('cmd', new AbortController().signal);
      });
      const execPromise = onExecMock.mock.calls[0][0];

      // The hook's state should not have changed to a PID
      expect(result.current.activeShellPtyId).toBeNull();

      await act(async () => await execPromise); // Let the promise resolve

      // And it should still be null after everything is done
      expect(result.current.activeShellPtyId).toBeNull();
    });

    it('should not set activeShellPtyId if service does not return a PID', async () => {
      mockShellExecutionService.mockImplementation((_cmd, _cwd, callback) => {
        mockShellOutputCallback = callback;
        return Promise.resolve({
          pid: undefined, // No PID
          result: new Promise((resolve) => {
            resolveExecutionPromise = resolve;
          }),
        });
      });

      const { result } = await renderProcessorHook();

      act(() => {
        result.current.handleShellCommand('ls', new AbortController().signal);
      });

      // Let microtasks run
      await act(async () => {});

      expect(result.current.activeShellPtyId).toBeNull();
    });
  });

  describe('Background Shell Management', () => {
    it('should register a background shell and update count', async () => {
      const { result } = await renderProcessorHook();

      act(() => {
        result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
      });

      expect(result.current.backgroundTaskCount).toBe(1);
      const shell = result.current.backgroundTasks.get(1001);
      expect(shell).toEqual(
        expect.objectContaining({
          pid: 1001,
          command: 'bg-cmd',
          output: 'initial',
        }),
      );
      expect(mockLifecycleOnExit).toHaveBeenCalledWith(
        1001,
        expect.any(Function),
      );
      expect(mockLifecycleSubscribe).toHaveBeenCalledWith(
        1001,
        expect.any(Function),
      );
    });

    it('should toggle background shell visibility', async () => {
      const { result } = await renderProcessorHook();

      act(() => {
        result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
      });

      expect(result.current.isBackgroundTaskVisible).toBe(false);

      act(() => {
        result.current.toggleBackgroundTasks();
      });

      expect(result.current.isBackgroundTaskVisible).toBe(true);

      act(() => {
        result.current.toggleBackgroundTasks();
      });

      expect(result.current.isBackgroundTaskVisible).toBe(false);
    });

    it('should show info message when toggling background shells if none are active', async () => {
      const { result } = await renderProcessorHook();

      act(() => {
        result.current.toggleBackgroundTasks();
      });

      expect(addItemToHistoryMock).toHaveBeenCalledWith(
        expect.objectContaining({
          type: 'info',
          text: 'No background tasks are currently active.',
        }),
        expect.any(Number),
      );
      expect(result.current.isBackgroundTaskVisible).toBe(false);
    });

    it('should dismiss a background shell and remove it from state', async () => {
      const { result } = await renderProcessorHook();

      act(() => {
        result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
      });

      await act(async () => {
        await result.current.dismissBackgroundTask(1001);
      });

      expect(mockLifecycleKill).toHaveBeenCalledWith(1001);
      expect(result.current.backgroundTaskCount).toBe(0);
      expect(result.current.backgroundTasks.has(1001)).toBe(false);
    });

    it('should handle backgrounding the current shell', async () => {
      // Simulate an active shell
      mockShellExecutionService.mockImplementation((_cmd, _cwd, callback) => {
        mockShellOutputCallback = callback;
        return Promise.resolve({
          pid: 555,
          result: new Promise((resolve) => {
            resolveExecutionPromise = resolve;
          }),
        });
      });

      const { result } = await renderProcessorHook();

      await act(async () => {
        result.current.handleShellCommand('top', new AbortController().signal);
      });

      expect(result.current.activeShellPtyId).toBe(555);

      act(() => {
        result.current.backgroundCurrentExecution();
      });

      expect(mockShellBackground).toHaveBeenCalledWith(555);
      // The actual state update happens when the promise resolves with backgrounded: true
      // which is handled in handleShellCommand's .then block.
      // We simulate that here:

      await act(async () => {
        resolveExecutionPromise(
          createMockServiceResult({
            backgrounded: true,
            pid: 555,
            output: 'running...',
          }),
        );
      });
      // Wait for promise resolution
      await act(async () => await onExecMock.mock.calls[0][0]);

      expect(result.current.backgroundTaskCount).toBe(1);
      expect(result.current.activeShellPtyId).toBeNull();
    });

    it('should auto-dismiss background task on successful exit', async () => {
      const { result } = await renderProcessorHook();

      act(() => {
        result.current.registerBackgroundTask(888, 'auto-exit', '');
      });

      // Find the exit callback registered
      const exitCallback = mockLifecycleOnExit.mock.calls.find(
        (call) => call[0] === 888,
      )?.[1];
      expect(exitCallback).toBeDefined();

      if (exitCallback) {
        act(() => {
          exitCallback(0);
        });
      }

      // Should be auto-dismissed from the panel
      expect(result.current.backgroundTaskCount).toBe(0);
      expect(result.current.backgroundTasks.has(888)).toBe(false);
    });

    it('should auto-dismiss background task on failed exit', async () => {
      const { result } = await renderProcessorHook();

      act(() => {
        result.current.registerBackgroundTask(999, 'fail-exit', '');
      });

      const exitCallback = mockLifecycleOnExit.mock.calls.find(
        (call) => call[0] === 999,
      )?.[1];
      expect(exitCallback).toBeDefined();

      if (exitCallback) {
        act(() => {
          exitCallback(1);
        });
      }

      // Should be auto-dismissed from the panel
      expect(result.current.backgroundTaskCount).toBe(0);
      expect(result.current.backgroundTasks.has(999)).toBe(false);
    });

    it('should NOT trigger re-render on background shell output when visible', async () => {
      const { result, getRenderCount } = await renderProcessorHook();

      act(() => {
        result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
      });

      // Show the background shells
      act(() => {
        result.current.toggleBackgroundTasks();
      });

      const initialRenderCount = getRenderCount();

      const subscribeCallback = mockLifecycleSubscribe.mock.calls.find(
        (call) => call[0] === 1001,
      )?.[1];
      expect(subscribeCallback).toBeDefined();

      if (subscribeCallback) {
        act(() => {
          subscribeCallback({ type: 'data', chunk: ' + updated' });
        });
      }

      expect(getRenderCount()).toBeGreaterThan(initialRenderCount);
      const shell = result.current.backgroundTasks.get(1001);
      expect(shell?.output).toBe('initial + updated');
    });

    it('should NOT trigger re-render on background shell output when hidden', async () => {
      const { result, getRenderCount } = await renderProcessorHook();

      act(() => {
        result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
      });

      // Ensure background shells are hidden (default)
      const initialRenderCount = getRenderCount();

      const subscribeCallback = mockLifecycleSubscribe.mock.calls.find(
        (call) => call[0] === 1001,
      )?.[1];
      expect(subscribeCallback).toBeDefined();

      if (subscribeCallback) {
        act(() => {
          subscribeCallback({ type: 'data', chunk: ' + updated' });
        });
      }

      expect(getRenderCount()).toBeGreaterThan(initialRenderCount);
      const shell = result.current.backgroundTasks.get(1001);
      expect(shell?.output).toBe('initial + updated');
    });

    it('should trigger re-render on binary progress when visible', async () => {
      const { result, getRenderCount } = await renderProcessorHook();

      act(() => {
        result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
      });

      // Show the background shells
      act(() => {
        result.current.toggleBackgroundTasks();
      });

      const initialRenderCount = getRenderCount();

      const subscribeCallback = mockLifecycleSubscribe.mock.calls.find(
        (call) => call[0] === 1001,
      )?.[1];
      expect(subscribeCallback).toBeDefined();

      if (subscribeCallback) {
        act(() => {
          subscribeCallback({ type: 'binary_progress', bytesReceived: 1024 });
        });
      }

      expect(getRenderCount()).toBeGreaterThan(initialRenderCount);
      const shell = result.current.backgroundTasks.get(1001);
      expect(shell?.isBinary).toBe(true);
      expect(shell?.binaryBytesReceived).toBe(1024);
    });

    it('should NOT hide background shell when model is responding without confirmation', async () => {
      const { result, rerender } = await renderProcessorHook();

      // 1. Register and show background shell
      act(() => {
        result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
      });
      act(() => {
        result.current.toggleBackgroundTasks();
      });
      expect(result.current.isBackgroundTaskVisible).toBe(true);

      // 2. Simulate model responding (not waiting for confirmation)
      act(() => {
        rerender(false); // isWaitingForConfirmation = false
      });

      // Should stay visible
      expect(result.current.isBackgroundTaskVisible).toBe(true);
    });

    it('should hide background shell when waiting for confirmation and restore after delay', async () => {
      const { result, rerender } = await renderProcessorHook();

      // 1. Register and show background shell
      act(() => {
        result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
      });
      act(() => {
        result.current.toggleBackgroundTasks();
      });
      expect(result.current.isBackgroundTaskVisible).toBe(true);

      // 2. Simulate tool confirmation showing up
      act(() => {
        rerender(true); // isWaitingForConfirmation = true
      });

      // Should be hidden
      expect(result.current.isBackgroundTaskVisible).toBe(false);

      // 3. Simulate confirmation accepted (waiting for PTY start)
      act(() => {
        rerender(false);
      });

      // Should STAY hidden during the 300ms gap
      expect(result.current.isBackgroundTaskVisible).toBe(false);

      // 4. Wait for restore delay
      await waitFor(() =>
        expect(result.current.isBackgroundTaskVisible).toBe(true),
      );
    });

    it('should auto-hide background shell when foreground shell starts and restore when it ends', async () => {
      const { result } = await renderProcessorHook();

      // 1. Register and show background shell
      act(() => {
        result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
      });
      act(() => {
        result.current.toggleBackgroundTasks();
      });
      expect(result.current.isBackgroundTaskVisible).toBe(true);

      // 2. Start foreground shell
      act(() => {
        result.current.handleShellCommand('ls', new AbortController().signal);
      });

      // Wait for PID to be set
      await waitFor(() => expect(result.current.activeShellPtyId).toBe(12345));

      // Should be hidden automatically
      expect(result.current.isBackgroundTaskVisible).toBe(false);

      // 3. Complete foreground shell
      act(() => {
        resolveExecutionPromise(createMockServiceResult());
      });

      await waitFor(() => expect(result.current.activeShellPtyId).toBe(null));

      // Should be restored automatically (after delay)
      await waitFor(() =>
        expect(result.current.isBackgroundTaskVisible).toBe(true),
      );
    });

    it('should NOT restore background shell if it was manually hidden during foreground execution', async () => {
      const { result } = await renderProcessorHook();

      // 1. Register and show background shell
      act(() => {
        result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
      });
      act(() => {
        result.current.toggleBackgroundTasks();
      });
      expect(result.current.isBackgroundTaskVisible).toBe(true);

      // 2. Start foreground shell
      act(() => {
        result.current.handleShellCommand('ls', new AbortController().signal);
      });
      await waitFor(() => expect(result.current.activeShellPtyId).toBe(12345));
      expect(result.current.isBackgroundTaskVisible).toBe(false);

      // 3. Manually toggle visibility (e.g. user wants to peek)
      act(() => {
        result.current.toggleBackgroundTasks();
      });
      expect(result.current.isBackgroundTaskVisible).toBe(true);

      // 4. Complete foreground shell
      act(() => {
        resolveExecutionPromise(createMockServiceResult());
      });
      await waitFor(() => expect(result.current.activeShellPtyId).toBe(null));

      // It should NOT change visibility because manual toggle cleared the auto-restore flag
      // After delay it should stay true (as it was manually toggled to true)
      await waitFor(() =>
        expect(result.current.isBackgroundTaskVisible).toBe(true),
      );
    });
  });
});
