import { act, renderHook } from '@testing-library/react';
import { useState } from 'react';
import { describe, expect, it, vi } from 'vitest';
import {
  mergeChatCommandCatalog,
  type ChatCommandContext,
} from '@/pages/Chat/chat-command-registry';
import {
  useChatCommands,
  type ChatCommandActions,
  type ChatCommandKeyEvent,
} from '@/pages/Chat/use-chat-commands';

function keyEvent(
  key: string,
  overrides: Partial<ChatCommandKeyEvent> = {},
): ChatCommandKeyEvent {
  return {
    key,
    shiftKey: false,
    isComposing: false,
    preventDefault: vi.fn(),
    ...overrides,
  };
}

const enabledContext: ChatCommandContext = {
  hasSession: true,
  hasMessages: true,
  hasUndoableUserMessage: true,
  runtimeReady: true,
  busy: false,
  hasModel: true,
  shared: false,
  reverted: true,
  shareEnabled: true,
  clipboardAvailable: true,
};

function renderCommandHook(
  initialDraft: string,
  overrides: {
    context?: Partial<ChatCommandContext>;
  } = {},
) {
  const context = {
    ...enabledContext,
    ...overrides.context,
  };
  const actions = {
    rename: vi.fn<ChatCommandActions['rename']>().mockResolvedValue(undefined),
    timeline: vi.fn<ChatCommandActions['timeline']>(),
    compact: vi.fn<ChatCommandActions['compact']>().mockResolvedValue(undefined),
    share: vi.fn<ChatCommandActions['share']>().mockImplementation(async () => {
      context.shared = true;
    }),
    unshare: vi.fn<ChatCommandActions['unshare']>().mockImplementation(async () => {
      context.shared = false;
    }),
    undo: vi.fn<ChatCommandActions['undo']>().mockResolvedValue(undefined),
    redo: vi.fn<ChatCommandActions['redo']>().mockResolvedValue(undefined),
    toggleTimestamps:
      vi.fn<ChatCommandActions['toggleTimestamps']>(),
    toggleThinking: vi.fn<ChatCommandActions['toggleThinking']>(),
    toggleProcess: vi.fn<ChatCommandActions['toggleProcess']>(),
    copy: vi.fn<ChatCommandActions['copy']>().mockResolvedValue(undefined),
    export: vi.fn<ChatCommandActions['export']>().mockResolvedValue(undefined),
    project: vi.fn<ChatCommandActions['project']>().mockResolvedValue({
      status: 'executed',
    }),
  };
  let currentDraft = initialDraft;
  let draftRevision = 0;
  let setDraft:
    | ReturnType<typeof useState<string>>[1]
    | undefined;
  let setScopeKey:
    | ReturnType<typeof useState<string>>[1]
    | undefined;
  const catalog = mergeChatCommandCatalog([{
    name: 'Review',
    hints: ['$ARGUMENTS'],
  }]);
  const { result } = renderHook(() => {
    const [draft, updateDraftState] = useState(initialDraft);
    const [scopeKey, updateScopeKey] = useState('ses-test');
    const updateDraft = (
      next: string | ((current: string) => string),
    ) => {
      updateDraftState((current) => {
        const value = typeof next === 'function'
          ? next(current)
          : next;
        if (value !== current) draftRevision += 1;
        return value;
      });
    };
    currentDraft = draft;
    setDraft = updateDraft;
    setScopeKey = updateScopeKey;
    return useChatCommands({
      draft,
      setDraft: updateDraft,
      scopeKey,
      draftRevision,
      catalog,
      context,
      actions,
      normalizeError: (error) => (
        error instanceof Error ? error.message : String(error)
      ),
    });
  });

  const updateDraft = (value: string) => {
    if (!setDraft) throw new Error('Hook draft setter is unavailable');
    setDraft(value);
  };

  return {
    result,
    actions,
    getDraft: () => currentDraft,
    setDraft: updateDraft,
    setScopeKey: (scopeKey: string) => {
      if (!setScopeKey) throw new Error('Hook scope setter is unavailable');
      setScopeKey(scopeKey);
    },
    run: async (command: string) => {
      await act(async () => {
        updateDraft(command);
      });
      await act(async () => {
        await result.current.tryExecuteDraft();
      });
    },
  };
}

