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

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import { HookRegistry } from './hookRegistry.js';
import type { Storage } from '../config/storage.js';
import {
  ConfigSource,
  HookEventName,
  HookType,
  HOOKS_CONFIG_FIELDS,
  type CommandHookConfig,
  type HookDefinition,
} from './types.js';
import type { Config } from '../config/config.js';

// Mock fs
vi.mock('fs', () => ({
  existsSync: vi.fn(),
  readFileSync: vi.fn(),
}));

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

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

const { mockTrustedHooksManager, mockCoreEvents } = vi.hoisted(() => ({
  mockTrustedHooksManager: {
    getUntrustedHooks: vi.fn().mockReturnValue([]),
    trustHooks: vi.fn(),
  },
  mockCoreEvents: {
    emitConsoleLog: vi.fn(),
    emitFeedback: vi.fn(),
  },
}));

vi.mock('./trustedHooks.js', () => ({
  TrustedHooksManager: vi.fn(() => mockTrustedHooksManager),
}));

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

describe('HookRegistry', () => {
  let hookRegistry: HookRegistry;
  let mockConfig: Config;
  let mockStorage: Storage;

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

    mockStorage = {
      getGeminiDir: vi.fn().mockReturnValue('/project/.gemini'),
    } as unknown as Storage;

    mockConfig = {
      storage: mockStorage,
      getExtensions: vi.fn().mockReturnValue([]),
      getHooks: vi.fn().mockReturnValue({}),
      getProjectHooks: vi.fn().mockReturnValue({}),
      getDisabledHooks: vi.fn().mockReturnValue([]),
      isTrustedFolder: vi.fn().mockReturnValue(true),
      getProjectRoot: vi.fn().mockReturnValue('/project'),
    } as unknown as Config;

    hookRegistry = new HookRegistry(mockConfig);
  });

  afterEach(() => {
    vi.restoreAllMocks();
  });

  describe('initialize', () => {
    it('should initialize successfully with no hooks', async () => {
      vi.mocked(fs.existsSync).mockReturnValue(false);

      await hookRegistry.initialize();

      expect(hookRegistry.getAllHooks()).toHaveLength(0);
      expect(mockDebugLogger.debug).toHaveBeenCalledWith(
        'Hook registry initialized with 0 hook entries',
      );
    });

    it('should not load hooks if folder is not trusted', async () => {
      vi.mocked(mockConfig.isTrustedFolder).mockReturnValue(false);
      const mockHooksConfig = {
        BeforeTool: [
          {
            hooks: [
              {
                type: 'command',
                command: './hooks/test.sh',
              },
            ],
          },
        ],
      };

      vi.mocked(mockConfig.getHooks).mockReturnValue(
        mockHooksConfig as unknown as {
          [K in HookEventName]?: HookDefinition[];
        },
      );

      await hookRegistry.initialize();

      expect(hookRegistry.getAllHooks()).toHaveLength(0);
      expect(mockDebugLogger.warn).toHaveBeenCalledWith(
        'Project hooks disabled because the folder is not trusted.',
      );
    });

    it('should load hooks from project configuration', async () => {
      const mockHooksConfig = {
        BeforeTool: [
          {
            matcher: 'EditTool',
            hooks: [
              {
                type: 'command',
                command: './hooks/check_style.sh',
                timeout: 60,
              },
            ],
          },
        ],
      };

      // Update mock to return the hooks configuration
      vi.mocked(mockConfig.getHooks).mockReturnValue(
        mockHooksConfig as unknown as {
          [K in HookEventName]?: HookDefinition[];
        },
      );

      await hookRegistry.initialize();

      const hooks = hookRegistry.getAllHooks();
      expect(hooks).toHaveLength(1);
      expect(hooks[0].eventName).toBe(HookEventName.BeforeTool);
      expect(hooks[0].config.type).toBe(HookType.Command);
      expect((hooks[0].config as CommandHookConfig).command).toBe(
        './hooks/check_style.sh',
      );
      expect(hooks[0].matcher).toBe('EditTool');
      expect(hooks[0].source).toBe(ConfigSource.Project);
    });

    it('should load plugin hooks', async () => {
      const mockHooksConfig = {
        AfterTool: [
          {
            hooks: [
              {
                type: 'command',
                command: './hooks/after-tool.sh',
                timeout: 30,
              },
            ],
          },
        ],
      };

      // Update mock to return the hooks configuration
      vi.mocked(mockConfig.getHooks).mockReturnValue(
        mockHooksConfig as unknown as {
          [K in HookEventName]?: HookDefinition[];
        },
      );

      await hookRegistry.initialize();

      const hooks = hookRegistry.getAllHooks();
      expect(hooks).toHaveLength(1);
      expect(hooks[0].eventName).toBe(HookEventName.AfterTool);
      expect(hooks[0].config.type).toBe(HookType.Command);
      expect((hooks[0].config as CommandHookConfig).command).toBe(
        './hooks/after-tool.sh',
      );
    });

    it('should handle invalid configuration gracefully', async () => {
      const invalidHooksConfig = {
        BeforeTool: [
          {
            hooks: [
              {
                type: 'invalid-type', // Invalid hook type
                command: './hooks/test.sh',
              },
            ],
          },
        ],
      };

      // Update mock to return invalid configuration
      vi.mocked(mockConfig.getHooks).mockReturnValue(
        invalidHooksConfig as unknown as {
          [K in HookEventName]?: HookDefinition[];
        },
      );

      await hookRegistry.initialize();

      expect(hookRegistry.getAllHooks()).toHaveLength(0);
      expect(mockDebugLogger.warn).toHaveBeenCalled();
    });

    it('should validate hook configurations', async () => {
      const mockHooksConfig = {
        BeforeTool: [
          {
            hooks: [
              {
                type: 'invalid',
                command: './hooks/test.sh',
              },
              {
                type: 'command',
                // Missing command field
              },
            ],
          },
        ],
      };

      // Update mock to return invalid configuration
      vi.mocked(mockConfig.getHooks).mockReturnValue(
        mockHooksConfig as unknown as {
          [K in HookEventName]?: HookDefinition[];
        },
      );

      await hookRegistry.initialize();

      expect(hookRegistry.getAllHooks()).toHaveLength(0);
      expect(mockDebugLogger.warn).toHaveBeenCalled(); // At least some warnings should be logged
    });

    it('should respect disabled hooks using friendly name', async () => {
      const mockHooksConfig = {
        BeforeTool: [
          {
            hooks: [
              {
                name: 'disabled-hook',
                type: 'command',
                command: './hooks/test.sh',
              },
            ],
          },
        ],
      };

      // Update mock to return the hooks configuration
      vi.mocked(mockConfig.getHooks).mockReturnValue(
        mockHooksConfig as unknown as {
          [K in HookEventName]?: HookDefinition[];
        },
      );
      vi.mocked(mockConfig.getDisabledHooks).mockReturnValue(['disabled-hook']);

      await hookRegistry.initialize();

      const hooks = hookRegistry.getAllHooks();
      expect(hooks).toHaveLength(1);
      expect(hooks[0].enabled).toBe(false);
      expect(
        hookRegistry.getHooksForEvent(HookEventName.BeforeTool),
      ).toHaveLength(0);
    });
  });

  describe('getHooksForEvent', () => {
    beforeEach(async () => {
      const mockHooksConfig = {
        BeforeTool: [
          {
            matcher: 'EditTool',
            hooks: [
              {
                type: 'command',
                command: './hooks/edit_check.sh',
              },
            ],
          },
          {
            hooks: [
              {
                type: 'command',
                command: './hooks/general_check.sh',
              },
            ],
          },
        ],
        AfterTool: [
          {
            hooks: [
              {
                type: 'command',
                command: './hooks/after-tool.sh',
              },
            ],
          },
        ],
      };

      // Update mock to return the hooks configuration
      vi.mocked(mockConfig.getHooks).mockReturnValue(
        mockHooksConfig as unknown as {
          [K in HookEventName]?: HookDefinition[];
        },
      );

      await hookRegistry.initialize();
    });

    it('should return hooks for specific event', () => {
      const beforeToolHooks = hookRegistry.getHooksForEvent(
        HookEventName.BeforeTool,
      );
      expect(beforeToolHooks).toHaveLength(2);

      const afterToolHooks = hookRegistry.getHooksForEvent(
        HookEventName.AfterTool,
      );
      expect(afterToolHooks).toHaveLength(1);
    });

    it('should return empty array for events with no hooks', () => {
      const notificationHooks = hookRegistry.getHooksForEvent(
        HookEventName.Notification,
      );
      expect(notificationHooks).toHaveLength(0);
    });
  });

  describe('setHookEnabled', () => {
    beforeEach(async () => {
      const mockHooksConfig = {
        BeforeTool: [
          {
            hooks: [
              {
                type: 'command',
                command: './hooks/test.sh',
              },
            ],
          },
        ],
      };

      // Update mock to return the hooks configuration
      vi.mocked(mockConfig.getHooks).mockReturnValue(
        mockHooksConfig as unknown as {
          [K in HookEventName]?: HookDefinition[];
        },
      );

      await hookRegistry.initialize();
    });

    it('should enable and disable hooks', () => {
      const hookName = './hooks/test.sh';

      // Initially enabled
      let hooks = hookRegistry.getHooksForEvent(HookEventName.BeforeTool);
      expect(hooks).toHaveLength(1);

      // Disable
      hookRegistry.setHookEnabled(hookName, false);
      hooks = hookRegistry.getHooksForEvent(HookEventName.BeforeTool);
      expect(hooks).toHaveLength(0);

      // Re-enable
      hookRegistry.setHookEnabled(hookName, true);
      hooks = hookRegistry.getHooksForEvent(HookEventName.BeforeTool);
      expect(hooks).toHaveLength(1);
    });

    it('should warn when hook not found', () => {
      hookRegistry.setHookEnabled('non-existent-hook', false);
      expect(mockDebugLogger.warn).toHaveBeenCalledWith(
        'No hooks found matching "non-existent-hook"',
      );
    });

    it('should prefer hook name over command for identification', async () => {
      const mockHooksConfig = {
        BeforeTool: [
          {
            hooks: [
              {
                name: 'friendly-name',
                type: 'command',
                command: './hooks/test.sh',
              },
            ],
          },
        ],
      };

      vi.mocked(mockConfig.getHooks).mockReturnValue(
        mockHooksConfig as unknown as {
          [K in HookEventName]?: HookDefinition[];
        },
      );

      await hookRegistry.initialize();

      // Should be enabled initially
      let hooks = hookRegistry.getHooksForEvent(HookEventName.BeforeTool);
      expect(hooks).toHaveLength(1);

      // Disable using friendly name
      hookRegistry.setHookEnabled('friendly-name', false);
      hooks = hookRegistry.getHooksForEvent(HookEventName.BeforeTool);
      expect(hooks).toHaveLength(0);

      // Identification by command should NOT work when name is present
      hookRegistry.setHookEnabled('./hooks/test.sh', true);
      expect(mockDebugLogger.warn).toHaveBeenCalledWith(
        'No hooks found matching "./hooks/test.sh"',
      );
    });

    it('should use command as identifier when name is missing', async () => {
      const mockHooksConfig = {
        BeforeTool: [
          {
            hooks: [
              {
                type: 'command',
                command: './hooks/no-name.sh',
              },
            ],
          },
        ],
      };

      vi.mocked(mockConfig.getHooks).mockReturnValue(
        mockHooksConfig as unknown as {
          [K in HookEventName]?: HookDefinition[];
        },
      );

      await hookRegistry.initialize();

      // Should be enabled initially
      let hooks = hookRegistry.getHooksForEvent(HookEventName.BeforeTool);
      expect(hooks).toHaveLength(1);

      // Disable using command
      hookRegistry.setHookEnabled('./hooks/no-name.sh', false);
      hooks = hookRegistry.getHooksForEvent(HookEventName.BeforeTool);
      expect(hooks).toHaveLength(0);
    });
  });

  describe('malformed configuration handling', () => {
    it('should handle non-array definitions gracefully', async () => {
      const malformedConfig = {
        BeforeTool: 'not-an-array', // Should be an array of HookDefinition
      };

      vi.mocked(mockConfig.getHooks).mockReturnValue(
        malformedConfig as unknown as {
          [K in HookEventName]?: HookDefinition[];
        },
      );

      await hookRegistry.initialize();

      expect(hookRegistry.getAllHooks()).toHaveLength(0);
      expect(mockDebugLogger.warn).toHaveBeenCalledWith(
        expect.stringContaining('is not an array'),
      );
    });

    it('should handle object instead of array for definitions', async () => {
      const malformedConfig = {
        AfterTool: { hooks: [] }, // Should be an array, not a single object
      };

      vi.mocked(mockConfig.getHooks).mockReturnValue(
        malformedConfig as unknown as {
          [K in HookEventName]?: HookDefinition[];
        },
      );

      await hookRegistry.initialize();

      expect(hookRegistry.getAllHooks()).toHaveLength(0);
      expect(mockDebugLogger.warn).toHaveBeenCalledWith(
        expect.stringContaining('is not an array'),
      );
    });

    it('should handle null definition gracefully', async () => {
      const malformedConfig = {
        BeforeTool: [null], // Invalid: null definition
      };

      vi.mocked(mockConfig.getHooks).mockReturnValue(
        malformedConfig as unknown as {
          [K in HookEventName]?: HookDefinition[];
        },
      );

      await hookRegistry.initialize();

      expect(hookRegistry.getAllHooks()).toHaveLength(0);
      expect(mockDebugLogger.warn).toHaveBeenCalledWith(
        expect.stringContaining('Discarding invalid hook definition'),
        null,
      );
    });

    it('should handle definition without hooks array', async () => {
      const malformedConfig = {
        BeforeTool: [
          {
            matcher: 'EditTool',
            // Missing hooks array
          },
        ],
      };

      vi.mocked(mockConfig.getHooks).mockReturnValue(
        malformedConfig as unknown as {
          [K in HookEventName]?: HookDefinition[];
        },
      );

      await hookRegistry.initialize();

      expect(hookRegistry.getAllHooks()).toHaveLength(0);
      expect(mockDebugLogger.warn).toHaveBeenCalledWith(
        expect.stringContaining('Discarding invalid hook definition'),
        expect.objectContaining({ matcher: 'EditTool' }),
      );
    });

    it('should handle non-array hooks property', async () => {
      const malformedConfig = {
        BeforeTool: [
          {
            matcher: 'EditTool',
            hooks: 'not-an-array', // Should be an array
          },
        ],
      };

      vi.mocked(mockConfig.getHooks).mockReturnValue(
        malformedConfig as unknown as {
          [K in HookEventName]?: HookDefinition[];
        },
      );

      await hookRegistry.initialize();

      expect(hookRegistry.getAllHooks()).toHaveLength(0);
      expect(mockDebugLogger.warn).toHaveBeenCalledWith(
        expect.stringContaining('Discarding invalid hook definition'),
        expect.objectContaining({ hooks: 'not-an-array', matcher: 'EditTool' }),
      );
    });

    it('should handle non-object hookConfig in hooks array', async () => {
      const malformedConfig = {
        BeforeTool: [
          {
            hooks: [
              'not-an-object', // Should be an object
              42, // Should be an object
              null, // Should be an object
            ],
          },
        ],
      };
      mockTrustedHooksManager.getUntrustedHooks.mockReturnValue([]);

      vi.mocked(mockConfig.getHooks).mockReturnValue(
        malformedConfig as unknown as {
          [K in HookEventName]?: HookDefinition[];
        },
      );

      await hookRegistry.initialize();

      expect(hookRegistry.getAllHooks()).toHaveLength(0);
      expect(mockDebugLogger.warn).toHaveBeenCalledTimes(3); // One warning for each invalid hookConfig
    });

    it('should handle mixed valid and invalid hook configurations', async () => {
      const mixedConfig = {
        BeforeTool: [
          {
            hooks: [
              {
                type: 'command',
                command: './valid-hook.sh',
              },
              'invalid-string',
              {
                type: 'invalid-type',
                command: './invalid-type.sh',
              },
            ],
          },
        ],
      };

      vi.mocked(mockConfig.getHooks).mockReturnValue(
        mixedConfig as unknown as {
          [K in HookEventName]?: HookDefinition[];
        },
      );

      await hookRegistry.initialize();

      // Should only load the valid hook
      const hooks = hookRegistry.getAllHooks();
      expect(hooks).toHaveLength(1);
      expect((hooks[0].config as CommandHookConfig).command).toBe(
        './valid-hook.sh',
      );

      // Verify the warnings for invalid configurations
      // 1st warning: non-object hookConfig ('invalid-string')
      expect(mockDebugLogger.warn).toHaveBeenCalledWith(
        expect.stringContaining('Discarding invalid hook configuration'),
        'invalid-string',
      );
      // 2nd warning: validateHookConfig logs invalid type
      expect(mockDebugLogger.warn).toHaveBeenCalledWith(
        expect.stringContaining('Invalid hook BeforeTool from project type'),
      );
      // 3rd warning: processHookDefinition logs the failed hookConfig
      expect(mockDebugLogger.warn).toHaveBeenCalledWith(
        expect.stringContaining('Discarding invalid hook configuration'),
        expect.objectContaining({ type: 'invalid-type' }),
      );
    });

    it('should skip known config fields and warn on invalid event names', async () => {
      const configWithExtras: Record<string, unknown> = {
        InvalidEvent: [],
        BeforeTool: [
          {
            hooks: [
              {
                type: 'command',
                command: './test.sh',
              },
            ],
          },
        ],
      };

      // Add all known config fields dynamically
      for (const field of HOOKS_CONFIG_FIELDS) {
        configWithExtras[field] = field === 'disabled' ? [] : true;
      }

      vi.mocked(mockConfig.getHooks).mockReturnValue(
        configWithExtras as unknown as {
          [K in HookEventName]?: HookDefinition[];
        },
      );

      await hookRegistry.initialize();

      // Should only load the valid hook
      expect(hookRegistry.getAllHooks()).toHaveLength(1);

      // Should skip all known config fields without warnings
      for (const field of HOOKS_CONFIG_FIELDS) {
        expect(mockDebugLogger.warn).not.toHaveBeenCalledWith(
          expect.stringContaining(`Invalid hook event name: ${field}`),
        );
      }

      // Should warn on truly invalid event name
      expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith(
        'warning',
        expect.stringContaining('Invalid hook event name: "InvalidEvent"'),
      );
    });
  });

  describe('project hook warnings', () => {
    it('should check for untrusted project hooks when folder is trusted', async () => {
      const projectHooks = {
        BeforeTool: [
          {
            hooks: [
              {
                type: 'command',
                command: './hooks/untrusted.sh',
              },
            ],
          },
        ],
      };

      vi.mocked(mockConfig.getHooks).mockReturnValue(
        projectHooks as unknown as { [K in HookEventName]?: HookDefinition[] },
      );
      vi.mocked(mockConfig.getProjectHooks).mockReturnValue(
        projectHooks as unknown as { [K in HookEventName]?: HookDefinition[] },
      );

      // Simulate untrusted hooks found
      mockTrustedHooksManager.getUntrustedHooks.mockReturnValue([
        './hooks/untrusted.sh',
      ]);

      await hookRegistry.initialize();

      expect(mockTrustedHooksManager.getUntrustedHooks).toHaveBeenCalledWith(
        '/project',
        projectHooks,
      );
      expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith(
        'warning',
        expect.stringContaining(
          'WARNING: The following project-level hooks have been detected',
        ),
      );
      expect(mockTrustedHooksManager.trustHooks).toHaveBeenCalledWith(
        '/project',
        projectHooks,
      );
    });

    it('should not warn if hooks are already trusted', async () => {
      const projectHooks = {
        BeforeTool: [
          {
            hooks: [
              {
                type: 'command',
                command: './hooks/trusted.sh',
              },
            ],
          },
        ],
      };

      vi.mocked(mockConfig.getHooks).mockReturnValue(
        projectHooks as unknown as { [K in HookEventName]?: HookDefinition[] },
      );
      vi.mocked(mockConfig.getProjectHooks).mockReturnValue(
        projectHooks as unknown as { [K in HookEventName]?: HookDefinition[] },
      );

      // Simulate no untrusted hooks
      mockTrustedHooksManager.getUntrustedHooks.mockReturnValue([]);

      await hookRegistry.initialize();

      expect(mockCoreEvents.emitFeedback).not.toHaveBeenCalled();
      expect(mockTrustedHooksManager.trustHooks).not.toHaveBeenCalled();
    });

    it('should not check for untrusted hooks if folder is not trusted', async () => {
      vi.mocked(mockConfig.isTrustedFolder).mockReturnValue(false);

      await hookRegistry.initialize();

      expect(mockTrustedHooksManager.getUntrustedHooks).not.toHaveBeenCalled();
    });
  });
});
