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

import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import { act } from 'react';
import { renderHook } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import { useSlashCommandProcessor } from './slashCommandProcessor.js';
import { CommandKind, type SlashCommand } from '../commands/types.js';
import type { LoadedSettings } from '../../config/settings.js';
import { MessageType } from '../types.js';
import { BuiltinCommandLoader } from '../../services/BuiltinCommandLoader.js';
import { FileCommandLoader } from '../../services/FileCommandLoader.js';
import { McpPromptLoader } from '../../services/McpPromptLoader.js';
import {
  SlashCommandStatus,
  MCPDiscoveryState,
  makeFakeConfig,
  coreEvents,
  type GeminiClient,
} from '@google/gemini-cli-core';

const {
  logSlashCommand,
  mockBuiltinLoadCommands,
  mockFileLoadCommands,
  mockMcpLoadCommands,
  mockIdeClientGetInstance,
  mockUseAlternateBuffer,
} = vi.hoisted(() => ({
  logSlashCommand: vi.fn(),
  mockBuiltinLoadCommands: vi.fn().mockResolvedValue([]),
  mockFileLoadCommands: vi.fn().mockResolvedValue([]),
  mockMcpLoadCommands: vi.fn().mockResolvedValue([]),
  mockIdeClientGetInstance: vi.fn().mockResolvedValue({
    addStatusChangeListener: vi.fn(),
    removeStatusChangeListener: vi.fn(),
  }),
  mockUseAlternateBuffer: vi.fn().mockReturnValue(false),
}));

vi.mock('./useAlternateBuffer.js', () => ({
  useAlternateBuffer: mockUseAlternateBuffer,
}));

vi.mock('@google/gemini-cli-core', async (importOriginal) => {
  const original =
    await importOriginal<typeof import('@google/gemini-cli-core')>();

  return {
    ...original,
    logSlashCommand,
    getIdeInstaller: vi.fn().mockReturnValue(null),
    IdeClient: {
      getInstance: mockIdeClientGetInstance,
    },
  };
});

const { mockProcessExit } = vi.hoisted(() => ({
  mockProcessExit: vi.fn((_code?: number): never => undefined as never),
}));

vi.mock('node:process', () => {
  const mockProcess: Partial<NodeJS.Process> = {
    exit: mockProcessExit,
    platform: 'sunos',
    cwd: () => '/fake/dir',
    env: {},
  } as unknown as NodeJS.Process;
  return {
    ...mockProcess,
    default: mockProcess,
  };
});

vi.mock('../../services/BuiltinCommandLoader.js', () => ({
  BuiltinCommandLoader: vi.fn(() => ({
    loadCommands: mockBuiltinLoadCommands,
  })),
}));

vi.mock('../../services/FileCommandLoader.js', () => ({
  FileCommandLoader: vi.fn(() => ({
    loadCommands: mockFileLoadCommands,
  })),
}));

vi.mock('../../services/McpPromptLoader.js', () => ({
  McpPromptLoader: vi.fn(() => ({
    loadCommands: mockMcpLoadCommands,
  })),
}));

vi.mock('../contexts/SessionContext.js', () => ({
  useSessionStats: vi.fn(() => ({ stats: {} })),
}));

const { mockRunExitCleanup } = vi.hoisted(() => ({
  mockRunExitCleanup: vi.fn(),
}));

vi.mock('../../utils/cleanup.js', () => ({
  runExitCleanup: mockRunExitCleanup,
}));

function createTestCommand(
  overrides: Partial<SlashCommand>,
  kind: CommandKind = CommandKind.BUILT_IN,
): SlashCommand {
  return {
    name: 'test',
    description: 'a test command',
    kind,
    ...overrides,
  };
}