describe('useChatCommands', () => {
  it('fills a palette selection and executes it only on the next Enter', async () => {
    const fixture = renderCommandHook('/comp');
    act(() => fixture.result.current.handleKeyDown(keyEvent('Enter')));
    expect(fixture.getDraft()).toBe('/compact');
    expect(fixture.actions.compact).not.toHaveBeenCalled();
    expect(fixture.result.current.notice).toBe('再次按 Enter 执行');

    await act(async () => fixture.result.current.handleKeyDown(keyEvent('Enter')));
    expect(fixture.actions.compact).toHaveBeenCalledOnce();
    expect(fixture.getDraft()).toBe('');
  });

  it('executes a directly typed exact command and preserves raw project argument spacing', async () => {
    const fixture = renderCommandHook('/Review   staged changes  ');
    await act(async () => fixture.result.current.handleKeyDown(keyEvent('Enter')));
    expect(fixture.actions.project).toHaveBeenCalledWith({
      rawName: 'Review',
      arguments: '  staged changes  ',
    });
  });

  it('never intercepts Shift+Enter or IME composition', () => {
    const fixture = renderCommandHook('/compact');
    expect(fixture.result.current.handleKeyDown(keyEvent('Enter', {
      shiftKey: true,
    }))).toBe(false);
    expect(fixture.result.current.handleKeyDown(keyEvent('Enter', {
      isComposing: true,
    }))).toBe(false);
    expect(fixture.actions.compact).not.toHaveBeenCalled();
  });

  it('supports arrows Tab and Escape without executing the highlighted item', () => {
    const fixture = renderCommandHook('/');
    expect(fixture.result.current.activeIndex).toBe(0);
    act(() => fixture.result.current.handleKeyDown(keyEvent('ArrowDown')));
    expect(fixture.result.current.activeIndex).toBe(1);
    act(() => fixture.result.current.handleKeyDown(keyEvent('ArrowUp')));
    expect(fixture.result.current.activeIndex).toBe(0);
    act(() => fixture.result.current.handleKeyDown(keyEvent('Tab')));
    expect(fixture.getDraft()).toMatch(/^\/\S+/);
    expect(
      Object.values(fixture.actions)
        .every((mock) => mock.mock.calls.length === 0),
    ).toBe(true);
    act(() => fixture.result.current.handleKeyDown(keyEvent('Escape')));
    expect(fixture.result.current.paletteOpen).toBe(false);
  });

  it('blocks an unknown slash token instead of sending it to the model', async () => {
    const fixture = renderCommandHook('/does-not-exist payload');
    let handled = false;
    await act(async () => {
      handled = await fixture.result.current.tryExecuteDraft();
    });
    expect(handled).toBe(true);
    expect(fixture.result.current.error).toBe(
      '未知命令 /does-not-exist',
    );
    expect(
      Object.values(fixture.actions)
        .every((mock) => mock.mock.calls.length === 0),
    ).toBe(true);
    expect(fixture.getDraft()).toBe('/does-not-exist payload');
  });

  it('keeps the originating draft when an action rejects or is cancelled', async () => {
    const fixture = renderCommandHook('/compact');
    fixture.actions.compact.mockRejectedValueOnce(
      new Error('summarize failed'),
    );
    await act(async () => fixture.result.current.tryExecuteDraft());
    expect(fixture.getDraft()).toBe('/compact');
    expect(fixture.result.current.error).toBe('summarize failed');

    act(() => fixture.setDraft('/undo'));
    await act(async () => fixture.result.current.tryExecuteDraft());
    expect(fixture.result.current.confirmation?.kind).toBe('undo');
    act(() => fixture.result.current.cancelDialog());
    expect(fixture.getDraft()).toBe('/undo');
  });

  it('dispatches every approved builtin to its exact workflow', async () => {
    const fixture = renderCommandHook('/rename New title');
    await fixture.run('/rename New title');
    expect(fixture.actions.rename).toHaveBeenCalledWith('New title');

    await fixture.run('/rename');
    expect(fixture.result.current.dialog).toEqual({
      kind: 'rename',
      originatingDraft: '/rename',
    });
    fixture.result.current.cancelDialog();

    await fixture.run('/timeline');
    expect(fixture.result.current.dialog).toEqual({
      kind: 'timeline',
      originatingDraft: '/timeline',
    });
    fixture.result.current.cancelDialog();

    await fixture.run('/compact');
    expect(fixture.actions.compact).toHaveBeenCalledOnce();

    await fixture.run('/share');
    expect(fixture.result.current.confirmation?.kind).toBe('share');
    await act(async () => fixture.result.current.confirmDialog());
    expect(fixture.actions.share).toHaveBeenCalledOnce();

    await fixture.run('/unshare');
    expect(fixture.result.current.confirmation?.kind).toBe('unshare');
    await act(async () => fixture.result.current.confirmDialog());
    expect(fixture.actions.unshare).toHaveBeenCalledOnce();

    await fixture.run('/undo');
    expect(fixture.result.current.confirmation?.kind).toBe('undo');
    await act(async () => fixture.result.current.confirmDialog());
    expect(fixture.actions.undo).toHaveBeenCalledOnce();

    await fixture.run('/redo');
    expect(fixture.result.current.confirmation?.kind).toBe('redo');
    await act(async () => fixture.result.current.confirmDialog());
    expect(fixture.actions.redo).toHaveBeenCalledOnce();

    await fixture.run('/timestamps');
    expect(fixture.actions.toggleTimestamps).toHaveBeenCalledOnce();

    await fixture.run('/thinking');
    expect(fixture.actions.toggleThinking).toHaveBeenCalledOnce();

    await fixture.run('/process');
    expect(fixture.actions.toggleProcess).toHaveBeenCalledOnce();

    await fixture.run('/copy');
    expect(fixture.actions.copy).toHaveBeenCalledOnce();

    await fixture.run('/export');
    expect(fixture.actions.export).toHaveBeenCalledOnce();
  });

  it('does not invoke disabled runtime actions and exposes the computed reason', async () => {
    const fixture = renderCommandHook('/Review changes', {
      context: { runtimeReady: false },
    });
    await fixture.run('/Review changes');
    expect(fixture.actions.project).not.toHaveBeenCalled();
    expect(fixture.result.current.error).toBe(
      'OpenCode 运行时未启动',
    );
  });

  it('retains a newer edit when the originating command completes', async () => {
    const deferred = Promise.withResolvers<void>();
    const fixture = renderCommandHook('/compact');
    fixture.actions.compact.mockReturnValueOnce(deferred.promise);
    const pending = fixture.result.current.tryExecuteDraft();
    act(() => fixture.setDraft('/compact plus my newer note'));
    deferred.resolve();
    await act(async () => pending);
    expect(fixture.getDraft()).toBe('/compact plus my newer note');
  });

  it('retains an ABA-edited draft when an older command completes', async () => {
    const deferred = Promise.withResolvers<void>();
    const fixture = renderCommandHook('/compact');
    fixture.actions.compact.mockReturnValueOnce(deferred.promise);
    const pending = fixture.result.current.tryExecuteDraft();

    act(() => fixture.setDraft('temporary edit'));
    act(() => fixture.setDraft('/compact'));
    deferred.resolve();
    await act(async () => pending);

    expect(fixture.getDraft()).toBe('/compact');
  });

  it('does not clear an identical draft after its command scope changes', async () => {
    const deferred = Promise.withResolvers<void>();
    const fixture = renderCommandHook('/compact');
    fixture.actions.compact.mockReturnValueOnce(deferred.promise);
    const pending = fixture.result.current.tryExecuteDraft();

    act(() => fixture.setScopeKey('ses-second'));
    deferred.resolve();
    await act(async () => pending);

    expect(fixture.getDraft()).toBe('/compact');
  });

  it('closes a stale confirmation without dispatching it in a new scope', async () => {
    const fixture = renderCommandHook('/undo');
    await act(async () => fixture.result.current.tryExecuteDraft());
    expect(fixture.result.current.confirmation?.kind).toBe('undo');

    act(() => fixture.setScopeKey('ses-second'));
    expect(fixture.result.current.confirmation).toBeNull();
    await act(async () => fixture.result.current.confirmDialog());

    expect(fixture.actions.undo).not.toHaveBeenCalled();
  });

  it('does not clear an identical current draft when a project command is cancelled', async () => {
    const fixture = renderCommandHook('/Review same text');
    fixture.actions.project.mockResolvedValueOnce({ status: 'cancelled' });

    await act(async () => fixture.result.current.tryExecuteDraft());

    expect(fixture.actions.project).toHaveBeenCalledWith({
      rawName: 'Review',
      arguments: 'same text',
    });
    expect(fixture.getDraft()).toBe('/Review same text');
  });
});
