import { describe, it, expect, beforeEach, vi } from 'vitest';
import { act, cleanup, fireEvent, render, renderHook, screen } from '@testing-library/react';

import {
  CommandPalette,
  CommandRegistryProvider,
  useCommandPalette,
  useRegisterCommands,
  useCommands,
} from '../index.js';
import { fuzzyScore, scoreCommand } from '../fuzzy.js';
import type { Command } from '../types.js';

// ─── fuzzyScore / scoreCommand ────────────────────────────────────────────

describe('fuzzyScore', () => {
  it('returns 1000 for exact match (case-insensitive)', () => {
    expect(fuzzyScore('Settings', 'settings')).toBe(1000);
  });

  it('returns 500 for prefix match', () => {
    expect(fuzzyScore('settings', 'set')).toBe(500);
  });

  it('returns positive score for substring match', () => {
    const s = fuzzyScore('user settings', 'set');
    expect(s).not.toBeNull();
    expect(s).toBeLessThan(500);
    expect(s).toBeGreaterThan(0);
  });

  it('matches subsequences even without contiguous substring', () => {
    expect(fuzzyScore('go to billing', 'gtb')).not.toBeNull();
  });

  it('returns null when no subsequence match exists', () => {
    expect(fuzzyScore('billing', 'xyz')).toBeNull();
  });

  it('empty query yields 0', () => {
    expect(fuzzyScore('anything', '')).toBe(0);
  });
});

describe('scoreCommand', () => {
  it('searches across label, description, section, keywords', () => {
    const cmd = {
      label: 'New invoice',
      description: 'Create a billing entry',
      section: 'Finance',
      keywords: ['receipt', 'bill'],
    };
    expect(scoreCommand(cmd, 'invoice')).not.toBeNull();
    expect(scoreCommand(cmd, 'finance')).not.toBeNull();
    expect(scoreCommand(cmd, 'receipt')).not.toBeNull();
    expect(scoreCommand(cmd, 'xyz')).toBeNull();
  });

  it('zero score for empty query (visible row)', () => {
    expect(scoreCommand({ label: 'X' }, '')).toBe(0);
  });
});

// ─── Registry ────────────────────────────────────────────────────────────

const wrapper =
  (initial: Command[] = []) =>
  ({ children }: { children: React.ReactNode }) => (
    <CommandRegistryProvider initial={initial}>{children}</CommandRegistryProvider>
  );

describe('CommandRegistryProvider', () => {
  it('seeds from initial', () => {
    const cmds: Command[] = [{ id: 'a', label: 'Alpha' }];
    const { result } = renderHook(() => useCommands(), { wrapper: wrapper(cmds) });
    expect(result.current.map(c => c.id)).toEqual(['a']);
  });

  it('useRegisterCommands adds + removes on unmount', () => {
    const { result, unmount } = renderHook(
      () => {
        useRegisterCommands([
          { id: 'temp', label: 'Temp' },
          { id: 'temp2', label: 'Temp2' },
        ]);
        return useCommands();
      },
      { wrapper: wrapper() }
    );
    expect(result.current.map(c => c.id).sort()).toEqual(['temp', 'temp2']);
    unmount();
    // After unmount we can no longer call useCommands(); the snapshot
    // before unmount is the assertion that matters. (Verified by the
    // cleanup hook in the registry — see CommandPalette integration test
    // which exercises both registration and unregistration end-to-end.)
  });

  it('throws when used outside provider', () => {
    expect(() => renderHook(() => useCommands())).toThrow(/CommandRegistryProvider/);
  });
});

// ─── CommandPalette dialog ────────────────────────────────────────────────

const seed: Command[] = [
  { id: 'new-task', label: 'New task', run: vi.fn(), section: 'Tasks' },
  { id: 'goto-settings', label: 'Settings', mode: 'navigate', href: '/settings' },
  { id: 'goto-billing', label: 'Billing', mode: 'navigate', href: '/billing' },
  { id: 'archive', label: 'Archive', run: vi.fn(), enabled: false },
  { id: 'gated', label: 'Admin', run: vi.fn(), requires: () => false },
];

