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

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as os from 'node:os';
import * as path from 'node:path';
import * as fs from 'node:fs/promises';
import { Storage } from '@google/gemini-cli-core';
import {
  Command,
  commandCategories,
  commandDescriptions,
  defaultKeyBindingConfig,
  KeyBinding,
  loadCustomKeybindings,
} from './keyBindings.js';

describe('KeyBinding', () => {
  describe('constructor', () => {
    it('should parse a simple key', () => {
      const binding = new KeyBinding('a');
      expect(binding.name).toBe('a');
      expect(binding.ctrl).toBe(false);
      expect(binding.shift).toBe(false);
      expect(binding.alt).toBe(false);
      expect(binding.cmd).toBe(false);
    });

    it('should parse ctrl+key', () => {
      const binding = new KeyBinding('ctrl+c');
      expect(binding.name).toBe('c');
      expect(binding.ctrl).toBe(true);
    });

    it('should parse shift+key', () => {
      const binding = new KeyBinding('shift+z');
      expect(binding.name).toBe('z');
      expect(binding.shift).toBe(true);
    });

    it('should parse alt+key', () => {
      const binding = new KeyBinding('alt+left');
      expect(binding.name).toBe('left');
      expect(binding.alt).toBe(true);
    });

    it('should parse cmd+key', () => {
      const binding = new KeyBinding('cmd+f');
      expect(binding.name).toBe('f');
      expect(binding.cmd).toBe(true);
    });

    it('should handle aliases (option/opt/meta)', () => {
      const optionBinding = new KeyBinding('option+b');
      expect(optionBinding.name).toBe('b');
      expect(optionBinding.alt).toBe(true);

      const optBinding = new KeyBinding('opt+b');
      expect(optBinding.name).toBe('b');
      expect(optBinding.alt).toBe(true);

      const metaBinding = new KeyBinding('meta+enter');
      expect(metaBinding.name).toBe('enter');
      expect(metaBinding.cmd).toBe(true);
    });

    it('should parse multiple modifiers', () => {
      const binding = new KeyBinding('ctrl+shift+alt+cmd+x');
      expect(binding.name).toBe('x');
      expect(binding.ctrl).toBe(true);
      expect(binding.shift).toBe(true);
      expect(binding.alt).toBe(true);
      expect(binding.cmd).toBe(true);
    });

    it('should be case-insensitive', () => {
      const binding = new KeyBinding('CTRL+Shift+F');
      expect(binding.name).toBe('f');
      expect(binding.ctrl).toBe(true);
      expect(binding.shift).toBe(true);
    });

    it('should handle named keys with modifiers', () => {
      const binding = new KeyBinding('ctrl+enter');
      expect(binding.name).toBe('enter');
      expect(binding.ctrl).toBe(true);
    });

    it('should throw an error for invalid keys or typos in modifiers', () => {
      expect(() => new KeyBinding('ctrl+unknown')).toThrow(
        'Invalid keybinding key: "unknown" in "ctrl+unknown"',
      );
      expect(() => new KeyBinding('ctlr+a')).toThrow(
        'Invalid keybinding key: "ctlr+a" in "ctlr+a"',
      );
    });
  });
});

describe('keyBindings config', () => {
  it('should have bindings for all commands', () => {
    for (const command of Object.values(Command)) {
      expect(defaultKeyBindingConfig.has(command)).toBe(true);
      expect(defaultKeyBindingConfig.get(command)?.length).toBeGreaterThan(0);
    }
  });

  it('should have platform-specific UNDO bindings', () => {
    const undoBindings = defaultKeyBindingConfig.get(Command.UNDO);
    if (process.platform === 'win32') {
      expect(undoBindings?.[0].name).toBe('z');
      expect(undoBindings?.[0].ctrl).toBe(true);
    } else if (process.platform === 'darwin') {
      expect(undoBindings?.[0].name).toBe('z');
      expect(undoBindings?.[0].cmd).toBe(true);
    } else {
      expect(undoBindings?.[0].name).toBe('z');
      expect(undoBindings?.[0].alt).toBe(true);
      // Ensure ctrl+z is also present for smart bubbling
      expect(undoBindings?.some((b) => b.name === 'z' && b.ctrl)).toBe(true);
    }
  });

  it('should have platform-specific REDO bindings', () => {
    const redoBindings = defaultKeyBindingConfig.get(Command.REDO);
    // Ctrl+Shift+Z is now the universal primary to avoid conflict with YOLO (Ctrl+Y)
    expect(redoBindings?.[0].name).toBe('z');
    expect(redoBindings?.[0].shift).toBe(true);
    expect(redoBindings?.[0].ctrl).toBe(true);
  });

  describe('command metadata', () => {
    const commandValues = Object.values(Command);

    it('has a description entry for every command', () => {
      const describedCommands = Object.keys(commandDescriptions);
      expect(describedCommands.sort()).toEqual([...commandValues].sort());

      for (const command of commandValues) {
        expect(typeof commandDescriptions[command]).toBe('string');
        expect(commandDescriptions[command]?.trim()).not.toHaveLength(0);
      }
    });

    it('categorizes each command exactly once', () => {
      const seen = new Set<Command>();

      for (const category of commandCategories) {
        expect(typeof category.title).toBe('string');
        expect(Array.isArray(category.commands)).toBe(true);

        for (const command of category.commands) {
          expect(commandValues).toContain(command);
          expect(seen.has(command)).toBe(false);
          seen.add(command);
        }
      }

      expect(seen.size).toBe(commandValues.length);
    });
  });
});

