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

import type {
  GenerateContentParameters,
  GenerateContentResponse,
} from '@google/genai';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { HookEventHandler } from './hookEventHandler.js';
import type { Config } from '../config/config.js';
import {
  NotificationType,
  SessionStartSource,
  HookEventName,
  HookType,
  type HookConfig,
  type HookExecutionResult,
} from './types.js';
import type { HookPlanner } from './hookPlanner.js';
import type { HookRunner } from './hookRunner.js';
import type { HookAggregator } from './hookAggregator.js';

// Mock debugLogger
const mockDebugLogger = vi.hoisted(() => ({
  log: vi.fn(),
  warn: vi.fn(),
  error: vi.fn(),
  debug: vi.fn(),
}));

// Mock coreEvents
const mockCoreEvents = vi.hoisted(() => ({
  emitFeedback: vi.fn(),
  emitHookStart: vi.fn(),
  emitHookEnd: vi.fn(),
  emitHookSystemMessage: vi.fn(),
}));

vi.mock('../utils/debugLogger.js', () => ({
  debugLogger: mockDebugLogger,
}));

vi.mock('../utils/events.js', () => ({
  coreEvents: mockCoreEvents,
}));

vi.mock('../telemetry/clearcut-logger/clearcut-logger.js', () => ({
  ClearcutLogger: {
    getInstance: vi.fn().mockReturnValue({
      logHookCallEvent: vi.fn(),
    }),
  },
}));