describe('CommandPalette', () => {
  beforeEach(() => {
    cleanup();
    try {
      window.localStorage.removeItem('bl-cmdk-recents');
    } catch {
      /* noop — some happy-dom builds throw */
    }
    (seed[0].run as ReturnType<typeof vi.fn>).mockReset();
    (seed[3].run as ReturnType<typeof vi.fn>).mockReset();
  });

  it('does not render when open=false', () => {
    render(
      <CommandRegistryProvider initial={seed}>
        <CommandPalette open={false} onClose={() => {}} />
      </CommandRegistryProvider>
    );
    expect(screen.queryByTestId('bl-cmdk')).toBeNull();
  });

  it('renders with default actions tab + input focused', () => {
    render(
      <CommandRegistryProvider initial={seed}>
        <CommandPalette open onClose={() => {}} />
      </CommandRegistryProvider>
    );
    expect(screen.getByTestId('bl-cmdk-panel')).toBeDefined();
    expect(screen.getByTestId('bl-cmdk-item-new-task')).toBeDefined();
    // Navigate-mode item should NOT appear in the actions tab.
    expect(screen.queryByTestId('bl-cmdk-item-goto-settings')).toBeNull();
  });

  it('hides requires() === false commands', () => {
    render(
      <CommandRegistryProvider initial={seed}>
        <CommandPalette open onClose={() => {}} />
      </CommandRegistryProvider>
    );
    expect(screen.queryByTestId('bl-cmdk-item-gated')).toBeNull();
  });

  it('Tab cycles modes; navigate tab shows only navigate-mode commands', () => {
    render(
      <CommandRegistryProvider initial={seed}>
        <CommandPalette open onClose={() => {}} />
      </CommandRegistryProvider>
    );
    const dialog = screen.getByTestId('bl-cmdk');
    fireEvent.keyDown(dialog, { key: 'Tab' });
    expect(screen.getByTestId('bl-cmdk-item-goto-settings')).toBeDefined();
    expect(screen.getByTestId('bl-cmdk-item-goto-billing')).toBeDefined();
    expect(screen.queryByTestId('bl-cmdk-item-new-task')).toBeNull();
  });

  it('filters results by query', () => {
    render(
      <CommandRegistryProvider initial={seed}>
        <CommandPalette open onClose={() => {}} />
      </CommandRegistryProvider>
    );
    fireEvent.change(screen.getByTestId('bl-cmdk-input'), {
      target: { value: 'task' },
    });
    expect(screen.getByTestId('bl-cmdk-item-new-task')).toBeDefined();
  });

  it('Enter activates the selected command via run()', () => {
    const onClose = vi.fn();
    render(
      <CommandRegistryProvider initial={seed}>
        <CommandPalette open onClose={onClose} />
      </CommandRegistryProvider>
    );
    fireEvent.keyDown(screen.getByTestId('bl-cmdk'), { key: 'Enter' });
    expect(seed[0].run as ReturnType<typeof vi.fn>).toHaveBeenCalledOnce();
    expect(onClose).toHaveBeenCalled();
  });

  it('navigate-mode Enter calls onNavigate with href', () => {
    const onNavigate = vi.fn();
    const onClose = vi.fn();
    render(
      <CommandRegistryProvider initial={seed}>
        <CommandPalette open onClose={onClose} onNavigate={onNavigate} />
      </CommandRegistryProvider>
    );
    fireEvent.keyDown(screen.getByTestId('bl-cmdk'), { key: 'Tab' });
    fireEvent.keyDown(screen.getByTestId('bl-cmdk'), { key: 'Enter' });
    expect(onNavigate).toHaveBeenCalledWith('/settings');
    expect(onClose).toHaveBeenCalled();
  });

  it('arrow keys move selection (highlighted row)', () => {
    render(
      <CommandRegistryProvider initial={seed}>
        <CommandPalette open onClose={() => {}} />
      </CommandRegistryProvider>
    );
    const dialog = screen.getByTestId('bl-cmdk');
    fireEvent.keyDown(dialog, { key: 'ArrowDown' });
    // After one ArrowDown, second visible action becomes selected.
    // 'new-task' is at index 0; 'archive' is index 1 (disabled but listed).
    // We assert aria-selected attribute.
    const items = screen.getAllByRole('option');
    expect(items[1]?.getAttribute('aria-selected')).toBe('true');
  });

  it('Escape closes', () => {
    const onClose = vi.fn();
    render(
      <CommandRegistryProvider initial={seed}>
        <CommandPalette open onClose={onClose} />
      </CommandRegistryProvider>
    );
    fireEvent.keyDown(document, { key: 'Escape' });
    expect(onClose).toHaveBeenCalledOnce();
  });

  it('Ask-AI tab renders default panel; custom askAiPanel takes precedence', () => {
    const { rerender } = render(
      <CommandRegistryProvider initial={seed}>
        <CommandPalette open onClose={() => {}} initialMode="ask-ai" />
      </CommandRegistryProvider>
    );
    expect(screen.getByTestId('bl-cmdk-ask-ai')).toBeDefined();

    rerender(
      <CommandRegistryProvider initial={seed}>
        <CommandPalette
          open
          onClose={() => {}}
          initialMode="ask-ai"
          askAiPanel={q => <div data-testid="custom-ai">{q || 'idle'}</div>}
        />
      </CommandRegistryProvider>
    );
    expect(screen.getByTestId('custom-ai').textContent).toBe('idle');
  });

  it('disabled commands are not activatable by click', () => {
    render(
      <CommandRegistryProvider initial={seed}>
        <CommandPalette open onClose={() => {}} />
      </CommandRegistryProvider>
    );
    const row = screen.getByTestId('bl-cmdk-item-archive');
    expect(row.getAttribute('aria-disabled')).toBe('true');
    fireEvent.click(row);
    expect(seed[3].run as ReturnType<typeof vi.fn>).not.toHaveBeenCalled();
  });

  it('recents persist to localStorage when a command is run', () => {
    // happy-dom v18 ships an incomplete Storage API in some builds, so
    // we install a deterministic in-memory shim for the duration of
    // this test. The shape is exactly what the registry depends on.
    const mem: Record<string, string> = {};
    const stub = {
      getItem: (k: string) => (k in mem ? mem[k]! : null),
      setItem: (k: string, v: string) => {
        mem[k] = v;
      },
      removeItem: (k: string) => {
        delete mem[k];
      },
      clear: () => {
        for (const k of Object.keys(mem)) delete mem[k];
      },
      key: (i: number) => Object.keys(mem)[i] ?? null,
      get length() {
        return Object.keys(mem).length;
      },
    };
    const original = window.localStorage;
    Object.defineProperty(window, 'localStorage', {
      configurable: true,
      value: stub,
    });
    try {
      render(
        <CommandRegistryProvider initial={seed}>
          <CommandPalette open onClose={() => {}} />
        </CommandRegistryProvider>
      );
      fireEvent.click(screen.getByTestId('bl-cmdk-item-new-task'));
      expect(mem['bl-cmdk-recents']).toContain('new-task');
    } finally {
      Object.defineProperty(window, 'localStorage', {
        configurable: true,
        value: original,
      });
    }
  });
});