describe('loadCustomKeybindings', () => {
  let tempDir: string;
  let tempFilePath: string;

  beforeEach(async () => {
    tempDir = await fs.mkdtemp(
      path.join(os.tmpdir(), 'gemini-keybindings-test-'),
    );
    tempFilePath = path.join(tempDir, 'keybindings.json');
    vi.spyOn(Storage, 'getUserKeybindingsPath').mockReturnValue(tempFilePath);
  });

  afterEach(async () => {
    await fs.rm(tempDir, { recursive: true, force: true });
    vi.restoreAllMocks();
  });

  it('returns default bindings when file does not exist', async () => {
    // We don't write the file.
    const { config, errors } = await loadCustomKeybindings();

    expect(errors).toHaveLength(0);
    expect(config.get(Command.RETURN)).toEqual([new KeyBinding('enter')]);
  });

  it('merges valid custom bindings, prepending them to defaults', async () => {
    const customJson = JSON.stringify([
      { command: Command.RETURN, key: 'ctrl+a' },
    ]);
    await fs.writeFile(tempFilePath, customJson, 'utf8');

    const { config, errors } = await loadCustomKeybindings();

    expect(errors).toHaveLength(0);
    expect(config.get(Command.RETURN)).toEqual([
      new KeyBinding('ctrl+a'),
      new KeyBinding('enter'),
    ]);
  });

  it('handles JSON with comments', async () => {
    const customJson = `
      [
        // This is a comment
        { "command": "${Command.QUIT}", "key": "ctrl+x" }
      ]
    `;
    await fs.writeFile(tempFilePath, customJson, 'utf8');

    const { config, errors } = await loadCustomKeybindings();

    expect(errors).toHaveLength(0);
    expect(config.get(Command.QUIT)).toEqual([
      new KeyBinding('ctrl+x'),
      new KeyBinding('ctrl+c'),
    ]);
  });

  it('returns validation errors for invalid schema', async () => {
    const invalidJson = JSON.stringify([{ command: 'unknown', key: 'a' }]);
    await fs.writeFile(tempFilePath, invalidJson, 'utf8');

    const { config, errors } = await loadCustomKeybindings();

    expect(errors.length).toBeGreaterThan(0);

    expect(errors[0]).toMatch(/error at 0.command: Invalid command: "unknown"/);
    // Should still have defaults
    expect(config.get(Command.RETURN)).toEqual([new KeyBinding('enter')]);
  });

  it('returns validation errors for invalid key patterns but loads valid ones', async () => {
    const mixedJson = JSON.stringify([
      { command: Command.RETURN, key: 'super+a' }, // invalid
      { command: Command.QUIT, key: 'ctrl+y' }, // valid
    ]);
    await fs.writeFile(tempFilePath, mixedJson, 'utf8');

    const { config, errors } = await loadCustomKeybindings();

    expect(errors.length).toBe(1);
    expect(errors[0]).toMatch(/Invalid keybinding/);
    expect(config.get(Command.QUIT)).toEqual([
      new KeyBinding('ctrl+y'),
      new KeyBinding('ctrl+c'),
    ]);
  });

  it('removes specific bindings when using the minus prefix', async () => {
    const customJson = JSON.stringify([
      { command: `-${Command.RETURN}`, key: 'enter' },
      { command: Command.RETURN, key: 'ctrl+a' },
    ]);
    await fs.writeFile(tempFilePath, customJson, 'utf8');

    const { config, errors } = await loadCustomKeybindings();

    expect(errors).toHaveLength(0);
    // 'enter' should be gone, only 'ctrl+a' should remain
    expect(config.get(Command.RETURN)).toEqual([new KeyBinding('ctrl+a')]);
  });

  it('returns an error when attempting to negate a non-existent binding', async () => {
    const customJson = JSON.stringify([
      { command: `-${Command.RETURN}`, key: 'ctrl+z' },
    ]);
    await fs.writeFile(tempFilePath, customJson, 'utf8');

    const { config, errors } = await loadCustomKeybindings();

    expect(errors.length).toBe(1);
    expect(errors[0]).toMatch(
      /Invalid keybinding for command "-basic.confirm": Error: cannot remove "ctrl\+z" since it is not bound/,
    );
    // Defaults should still be present
    expect(config.get(Command.RETURN)).toEqual([new KeyBinding('enter')]);
  });
});