describe('HookEventHandler', () => {
  let hookEventHandler: HookEventHandler;
  let mockConfig: Config;
  let mockHookPlanner: HookPlanner;
  let mockHookRunner: HookRunner;
  let mockHookAggregator: HookAggregator;

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

    const mockGeminiClient = {
      getChatRecordingService: vi.fn().mockReturnValue({
        getConversationFilePath: vi
          .fn()
          .mockReturnValue('/test/project/.gemini/tmp/chats/session.json'),
      }),
    };

    mockConfig = {
      get config() {
        return this;
      },
      geminiClient: mockGeminiClient,
      getGeminiClient: vi.fn().mockReturnValue(mockGeminiClient),
      getSessionId: vi.fn().mockReturnValue('test-session'),
      getWorkingDir: vi.fn().mockReturnValue('/test/project'),
    } as unknown as Config;

    mockHookPlanner = {
      createExecutionPlan: vi.fn(),
    } as unknown as HookPlanner;

    mockHookRunner = {
      executeHooksParallel: vi.fn(),
      executeHooksSequential: vi.fn(),
    } as unknown as HookRunner;

    mockHookAggregator = {
      aggregateResults: vi.fn(),
    } as unknown as HookAggregator;

    hookEventHandler = new HookEventHandler(
      mockConfig,
      mockHookPlanner,
      mockHookRunner,
      mockHookAggregator,
    );
  });

  describe('fireBeforeToolEvent', () => {
    it('should fire BeforeTool event with correct input', async () => {
      const mockPlan = [
        {
          hookConfig: {
            type: HookType.Command,
            command: './test.sh',
          } as unknown as HookConfig,
          eventName: HookEventName.BeforeTool,
        },
      ];
      const mockResults: HookExecutionResult[] = [
        {
          success: true,
          duration: 100,
          hookConfig: {
            type: HookType.Command,
            command: './test.sh',
            timeout: 30000,
          },
          eventName: HookEventName.BeforeTool,
        },
      ];
      const mockAggregated = {
        success: true,
        allOutputs: [],
        errors: [],
        totalDuration: 100,
      };

      vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue({
        eventName: HookEventName.BeforeTool,
        hookConfigs: mockPlan.map((p) => p.hookConfig),
        sequential: false,
      });
      vi.mocked(mockHookRunner.executeHooksParallel).mockResolvedValue(
        mockResults,
      );
      vi.mocked(mockHookAggregator.aggregateResults).mockReturnValue(
        mockAggregated,
      );

      const result = await hookEventHandler.fireBeforeToolEvent('EditTool', {
        file: 'test.txt',
      });

      expect(mockHookPlanner.createExecutionPlan).toHaveBeenCalledWith(
        HookEventName.BeforeTool,
        { toolName: 'EditTool' },
      );

      expect(mockHookRunner.executeHooksParallel).toHaveBeenCalledWith(
        [mockPlan[0].hookConfig],
        HookEventName.BeforeTool,
        expect.objectContaining({
          session_id: 'test-session',
          cwd: '/test/project',
          hook_event_name: 'BeforeTool',
          tool_name: 'EditTool',
          tool_input: { file: 'test.txt' },
        }),
        expect.any(Function),
        expect.any(Function),
      );

      // Verify event emission via callbacks
      const onHookStart = vi.mocked(mockHookRunner.executeHooksParallel).mock
        .calls[0][3];
      const onHookEnd = vi.mocked(mockHookRunner.executeHooksParallel).mock
        .calls[0][4];

      if (onHookStart) onHookStart(mockPlan[0].hookConfig, 0);
      expect(mockCoreEvents.emitHookStart).toHaveBeenCalledWith({
        hookName: './test.sh',
        eventName: HookEventName.BeforeTool,
        hookIndex: 1,
        totalHooks: 1,
      });

      if (onHookEnd) onHookEnd(mockPlan[0].hookConfig, mockResults[0]);
      expect(mockCoreEvents.emitHookEnd).toHaveBeenCalledWith({
        hookName: './test.sh',
        eventName: HookEventName.BeforeTool,
        success: true,
      });

      expect(result).toBe(mockAggregated);
    });

    it('should return empty result when no hooks to execute', async () => {
      vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue(null);

      const result = await hookEventHandler.fireBeforeToolEvent('EditTool', {});

      expect(result.success).toBe(true);
      expect(result.allOutputs).toHaveLength(0);
      expect(result.errors).toHaveLength(0);
      expect(result.totalDuration).toBe(0);
    });

    it('should handle execution errors gracefully', async () => {
      vi.mocked(mockHookPlanner.createExecutionPlan).mockImplementation(() => {
        throw new Error('Planning failed');
      });

      const result = await hookEventHandler.fireBeforeToolEvent('EditTool', {});

      expect(result.success).toBe(false);
      expect(result.errors).toHaveLength(1);
      expect(result.errors[0].message).toBe('Planning failed');
      expect(mockDebugLogger.error).toHaveBeenCalled();
    });

    it('should emit feedback when some hooks fail', async () => {
      const mockPlan = [
        {
          type: HookType.Command,
          command: './fail.sh',
        } as HookConfig,
      ];
      const mockResults: HookExecutionResult[] = [
        {
          success: false,
          duration: 50,
          hookConfig: mockPlan[0],
          eventName: HookEventName.BeforeTool,
          error: new Error('Failed to execute'),
        },
      ];
      const mockAggregated = {
        success: false,
        allOutputs: [],
        errors: [new Error('Failed to execute')],
        totalDuration: 50,
      };

      vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue({
        eventName: HookEventName.BeforeTool,
        hookConfigs: mockPlan,
        sequential: false,
      });
      vi.mocked(mockHookRunner.executeHooksParallel).mockResolvedValue(
        mockResults,
      );
      vi.mocked(mockHookAggregator.aggregateResults).mockReturnValue(
        mockAggregated,
      );

      await hookEventHandler.fireBeforeToolEvent('EditTool', {});

      expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith(
        'warning',
        expect.stringContaining('./fail.sh'),
      );
      expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith(
        'warning',
        expect.stringContaining('F12'),
      );
    });

    it('should fire BeforeTool event with MCP context when provided', async () => {
      const mockPlan = [
        {
          hookConfig: {
            type: HookType.Command,
            command: './test.sh',
          } as unknown as HookConfig,
          eventName: HookEventName.BeforeTool,
        },
      ];
      const mockResults: HookExecutionResult[] = [
        {
          success: true,
          duration: 100,
          hookConfig: {
            type: HookType.Command,
            command: './test.sh',
            timeout: 30000,
          },
          eventName: HookEventName.BeforeTool,
        },
      ];
      const mockAggregated = {
        success: true,
        allOutputs: [],
        errors: [],
        totalDuration: 100,
      };

      vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue({
        eventName: HookEventName.BeforeTool,
        hookConfigs: mockPlan.map((p) => p.hookConfig),
        sequential: false,
      });
      vi.mocked(mockHookRunner.executeHooksParallel).mockResolvedValue(
        mockResults,
      );
      vi.mocked(mockHookAggregator.aggregateResults).mockReturnValue(
        mockAggregated,
      );

      const mcpContext = {
        server_name: 'my-mcp-server',
        tool_name: 'read_file',
        command: 'npx',
        args: ['-y', '@my-org/mcp-server'],
      };

      const result = await hookEventHandler.fireBeforeToolEvent(
        'my-mcp-server__read_file',
        { path: '/etc/passwd' },
        mcpContext,
      );

      expect(mockHookRunner.executeHooksParallel).toHaveBeenCalledWith(
        [mockPlan[0].hookConfig],
        HookEventName.BeforeTool,
        expect.objectContaining({
          session_id: 'test-session',
          cwd: '/test/project',
          hook_event_name: 'BeforeTool',
          tool_name: 'my-mcp-server__read_file',
          tool_input: { path: '/etc/passwd' },
          mcp_context: mcpContext,
        }),
        expect.any(Function),
        expect.any(Function),
      );

      expect(result).toBe(mockAggregated);
    });

    it('should not include mcp_context when not provided', async () => {
      const mockPlan = [
        {
          hookConfig: {
            type: HookType.Command,
            command: './test.sh',
          } as unknown as HookConfig,
          eventName: HookEventName.BeforeTool,
        },
      ];
      const mockResults: HookExecutionResult[] = [
        {
          success: true,
          duration: 100,
          hookConfig: {
            type: HookType.Command,
            command: './test.sh',
            timeout: 30000,
          },
          eventName: HookEventName.BeforeTool,
        },
      ];
      const mockAggregated = {
        success: true,
        allOutputs: [],
        errors: [],
        totalDuration: 100,
      };

      vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue({
        eventName: HookEventName.BeforeTool,
        hookConfigs: mockPlan.map((p) => p.hookConfig),
        sequential: false,
      });
      vi.mocked(mockHookRunner.executeHooksParallel).mockResolvedValue(
        mockResults,
      );
      vi.mocked(mockHookAggregator.aggregateResults).mockReturnValue(
        mockAggregated,
      );

      await hookEventHandler.fireBeforeToolEvent('EditTool', {
        file: 'test.txt',
      });

      const callArgs = vi.mocked(mockHookRunner.executeHooksParallel).mock
        .calls[0][2];
      expect(callArgs).not.toHaveProperty('mcp_context');
    });
  });

  describe('fireAfterToolEvent', () => {
    it('should fire AfterTool event with tool response', async () => {
      const mockPlan = [
        {
          hookConfig: {
            type: HookType.Command,
            command: './after.sh',
          } as unknown as HookConfig,
          eventName: HookEventName.AfterTool,
        },
      ];
      const mockResults: HookExecutionResult[] = [
        {
          success: true,
          duration: 100,
          hookConfig: {
            type: HookType.Command,
            command: './test.sh',
            timeout: 30000,
          },
          eventName: HookEventName.BeforeTool,
        },
      ];
      const mockAggregated = {
        success: true,
        allOutputs: [],
        errors: [],
        totalDuration: 100,
      };

      vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue({
        eventName: HookEventName.BeforeTool,
        hookConfigs: mockPlan.map((p) => p.hookConfig),
        sequential: false,
      });
      vi.mocked(mockHookRunner.executeHooksParallel).mockResolvedValue(
        mockResults,
      );
      vi.mocked(mockHookAggregator.aggregateResults).mockReturnValue(
        mockAggregated,
      );

      const toolInput = { file: 'test.txt' };
      const toolResponse = { success: true, content: 'File edited' };

      const result = await hookEventHandler.fireAfterToolEvent(
        'EditTool',
        toolInput,
        toolResponse,
      );

      expect(mockHookRunner.executeHooksParallel).toHaveBeenCalledWith(
        [mockPlan[0].hookConfig],
        HookEventName.AfterTool,
        expect.objectContaining({
          tool_name: 'EditTool',
          tool_input: toolInput,
          tool_response: toolResponse,
        }),
        expect.any(Function),
        expect.any(Function),
      );

      expect(result).toBe(mockAggregated);
    });

    it('should fire AfterTool event with MCP context when provided', async () => {
      const mockPlan = [
        {
          hookConfig: {
            type: HookType.Command,
            command: './after.sh',
          } as unknown as HookConfig,
          eventName: HookEventName.AfterTool,
        },
      ];
      const mockResults: HookExecutionResult[] = [
        {
          success: true,
          duration: 100,
          hookConfig: {
            type: HookType.Command,
            command: './after.sh',
            timeout: 30000,
          },
          eventName: HookEventName.AfterTool,
        },
      ];
      const mockAggregated = {
        success: true,
        allOutputs: [],
        errors: [],
        totalDuration: 100,
      };

      vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue({
        eventName: HookEventName.AfterTool,
        hookConfigs: mockPlan.map((p) => p.hookConfig),
        sequential: false,
      });
      vi.mocked(mockHookRunner.executeHooksParallel).mockResolvedValue(
        mockResults,
      );
      vi.mocked(mockHookAggregator.aggregateResults).mockReturnValue(
        mockAggregated,
      );

      const toolInput = { path: '/etc/passwd' };
      const toolResponse = { success: true, content: 'File content' };
      const mcpContext = {
        server_name: 'my-mcp-server',
        tool_name: 'read_file',
        url: 'https://mcp.example.com',
      };

      const result = await hookEventHandler.fireAfterToolEvent(
        'my-mcp-server__read_file',
        toolInput,
        toolResponse,
        mcpContext,
      );

      expect(mockHookRunner.executeHooksParallel).toHaveBeenCalledWith(
        [mockPlan[0].hookConfig],
        HookEventName.AfterTool,
        expect.objectContaining({
          tool_name: 'my-mcp-server__read_file',
          tool_input: toolInput,
          tool_response: toolResponse,
          mcp_context: mcpContext,
        }),
        expect.any(Function),
        expect.any(Function),
      );

      expect(result).toBe(mockAggregated);
    });
  });

  describe('fireBeforeAgentEvent', () => {
    it('should fire BeforeAgent event with prompt', async () => {
      const mockPlan = [
        {
          hookConfig: {
            type: HookType.Command,
            command: './before_agent.sh',
          } as unknown as HookConfig,
          eventName: HookEventName.BeforeAgent,
        },
      ];
      const mockResults: HookExecutionResult[] = [
        {
          success: true,
          duration: 100,
          hookConfig: {
            type: HookType.Command,
            command: './test.sh',
            timeout: 30000,
          },
          eventName: HookEventName.BeforeTool,
        },
      ];
      const mockAggregated = {
        success: true,
        allOutputs: [],
        errors: [],
        totalDuration: 100,
      };

      vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue({
        eventName: HookEventName.BeforeTool,
        hookConfigs: mockPlan.map((p) => p.hookConfig),
        sequential: false,
      });
      vi.mocked(mockHookRunner.executeHooksParallel).mockResolvedValue(
        mockResults,
      );
      vi.mocked(mockHookAggregator.aggregateResults).mockReturnValue(
        mockAggregated,
      );

      const prompt = 'Please help me with this task';

      const result = await hookEventHandler.fireBeforeAgentEvent(prompt);

      expect(mockHookRunner.executeHooksParallel).toHaveBeenCalledWith(
        [mockPlan[0].hookConfig],
        HookEventName.BeforeAgent,
        expect.objectContaining({
          prompt,
        }),
        expect.any(Function),
        expect.any(Function),
      );

      expect(result).toBe(mockAggregated);
    });
  });

  describe('fireNotificationEvent', () => {
    it('should fire Notification event', async () => {
      const mockPlan = [
        {
          hookConfig: {
            type: HookType.Command,
            command: './notification-hook.sh',
          } as HookConfig,
          eventName: HookEventName.Notification,
        },
      ];
      const mockResults: HookExecutionResult[] = [
        {
          success: true,
          duration: 50,
          hookConfig: {
            type: HookType.Command,
            command: './notification-hook.sh',
            timeout: 30000,
          },
          eventName: HookEventName.Notification,
        },
      ];
      const mockAggregated = {
        success: true,
        allOutputs: [],
        errors: [],
        totalDuration: 50,
      };

      vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue({
        eventName: HookEventName.Notification,
        hookConfigs: mockPlan.map((p) => p.hookConfig),
        sequential: false,
      });
      vi.mocked(mockHookRunner.executeHooksParallel).mockResolvedValue(
        mockResults,
      );
      vi.mocked(mockHookAggregator.aggregateResults).mockReturnValue(
        mockAggregated,
      );

      const message = 'Tool execution requires permission';

      const result = await hookEventHandler.fireNotificationEvent(
        NotificationType.ToolPermission,
        message,
        { type: 'ToolPermission', title: 'Test Permission' },
      );

      expect(mockHookRunner.executeHooksParallel).toHaveBeenCalledWith(
        [mockPlan[0].hookConfig],
        HookEventName.Notification,
        expect.objectContaining({
          notification_type: 'ToolPermission',
          details: { type: 'ToolPermission', title: 'Test Permission' },
        }),
        expect.any(Function),
        expect.any(Function),
      );

      expect(result).toBe(mockAggregated);
    });
  });

  describe('fireSessionStartEvent', () => {
    it('should fire SessionStart event with source', async () => {
      const mockPlan = [
        {
          hookConfig: {
            type: HookType.Command,
            command: './session_start.sh',
          } as unknown as HookConfig,
          eventName: HookEventName.SessionStart,
        },
      ];
      const mockResults: HookExecutionResult[] = [
        {
          success: true,
          duration: 200,
          hookConfig: {
            type: HookType.Command,
            command: './session_start.sh',
            timeout: 30000,
          },
          eventName: HookEventName.SessionStart,
        },
      ];
      const mockAggregated = {
        success: true,
        allOutputs: [],
        errors: [],
        totalDuration: 200,
      };

      vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue({
        eventName: HookEventName.SessionStart,
        hookConfigs: mockPlan.map((p) => p.hookConfig),
        sequential: false,
      });
      vi.mocked(mockHookRunner.executeHooksParallel).mockResolvedValue(
        mockResults,
      );
      vi.mocked(mockHookAggregator.aggregateResults).mockReturnValue(
        mockAggregated,
      );

      const result = await hookEventHandler.fireSessionStartEvent(
        SessionStartSource.Startup,
      );

      expect(mockHookPlanner.createExecutionPlan).toHaveBeenCalledWith(
        HookEventName.SessionStart,
        { trigger: 'startup' },
      );

      expect(mockHookRunner.executeHooksParallel).toHaveBeenCalledWith(
        [mockPlan[0].hookConfig],
        HookEventName.SessionStart,
        expect.objectContaining({
          source: 'startup',
        }),
        expect.any(Function),
        expect.any(Function),
      );

      expect(result).toBe(mockAggregated);
    });
  });

  describe('fireBeforeModelEvent', () => {
    it('should fire BeforeModel event with LLM request', async () => {
      const mockPlan = [
        {
          hookConfig: {
            type: HookType.Command,
            command: './model-hook.sh',
          } as HookConfig,
          eventName: HookEventName.BeforeModel,
        },
      ];
      const mockResults: HookExecutionResult[] = [
        {
          success: true,
          duration: 150,
          hookConfig: {
            type: HookType.Command,
            command: './model-hook.sh',
            timeout: 30000,
          },
          eventName: HookEventName.BeforeModel,
        },
      ];
      const mockAggregated = {
        success: true,
        allOutputs: [],
        errors: [],
        totalDuration: 150,
      };

      vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue({
        eventName: HookEventName.BeforeModel,
        hookConfigs: mockPlan.map((p) => p.hookConfig),
        sequential: false,
      });
      vi.mocked(mockHookRunner.executeHooksParallel).mockResolvedValue(
        mockResults,
      );
      vi.mocked(mockHookAggregator.aggregateResults).mockReturnValue(
        mockAggregated,
      );

      const llmRequest = {
        model: 'gemini-pro',
        config: { temperature: 0.7 },
        contents: [{ role: 'user', parts: [{ text: 'Hello' }] }],
      };

      const result = await hookEventHandler.fireBeforeModelEvent(llmRequest);

      expect(mockHookRunner.executeHooksParallel).toHaveBeenCalledWith(
        [mockPlan[0].hookConfig],
        HookEventName.BeforeModel,
        expect.objectContaining({
          llm_request: expect.objectContaining({
            model: 'gemini-pro',
            messages: expect.arrayContaining([
              expect.objectContaining({
                role: 'user',
                content: 'Hello',
              }),
            ]),
          }),
        }),
        expect.any(Function),
        expect.any(Function),
      );

      expect(result).toBe(mockAggregated);
    });
  });

  describe('failure suppression', () => {
    it('should suppress duplicate feedback for the same failing hook and request context', async () => {
      const mockHook: HookConfig = {
        type: HookType.Command,
        command: './fail.sh',
        name: 'failing-hook',
      };
      const mockResults: HookExecutionResult[] = [
        {
          success: false,
          duration: 10,
          hookConfig: mockHook,
          eventName: HookEventName.AfterModel,
          error: new Error('Failed'),
        },
      ];
      const mockAggregated = {
        success: false,
        allOutputs: [],
        errors: [new Error('Failed')],
        totalDuration: 10,
      };

      vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue({
        eventName: HookEventName.AfterModel,
        hookConfigs: [mockHook],
        sequential: false,
      });
      vi.mocked(mockHookRunner.executeHooksParallel).mockResolvedValue(
        mockResults,
      );
      vi.mocked(mockHookAggregator.aggregateResults).mockReturnValue(
        mockAggregated,
      );

      const llmRequest = { model: 'test', contents: [] };
      const llmResponse = { candidates: [] };

      // First call - should emit feedback
      await hookEventHandler.fireAfterModelEvent(
        llmRequest as unknown as GenerateContentParameters,
        llmResponse as unknown as GenerateContentResponse,
      );
      expect(mockCoreEvents.emitFeedback).toHaveBeenCalledTimes(1);

      // Second call with SAME request - should NOT emit feedback
      await hookEventHandler.fireAfterModelEvent(
        llmRequest as unknown as GenerateContentParameters,
        llmResponse as unknown as GenerateContentResponse,
      );
      expect(mockCoreEvents.emitFeedback).toHaveBeenCalledTimes(1);

      // Third call with DIFFERENT request - should emit feedback again
      const differentRequest = { model: 'different', contents: [] };
      await hookEventHandler.fireAfterModelEvent(
        differentRequest as unknown as GenerateContentParameters,
        llmResponse as unknown as GenerateContentResponse,
      );
      expect(mockCoreEvents.emitFeedback).toHaveBeenCalledTimes(2);
    });
  });

  describe('createBaseInput', () => {
    it('should create base input with correct fields', async () => {
      const mockPlan = [
        {
          hookConfig: {
            type: HookType.Command,
            command: './test.sh',
          } as unknown as HookConfig,
          eventName: HookEventName.BeforeTool,
        },
      ];

      vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue({
        eventName: HookEventName.BeforeTool,
        hookConfigs: mockPlan.map((p) => p.hookConfig),
        sequential: false,
      });
      vi.mocked(mockHookRunner.executeHooksParallel).mockResolvedValue([]);
      vi.mocked(mockHookAggregator.aggregateResults).mockReturnValue({
        success: true,
        allOutputs: [],
        errors: [],
        totalDuration: 0,
      });

      await hookEventHandler.fireBeforeToolEvent('TestTool', {});

      expect(mockHookRunner.executeHooksParallel).toHaveBeenCalledWith(
        expect.any(Array),
        HookEventName.BeforeTool,
        expect.objectContaining({
          session_id: 'test-session',
          transcript_path: '/test/project/.gemini/tmp/chats/session.json',
          cwd: '/test/project',
          hook_event_name: 'BeforeTool',
          timestamp: expect.any(String),
        }),
        expect.any(Function),
        expect.any(Function),
      );
    });
  });

  describe('systemMessage event emission', () => {
    const buildMocks = (
      outputFormat: 'json' | 'text',
      systemMessage: string,
    ) => {
      const hookConfig: HookConfig = {
        type: HookType.Command,
        command: './hook.sh',
        timeout: 30000,
      };
      const results: HookExecutionResult[] = [
        {
          success: true,
          duration: 10,
          hookConfig,
          eventName: HookEventName.SessionStart,
          output: { systemMessage },
          outputFormat,
        },
      ];
      vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue({
        eventName: HookEventName.SessionStart,
        hookConfigs: [hookConfig],
        sequential: false,
      });
      vi.mocked(mockHookRunner.executeHooksParallel).mockResolvedValue(results);
      vi.mocked(mockHookAggregator.aggregateResults).mockReturnValue({
        success: true,
        allOutputs: [],
        errors: [],
        totalDuration: 10,
      });
    };

    it('emits HookSystemMessage for json-format hook output', async () => {
      buildMocks('json', 'json banner');

      await hookEventHandler.fireSessionStartEvent(SessionStartSource.Startup);

      expect(mockCoreEvents.emitHookSystemMessage).toHaveBeenCalledTimes(1);
      expect(mockCoreEvents.emitHookSystemMessage).toHaveBeenCalledWith(
        expect.objectContaining({
          eventName: HookEventName.SessionStart,
          message: 'json banner',
        }),
      );
    });

    it('emits HookSystemMessage for text-format hook output', async () => {
      buildMocks('text', 'plain-text banner');

      await hookEventHandler.fireSessionStartEvent(SessionStartSource.Startup);

      expect(mockCoreEvents.emitHookSystemMessage).toHaveBeenCalledTimes(1);
      expect(mockCoreEvents.emitHookSystemMessage).toHaveBeenCalledWith(
        expect.objectContaining({
          eventName: HookEventName.SessionStart,
          message: 'plain-text banner',
        }),
      );
    });

    it('does not emit when systemMessage is absent', async () => {
      const hookConfig: HookConfig = {
        type: HookType.Command,
        command: './hook.sh',
        timeout: 30000,
      };
      vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue({
        eventName: HookEventName.SessionStart,
        hookConfigs: [hookConfig],
        sequential: false,
      });
      vi.mocked(mockHookRunner.executeHooksParallel).mockResolvedValue([
        {
          success: true,
          duration: 10,
          hookConfig,
          eventName: HookEventName.SessionStart,
          output: {},
          outputFormat: 'json',
        },
      ]);
      vi.mocked(mockHookAggregator.aggregateResults).mockReturnValue({
        success: true,
        allOutputs: [],
        errors: [],
        totalDuration: 10,
      });

      await hookEventHandler.fireSessionStartEvent(SessionStartSource.Startup);

      expect(mockCoreEvents.emitHookSystemMessage).not.toHaveBeenCalled();
    });
  });
});