describe('useSlashCommandProcessor', () => {
  const mockAddItem = vi.fn();
  const mockClearItems = vi.fn();
  const mockLoadHistory = vi.fn();
  const mockOpenThemeDialog = vi.fn();
  const mockOpenAuthDialog = vi.fn();
  const mockOpenModelDialog = vi.fn();
  const mockSetQuittingMessages = vi.fn();

  const mockConfig = makeFakeConfig({});
  const mockSettings = {} as LoadedSettings;

  let unmountHook: (() => Promise<void>) | undefined;

  beforeEach(() => {
    vi.clearAllMocks();
    vi.mocked(BuiltinCommandLoader).mockClear();
    mockBuiltinLoadCommands.mockResolvedValue([]);
    mockFileLoadCommands.mockResolvedValue([]);
    mockMcpLoadCommands.mockResolvedValue([]);
    mockUseAlternateBuffer.mockReturnValue(false);
    mockIdeClientGetInstance.mockResolvedValue({
      addStatusChangeListener: vi.fn(),
      removeStatusChangeListener: vi.fn(),
    });
    vi.spyOn(console, 'clear').mockImplementation(() => {});
  });

  afterEach(async () => {
    if (unmountHook) {
      await unmountHook();
      unmountHook = undefined;
    }
    vi.restoreAllMocks();
  });

  const setupProcessorHook = async (
    options: {
      builtinCommands?: SlashCommand[];
      fileCommands?: SlashCommand[];
      mcpCommands?: SlashCommand[];
      setIsProcessing?: (isProcessing: boolean) => void;
      refreshStatic?: () => void;
      openAgentConfigDialog?: (
        name: string,
        displayName: string,
        definition: unknown,
      ) => void;
    } = {},
  ) => {
    const {
      builtinCommands = [],
      fileCommands = [],
      mcpCommands = [],
      setIsProcessing = vi.fn(),
      refreshStatic = vi.fn(),
      openAgentConfigDialog = vi.fn(),
    } = options;

    mockBuiltinLoadCommands.mockResolvedValue(Object.freeze(builtinCommands));
    mockFileLoadCommands.mockResolvedValue(Object.freeze(fileCommands));
    mockMcpLoadCommands.mockResolvedValue(Object.freeze(mcpCommands));

    let result!: { current: ReturnType<typeof useSlashCommandProcessor> };
    let unmount!: () => void;
    let rerender!: (props?: unknown) => void;

    await act(async () => {
      const hook = await renderHook(() =>
        useSlashCommandProcessor(
          mockConfig,
          mockSettings,
          mockAddItem,
          mockClearItems,
          mockLoadHistory,
          refreshStatic,
          vi.fn(), // toggleVimEnabled
          setIsProcessing,
          {
            openAuthDialog: mockOpenAuthDialog,
            openThemeDialog: mockOpenThemeDialog,
            openEditorDialog: vi.fn(),
            openPrivacyNotice: vi.fn(),
            openSettingsDialog: vi.fn(),
            openSessionBrowser: vi.fn(),
            openModelDialog: mockOpenModelDialog,
            openVoiceModelDialog: vi.fn(),
            openAgentConfigDialog,
            openPermissionsDialog: vi.fn(),
            quit: mockSetQuittingMessages,
            setDebugMessage: vi.fn(),
            toggleCorgiMode: vi.fn(),
            toggleVoiceMode: vi.fn(),
            toggleDebugProfiler: vi.fn(),
            dispatchExtensionStateUpdate: vi.fn(),
            addConfirmUpdateExtensionRequest: vi.fn(),
            toggleBackgroundTasks: vi.fn(),
            toggleShortcutsHelp: vi.fn(),
            setText: vi.fn(),
          },
          new Map(), // extensionsUpdateState
          true, // isConfigInitialized
          vi.fn(), // setBannerVisible
          vi.fn(), // setCustomDialog
        ),
      );
      result = hook.result;
      unmount = hook.unmount;
      rerender = hook.rerender;
    });

    unmountHook = async () => {
      unmount();
    };

    await waitFor(() => {
      expect(result.current.slashCommands).toBeDefined();
    });

    return {
      get current() {
        return result.current;
      },
      unmount,
      rerender: async () => {
        rerender();
      },
    };
  };

  describe('Console Clear Safety', () => {
    it('should not call console.clear if alternate buffer is active', async () => {
      mockUseAlternateBuffer.mockReturnValue(true);
      const clearCommand = createTestCommand({
        name: 'clear',
        action: async (context) => {
          context.ui.clear();
        },
      });
      const result = await setupProcessorHook({
        builtinCommands: [clearCommand],
      });

      await act(async () => {
        await result.current.handleSlashCommand('/clear');
      });

      expect(mockClearItems).toHaveBeenCalled();
    });

    it('should call console.clear if alternate buffer is not active', async () => {
      mockUseAlternateBuffer.mockReturnValue(false);
      const clearCommand = createTestCommand({
        name: 'clear',
        action: async (context) => {
          context.ui.clear();
        },
      });
      const result = await setupProcessorHook({
        builtinCommands: [clearCommand],
      });

      await act(async () => {
        await result.current.handleSlashCommand('/clear');
      });

      expect(mockClearItems).toHaveBeenCalled();
    });
  });

  describe('Initialization and Command Loading', () => {
    it('should initialize CommandService with all required loaders', async () => {
      await setupProcessorHook();
      expect(BuiltinCommandLoader).toHaveBeenCalledWith(mockConfig);
      expect(FileCommandLoader).toHaveBeenCalledWith(mockConfig);
      expect(McpPromptLoader).toHaveBeenCalledWith(mockConfig);
    });

    it('should call loadCommands and populate state after mounting', async () => {
      const testCommand = createTestCommand({ name: 'test' });
      const result = await setupProcessorHook({
        builtinCommands: [testCommand],
      });

      await waitFor(() => {
        expect(result.current.slashCommands).toHaveLength(1);
      });

      expect(result.current.slashCommands?.[0]?.name).toBe('test');
      expect(mockBuiltinLoadCommands).toHaveBeenCalledTimes(1);
      expect(mockFileLoadCommands).toHaveBeenCalledTimes(1);
      expect(mockMcpLoadCommands).toHaveBeenCalledTimes(1);
    });
  });

  describe('Command Execution Logic', () => {
    it('should treat unknown commands as regular input', async () => {
      const result = await setupProcessorHook();
      await waitFor(() => expect(result.current.slashCommands).toBeDefined());

      let handled: Awaited<
        ReturnType<typeof result.current.handleSlashCommand>
      >;
      await act(async () => {
        handled = await result.current.handleSlashCommand('/nonexistent');
      });

      // Unknown commands should return false so the input is sent to the model
      expect(handled!).toBe(false);
      // Should not add anything to history (the regular flow will handle it)
      expect(mockAddItem).not.toHaveBeenCalled();
    });

    it('should show MCP loading warning for unknown commands when MCP is loading', async () => {
      vi.spyOn(mockConfig, 'getMcpClientManager').mockReturnValue({
        getDiscoveryState: () => MCPDiscoveryState.IN_PROGRESS,
      } as ReturnType<typeof mockConfig.getMcpClientManager>);

      const result = await setupProcessorHook();
      await waitFor(() => expect(result.current.slashCommands).toBeDefined());

      let handled: Awaited<
        ReturnType<typeof result.current.handleSlashCommand>
      >;
      await act(async () => {
        handled = await result.current.handleSlashCommand('/mcp-command');
      });

      // When MCP is loading, should handle the command (show warning)
      expect(handled!).not.toBe(false);
      // Should add user input and error message to history
      expect(mockAddItem).toHaveBeenCalledWith(
        { type: MessageType.USER, text: '/mcp-command' },
        expect.any(Number),
      );
      expect(mockAddItem).toHaveBeenCalledWith(
        expect.objectContaining({
          type: MessageType.ERROR,
        }),
        expect.any(Number),
      );
    });

    it('should display help for a parent command invoked without a subcommand', async () => {
      const parentCommand: SlashCommand = {
        name: 'parent',
        description: 'a parent command',
        kind: CommandKind.BUILT_IN,
        subCommands: [
          {
            name: 'child1',
            description: 'First child.',
            kind: CommandKind.BUILT_IN,
          },
        ],
      };
      const result = await setupProcessorHook({
        builtinCommands: [parentCommand],
      });
      await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));

      await act(async () => {
        await result.current.handleSlashCommand('/parent');
      });

      expect(mockAddItem).toHaveBeenCalledTimes(2);
      expect(mockAddItem).toHaveBeenLastCalledWith(
        {
          type: MessageType.INFO,
          text: expect.stringContaining(
            "Command '/parent' requires a subcommand.",
          ),
        },
        expect.any(Number),
      );
    });

    it('should correctly find and execute a nested subcommand', async () => {
      const childAction = vi.fn();
      const parentCommand: SlashCommand = {
        name: 'parent',
        description: 'a parent command',
        kind: CommandKind.BUILT_IN,
        subCommands: [
          {
            name: 'child',
            description: 'a child command',
            kind: CommandKind.BUILT_IN,
            action: childAction,
          },
        ],
      };
      const result = await setupProcessorHook({
        builtinCommands: [parentCommand],
      });
      await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));

      await act(async () => {
        await result.current.handleSlashCommand('/parent child with args');
      });

      expect(childAction).toHaveBeenCalledTimes(1);

      expect(childAction).toHaveBeenCalledWith(
        expect.objectContaining({
          services: expect.objectContaining({
            agentContext: mockConfig,
          }),
          ui: expect.objectContaining({
            addItem: mockAddItem,
          }),
        }),
        'with args',
      );
    });

    it('sets isProcessing to false if the the input is not a command', async () => {
      const setMockIsProcessing = vi.fn();
      const result = await setupProcessorHook({
        setIsProcessing: setMockIsProcessing,
      });

      await act(async () => {
        await result.current.handleSlashCommand('imnotacommand');
      });

      expect(setMockIsProcessing).not.toHaveBeenCalled();
    });

    it('sets isProcessing to false if the command has an error', async () => {
      const setMockIsProcessing = vi.fn();
      const failCommand = createTestCommand({
        name: 'fail',
        action: vi.fn().mockRejectedValue(new Error('oh no!')),
      });

      const result = await setupProcessorHook({
        builtinCommands: [failCommand],
        setIsProcessing: setMockIsProcessing,
      });

      await waitFor(() => expect(result.current.slashCommands).toBeDefined());

      await act(async () => {
        await result.current.handleSlashCommand('/fail');
      });

      expect(setMockIsProcessing).toHaveBeenNthCalledWith(1, true);
      expect(setMockIsProcessing).toHaveBeenNthCalledWith(2, false);
    });

    it('should set isProcessing to true during execution and false afterwards', async () => {
      const mockSetIsProcessing = vi.fn();
      const command = createTestCommand({
        name: 'long-running',
        action: () => new Promise((resolve) => setTimeout(resolve, 50)),
      });

      const result = await setupProcessorHook({
        builtinCommands: [command],
        setIsProcessing: mockSetIsProcessing,
      });
      await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));

      const executionPromise = act(async () => {
        await result.current.handleSlashCommand('/long-running');
      });

      // It should be true immediately after starting
      expect(mockSetIsProcessing).toHaveBeenNthCalledWith(1, true);
      // It should not have been called with false yet
      expect(mockSetIsProcessing).not.toHaveBeenCalledWith(false);

      await executionPromise;

      // After the promise resolves, it should be called with false
      expect(mockSetIsProcessing).toHaveBeenNthCalledWith(2, false);
      expect(mockSetIsProcessing).toHaveBeenCalledTimes(2);
    });
  });

  describe('Action Result Handling', () => {
    describe('Dialog actions', () => {
      it.each([
        {
          dialogType: 'theme',
          commandName: 'themecmd',
          mockFn: mockOpenThemeDialog,
        },
        {
          dialogType: 'model',
          commandName: 'modelcmd',
          mockFn: mockOpenModelDialog,
        },
      ])(
        'should handle "dialog: $dialogType" action',
        async ({ dialogType, commandName, mockFn }) => {
          const command = createTestCommand({
            name: commandName,
            action: vi
              .fn()
              .mockResolvedValue({ type: 'dialog', dialog: dialogType }),
          });
          const result = await setupProcessorHook({
            builtinCommands: [command],
          });
          await waitFor(() =>
            expect(result.current.slashCommands).toHaveLength(1),
          );

          await act(async () => {
            await result.current.handleSlashCommand(`/${commandName}`);
          });

          expect(mockFn).toHaveBeenCalled();
        },
      );

      it('should handle "dialog: agentConfig" action with props', async () => {
        const mockOpenAgentConfigDialog = vi.fn();
        const agentDefinition = { name: 'test-agent' };
        const commandName = 'agentconfigcmd';
        const command = createTestCommand({
          name: commandName,
          action: vi.fn().mockResolvedValue({
            type: 'dialog',
            dialog: 'agentConfig',
            props: {
              name: 'test-agent',
              displayName: 'Test Agent',
              definition: agentDefinition,
            },
          }),
        });

        const result = await setupProcessorHook({
          builtinCommands: [command],
          openAgentConfigDialog: mockOpenAgentConfigDialog,
        });

        await waitFor(() =>
          expect(result.current.slashCommands).toHaveLength(1),
        );

        await act(async () => {
          await result.current.handleSlashCommand(`/${commandName}`);
        });

        expect(mockOpenAgentConfigDialog).toHaveBeenCalledWith(
          'test-agent',
          'Test Agent',
          agentDefinition,
        );
      });
    });

    it('should handle "load_history" action', async () => {
      const mockClient = {
        setHistory: vi.fn(),
        stripThoughtsFromHistory: vi.fn(),
      } as unknown as GeminiClient;
      vi.spyOn(mockConfig, 'getGeminiClient').mockReturnValue(mockClient);

      const command = createTestCommand({
        name: 'load',
        action: vi.fn().mockResolvedValue({
          type: 'load_history',
          history: [{ type: MessageType.USER, text: 'old prompt' }],
          clientHistory: [{ role: 'user', parts: [{ text: 'old prompt' }] }],
        }),
      });

      const mockRefreshStatic = vi.fn();
      const result = await setupProcessorHook({
        builtinCommands: [command],
        refreshStatic: mockRefreshStatic,
      });

      await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));

      await act(async () => {
        await result.current.handleSlashCommand('/load');
      });

      // ui.clear() is called which calls refreshStatic()
      expect(mockClearItems).toHaveBeenCalledTimes(1);
      expect(mockRefreshStatic).toHaveBeenCalledTimes(1);
      expect(mockAddItem).toHaveBeenCalledWith(
        { type: 'user', text: 'old prompt' },
        expect.any(Number),
      );
    });

    it('should call refreshStatic exactly once when ui.loadHistory is called', async () => {
      const mockRefreshStatic = vi.fn();
      const result = await setupProcessorHook({
        refreshStatic: mockRefreshStatic,
      });

      await act(async () => {
        result.current.commandContext.ui.loadHistory([]);
      });

      expect(mockLoadHistory).toHaveBeenCalled();
      expect(mockRefreshStatic).toHaveBeenCalledTimes(1);
    });

    it('should handle a "quit" action', async () => {
      const quitAction = vi
        .fn()
        .mockResolvedValue({ type: 'quit', messages: ['bye'] });
      const command = createTestCommand({
        name: 'exit',
        action: quitAction,
      });
      const result = await setupProcessorHook({
        builtinCommands: [command],
      });

      await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));

      await act(async () => {
        await result.current.handleSlashCommand('/exit');
      });

      expect(mockSetQuittingMessages).toHaveBeenCalledWith(['bye']);
    });

    it('should delete the current session when quit action has deleteSession flag', async () => {
      const mockDeleteCurrentSessionAsync = vi
        .fn()
        .mockResolvedValue(undefined);

      const mockClient = {
        getChatRecordingService: vi.fn().mockReturnValue({
          deleteCurrentSessionAsync: mockDeleteCurrentSessionAsync,
        }),
      } as unknown as GeminiClient;
      vi.spyOn(mockConfig, 'getGeminiClient').mockReturnValue(mockClient);

      const quitAction = vi.fn().mockResolvedValue({
        type: 'quit',
        deleteSession: true,
        messages: ['bye'],
      });
      const command = createTestCommand({
        name: 'exit',
        action: quitAction,
      });
      const result = await setupProcessorHook({
        builtinCommands: [command],
      });

      await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));

      await act(async () => {
        await result.current.handleSlashCommand('/exit --delete');
      });

      expect(mockDeleteCurrentSessionAsync).toHaveBeenCalled();
      expect(mockSetQuittingMessages).toHaveBeenCalledWith(['bye']);
    });

    it('should not delete session when quit action does not have deleteSession flag', async () => {
      const mockDeleteCurrentSessionAsync = vi
        .fn()
        .mockResolvedValue(undefined);
      const mockClient = {
        getChatRecordingService: vi.fn().mockReturnValue({
          deleteCurrentSessionAsync: mockDeleteCurrentSessionAsync,
        }),
      } as unknown as GeminiClient;
      vi.spyOn(mockConfig, 'getGeminiClient').mockReturnValue(mockClient);

      const quitAction = vi.fn().mockResolvedValue({
        type: 'quit',
        messages: ['bye'],
      });
      const command = createTestCommand({
        name: 'exit',
        action: quitAction,
      });
      const result = await setupProcessorHook({
        builtinCommands: [command],
      });

      await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));

      await act(async () => {
        await result.current.handleSlashCommand('/exit');
      });

      expect(mockDeleteCurrentSessionAsync).not.toHaveBeenCalled();
      expect(mockSetQuittingMessages).toHaveBeenCalledWith(['bye']);
    });

    it('should still quit even if session deletion fails', async () => {
      const mockClient = {
        getChatRecordingService: vi.fn().mockReturnValue({
          deleteCurrentSessionAsync: vi
            .fn()
            .mockRejectedValue(new Error('Deletion failed')),
        }),
      } as unknown as GeminiClient;
      vi.spyOn(mockConfig, 'getGeminiClient').mockReturnValue(mockClient);

      const quitAction = vi.fn().mockResolvedValue({
        type: 'quit',
        deleteSession: true,
        messages: ['bye'],
      });
      const command = createTestCommand({
        name: 'exit',
        action: quitAction,
      });
      const result = await setupProcessorHook({
        builtinCommands: [command],
      });

      await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));

      await act(async () => {
        await result.current.handleSlashCommand('/exit --delete');
      });

      // Should still quit even though deletion threw
      expect(mockSetQuittingMessages).toHaveBeenCalledWith(['bye']);
    });

    it('should handle "submit_prompt" action returned from a file-based command', async () => {
      const fileCommand = createTestCommand(
        {
          name: 'filecmd',
          description: 'A command from a file',
          action: async () => ({
            type: 'submit_prompt',
            content: [{ text: 'The actual prompt from the TOML file.' }],
          }),
        },
        CommandKind.USER_FILE,
      );

      const result = await setupProcessorHook({
        fileCommands: [fileCommand],
      });
      await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));

      let actionResult;
      await act(async () => {
        actionResult = await result.current.handleSlashCommand('/filecmd');
      });

      expect(actionResult).toEqual({
        type: 'submit_prompt',
        content: [{ text: 'The actual prompt from the TOML file.' }],
      });

      expect(mockAddItem).toHaveBeenCalledWith(
        { type: MessageType.USER, text: '/filecmd' },
        expect.any(Number),
      );
    });

    it('should handle "submit_prompt" action returned from a mcp-based command', async () => {
      const mcpCommand = createTestCommand(
        {
          name: 'mcpcmd',
          description: 'A command from mcp',
          action: async () => ({
            type: 'submit_prompt',
            content: [{ text: 'The actual prompt from the mcp command.' }],
          }),
        },
        CommandKind.MCP_PROMPT,
      );

      const result = await setupProcessorHook({
        mcpCommands: [mcpCommand],
      });
      await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));

      let actionResult;
      await act(async () => {
        actionResult = await result.current.handleSlashCommand('/mcpcmd');
      });

      expect(actionResult).toEqual({
        type: 'submit_prompt',
        content: [{ text: 'The actual prompt from the mcp command.' }],
      });

      expect(mockAddItem).toHaveBeenCalledWith(
        { type: MessageType.USER, text: '/mcpcmd' },
        expect.any(Number),
      );
    });
  });

  describe('Command Parsing and Matching', () => {
    it('should be case-sensitive', async () => {
      const command = createTestCommand({ name: 'test' });
      const result = await setupProcessorHook({
        builtinCommands: [command],
      });
      await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));

      let handled: Awaited<
        ReturnType<typeof result.current.handleSlashCommand>
      >;
      await act(async () => {
        // Use uppercase when command is lowercase
        handled = await result.current.handleSlashCommand('/Test');
      });

      // Case mismatch means it's not a known command, so treat as regular input
      expect(handled!).toBe(false);
      expect(mockAddItem).not.toHaveBeenCalled();
    });

    it('should correctly match an altName', async () => {
      const action = vi.fn();
      const command = createTestCommand({
        name: 'main',
        altNames: ['alias'],
        description: 'a command with an alias',
        action,
      });
      const result = await setupProcessorHook({
        builtinCommands: [command],
      });
      await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));

      await act(async () => {
        await result.current.handleSlashCommand('/alias');
      });

      expect(action).toHaveBeenCalledTimes(1);
      expect(mockAddItem).not.toHaveBeenCalledWith(
        expect.objectContaining({ type: MessageType.ERROR }),
      );
    });

    it('should handle extra whitespace around the command', async () => {
      const action = vi.fn();
      const command = createTestCommand({ name: 'test', action });
      const result = await setupProcessorHook({
        builtinCommands: [command],
      });
      await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));

      await act(async () => {
        await result.current.handleSlashCommand('  /test  with-args  ');
      });

      expect(action).toHaveBeenCalledWith(expect.anything(), 'with-args');
    });

    it('should handle `?` as a command prefix', async () => {
      const action = vi.fn();
      const command = createTestCommand({ name: 'help', action });
      const result = await setupProcessorHook({
        builtinCommands: [command],
      });
      await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));

      await act(async () => {
        await result.current.handleSlashCommand('?help');
      });

      expect(action).toHaveBeenCalledTimes(1);
    });
  });

  describe('Command Precedence', () => {
    it('should prioritize a command with a primary name over a command with a matching alias', async () => {
      const quitAction = vi.fn();
      const exitAction = vi.fn();

      const quitCommand = createTestCommand({
        name: 'quit',
        altNames: ['exit'],
        action: quitAction,
      });

      const exitCommand = createTestCommand(
        {
          name: 'exit',
          action: exitAction,
        },
        CommandKind.USER_FILE,
      );

      // The order of commands in the final loaded array is not guaranteed,
      // so the test must work regardless of which comes first.
      const result = await setupProcessorHook({
        builtinCommands: [quitCommand],
        fileCommands: [exitCommand],
      });

      await waitFor(() => {
        expect(result.current.slashCommands).toHaveLength(2);
      });

      await act(async () => {
        await result.current.handleSlashCommand('/exit');
      });

      // The action for the command whose primary name is 'exit' should be called.
      expect(exitAction).toHaveBeenCalledTimes(1);
      // The action for the command that has 'exit' as an alias should NOT be called.
      expect(quitAction).not.toHaveBeenCalled();
    });

    it('should add an overridden command to the history', async () => {
      const quitCommand = createTestCommand({
        name: 'quit',
        altNames: ['exit'],
        action: vi.fn(),
      });
      const exitCommand = createTestCommand(
        { name: 'exit', action: vi.fn() },
        CommandKind.USER_FILE,
      );

      const result = await setupProcessorHook({
        builtinCommands: [quitCommand],
        fileCommands: [exitCommand],
      });
      await waitFor(() => expect(result.current.slashCommands).toHaveLength(2));

      await act(async () => {
        await result.current.handleSlashCommand('/exit');
      });

      // It should be added to the history.
      expect(mockAddItem).toHaveBeenCalledWith(
        { type: MessageType.USER, text: '/exit' },
        expect.any(Number),
      );
    });
  });

  describe('Lifecycle', () => {
    it('removes the IDE status listener on unmount after async initialization', async () => {
      let resolveIdeClient:
        | ((client: {
            addStatusChangeListener: (listener: () => void) => void;
            removeStatusChangeListener: (listener: () => void) => void;
          }) => void)
        | undefined;
      const addStatusChangeListener = vi.fn();
      const removeStatusChangeListener = vi.fn();

      mockIdeClientGetInstance.mockImplementation(
        () =>
          new Promise((resolve) => {
            resolveIdeClient = resolve;
          }),
      );

      const result = await setupProcessorHook();

      await act(async () => {
        resolveIdeClient?.({
          addStatusChangeListener,
          removeStatusChangeListener,
        });
      });

      result.unmount();
      unmountHook = undefined;

      expect(addStatusChangeListener).toHaveBeenCalledTimes(1);
      expect(removeStatusChangeListener).toHaveBeenCalledTimes(1);
      expect(removeStatusChangeListener).toHaveBeenCalledWith(
        addStatusChangeListener.mock.calls[0]?.[0],
      );
    });

    it('does not register an IDE status listener if unmounted before async initialization resolves', async () => {
      let resolveIdeClient:
        | ((client: {
            addStatusChangeListener: (listener: () => void) => void;
            removeStatusChangeListener: (listener: () => void) => void;
          }) => void)
        | undefined;
      const addStatusChangeListener = vi.fn();
      const removeStatusChangeListener = vi.fn();

      mockIdeClientGetInstance.mockImplementation(
        () =>
          new Promise((resolve) => {
            resolveIdeClient = resolve;
          }),
      );

      const result = await setupProcessorHook();

      result.unmount();
      unmountHook = undefined;

      await act(async () => {
        resolveIdeClient?.({
          addStatusChangeListener,
          removeStatusChangeListener,
        });
      });

      expect(addStatusChangeListener).not.toHaveBeenCalled();
      expect(removeStatusChangeListener).not.toHaveBeenCalled();
    });

    it('should abort command loading when the hook unmounts', async () => {
      const abortSpy = vi.spyOn(AbortController.prototype, 'abort');
      const { unmount } = await setupProcessorHook();

      unmount();
      unmountHook = undefined;

      expect(abortSpy).toHaveBeenCalledTimes(1);
    });
  });

  describe('Slash Command Logging', () => {
    const mockCommandAction = vi.fn().mockResolvedValue({ type: 'handled' });
    let loggingTestCommands: SlashCommand[];

    beforeEach(() => {
      mockCommandAction.mockClear();
      vi.mocked(logSlashCommand).mockClear();
      loggingTestCommands = [
        createTestCommand({
          name: 'logtest',
          action: vi
            .fn()
            .mockResolvedValue({ type: 'message', content: 'hello world' }),
        }),
        createTestCommand({
          name: 'logwithsub',
          subCommands: [
            createTestCommand({
              name: 'sub',
              action: mockCommandAction,
            }),
          ],
        }),
        createTestCommand({
          name: 'fail',
          action: vi.fn().mockRejectedValue(new Error('oh no!')),
        }),
        createTestCommand({
          name: 'logalias',
          altNames: ['la'],
          action: mockCommandAction,
        }),
      ];
    });

    it.each([
      {
        command: '/logtest',
        expectedLog: {
          command: 'logtest',
          subcommand: undefined,
          status: SlashCommandStatus.SUCCESS,
        },
        desc: 'simple slash command',
      },
      {
        command: '/fail',
        expectedLog: {
          command: 'fail',
          status: SlashCommandStatus.ERROR,
          subcommand: undefined,
        },
        desc: 'failure event for failed command',
      },
      {
        command: '/logwithsub sub',
        expectedLog: {
          command: 'logwithsub',
          subcommand: 'sub',
        },
        desc: 'slash command with subcommand',
      },
      {
        command: '/la',
        expectedLog: {
          command: 'logalias',
        },
        desc: 'command path when alias is used',
      },
    ])('should log $desc', async ({ command, expectedLog }) => {
      const result = await setupProcessorHook({
        builtinCommands: loggingTestCommands,
      });
      await waitFor(() => expect(result.current.slashCommands).toBeDefined());

      await act(async () => {
        await result.current.handleSlashCommand(command);
      });

      await waitFor(() => {
        expect(logSlashCommand).toHaveBeenCalledWith(
          mockConfig,
          expect.objectContaining(expectedLog),
        );
      });
    });

    it.each([
      { command: '/bogusbogusbogus', desc: 'bogus command' },
      { command: '/unknown', desc: 'unknown command' },
    ])('should not log for $desc', async ({ command }) => {
      const result = await setupProcessorHook({
        builtinCommands: loggingTestCommands,
      });
      await waitFor(() => expect(result.current.slashCommands).toBeDefined());

      await act(async () => {
        await result.current.handleSlashCommand(command);
      });

      expect(logSlashCommand).not.toHaveBeenCalled();
    });
  });

  it('should reload commands on extension events', async () => {
    const result = await setupProcessorHook();
    await waitFor(() => expect(result.current.slashCommands).toEqual([]));

    // Create a new command and make that the result of the fileLoadCommands
    // (which is where extension commands come from)
    const newCommand = createTestCommand({
      name: 'someNewCommand',
      action: vi.fn(),
    });
    mockFileLoadCommands.mockResolvedValue([newCommand]);

    // We should not see a change until we fire an event.
    await waitFor(() => expect(result.current.slashCommands).toEqual([]));
    act(() => {
      coreEvents.emit('extensionsStarting');
    });
    await waitFor(() =>
      expect(result.current.slashCommands).toEqual([newCommand]),
    );
  });
});