// ─── useCommandPalette hotkey ────────────────────────────────────────────

describe('useCommandPalette', () => {
  it('toggles via Cmd-K / Ctrl-K and stays closed on plain K', () => {
    const { result } = renderHook(() => useCommandPalette());
    expect(result.current.open).toBe(false);

    act(() => {
      window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true }));
    });
    expect(result.current.open).toBe(true);

    act(() => {
      window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true }));
    });
    expect(result.current.open).toBe(false);

    // Plain 'k' should NOT toggle.
    act(() => {
      window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k' }));
    });
    expect(result.current.open).toBe(false);
  });

  it('ignores keydown events with an undefined key (autofill/IME) without throwing', () => {
    const { result } = renderHook(() => useCommandPalette());
    expect(() =>
      act(() => {
        // Some events (autofill, IME) dispatch keydown with no `key` set.
        const ev = new KeyboardEvent('keydown', { metaKey: true });
        Object.defineProperty(ev, 'key', { value: undefined });
        window.dispatchEvent(ev);
      })
    ).not.toThrow();
    expect(result.current.open).toBe(false);
  });

  it('show/hide/toggle imperative helpers', () => {
    const { result } = renderHook(() => useCommandPalette({ hotkey: null }));
    act(() => result.current.show());
    expect(result.current.open).toBe(true);
    act(() => result.current.toggle());
    expect(result.current.open).toBe(false);
    act(() => result.current.toggle());
    expect(result.current.open).toBe(true);
    act(() => result.current.hide());
    expect(result.current.open).toBe(false);
  });

  it('respects defaultOpen', () => {
    const { result } = renderHook(() => useCommandPalette({ defaultOpen: true, hotkey: null }));
    expect(result.current.open).toBe(true);
  });
});
