import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';

const {
  API_MODULE,
  APP_MODULE,
  CARET_HELPER_MODULE,
  PREVIEW_COMPONENT_MODULE,
  AUTOCOMPLETE_MODULE,
} = vi.hoisted(() => ({
  API_MODULE: new URL('../../../scripts/api.js', import.meta.url).pathname,
  APP_MODULE: new URL('../../../scripts/app.js', import.meta.url).pathname,
  CARET_HELPER_MODULE: new URL('../../../web/comfyui/textarea_caret_helper.js', import.meta.url).pathname,
  PREVIEW_COMPONENT_MODULE: new URL('../../../web/comfyui/preview_tooltip.js', import.meta.url).pathname,
  AUTOCOMPLETE_MODULE: new URL('../../../web/comfyui/autocomplete.js', import.meta.url).pathname,
}));

const fetchApiMock = vi.fn();
const settingGetMock = vi.fn();
const settingSetMock = vi.fn();
const caretHelperInstance = {
  getBeforeCursor: vi.fn(() => ''),
  getCursorOffset: vi.fn(() => ({ left: 0, top: 0 })),
};

const previewTooltipMock = {
  show: vi.fn(),
  hide: vi.fn(),
  cleanup: vi.fn(),
};

vi.mock(API_MODULE, () => ({
  api: {
    fetchApi: fetchApiMock,
  },
}));

vi.mock(APP_MODULE, () => ({
  app: {
    canvas: {
      ds: { scale: 1 },
    },
    extensionManager: {
      setting: {
        get: settingGetMock,
        set: settingSetMock,
      },
    },
    registerExtension: vi.fn(),
  },
}));

vi.mock(CARET_HELPER_MODULE, () => ({
  TextAreaCaretHelper: vi.fn(() => caretHelperInstance),
}));

vi.mock(PREVIEW_COMPONENT_MODULE, () => ({
  PreviewTooltip: vi.fn(() => previewTooltipMock),
}));

describe('AutoComplete widget interactions', () => {
  beforeEach(() => {
    document.body.innerHTML = '';
    document.head.querySelectorAll('style').forEach((styleEl) => styleEl.remove());
    Element.prototype.scrollIntoView = vi.fn();
    fetchApiMock.mockReset();
    settingGetMock.mockReset();
    settingSetMock.mockReset();
    settingGetMock.mockImplementation((key) => {
      if (key === 'loramanager.autocomplete_append_comma') {
        return true;
      }
      if (key === 'loramanager.autocomplete_auto_format') {
        return true;
      }
      if (key === 'loramanager.autocomplete_accept_key') {
        return 'both';
      }
      if (key === 'loramanager.prompt_tag_autocomplete') {
        return true;
      }
      if (key === 'loramanager.tag_space_replacement') {
        return false;
      }
      return undefined;
    });
    caretHelperInstance.getBeforeCursor.mockReset();
    caretHelperInstance.getCursorOffset.mockReset();
    caretHelperInstance.getBeforeCursor.mockReturnValue('');
    caretHelperInstance.getCursorOffset.mockReturnValue({ left: 0, top: 0 });
    previewTooltipMock.show.mockReset();
    previewTooltipMock.hide.mockReset();
    previewTooltipMock.cleanup.mockReset();
  });

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

  it('fetches and renders search results when input exceeds the minimum characters', async () => {
    vi.useFakeTimers();

    fetchApiMock.mockResolvedValue({
      json: () => Promise.resolve({ success: true, relative_paths: ['models/example.safetensors'] }),
    });

    caretHelperInstance.getBeforeCursor.mockReturnValue('example');
    caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });

    const input = document.createElement('textarea');
    document.body.append(input);

    const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
    const autoComplete = new AutoComplete(input,'loras', { debounceDelay: 0, showPreview: false });

    input.value = 'example';
    input.dispatchEvent(new Event('input', { bubbles: true }));

    await vi.runAllTimersAsync();
    await Promise.resolve();

    expect(fetchApiMock).toHaveBeenCalledWith('/lm/loras/relative-paths?search=example&limit=100');
    const items = autoComplete.dropdown.querySelectorAll('.comfy-autocomplete-item');
    expect(items).toHaveLength(1);
    expect(autoComplete.dropdown.style.display).toBe('block');
    expect(autoComplete.isVisible).toBe(true);
    expect(caretHelperInstance.getCursorOffset).toHaveBeenCalled();
  });

  it('deduplicates duplicate-equivalent query variations before issuing requests', async () => {
    vi.useFakeTimers();

    fetchApiMock.mockResolvedValue({
      json: () => Promise.resolve({ success: true, words: [] }),
    });

    caretHelperInstance.getBeforeCursor.mockReturnValue('Example');

    const input = document.createElement('textarea');
    document.body.append(input);

    const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
    new AutoComplete(input, 'prompt', { debounceDelay: 0, showPreview: false, minChars: 1 });

    input.value = 'Example';
    input.dispatchEvent(new Event('input', { bubbles: true }));

    await vi.runAllTimersAsync();
    await Promise.resolve();

    expect(fetchApiMock).toHaveBeenCalledTimes(1);
    expect(fetchApiMock).toHaveBeenCalledWith('/lm/custom-words/search?enriched=true&search=Example&limit=100');
  });

  it('inserts the selected LoRA with usage tip strengths and restores focus', async () => {
    fetchApiMock.mockImplementation((url) => {
      if (url.includes('usage-tips-by-path')) {
        return Promise.resolve({
          ok: true,
          json: () => Promise.resolve({
            success: true,
            usage_tips: JSON.stringify({ strength: '1.5', clip_strength: '0.9' }),
          }),
        });
      }

      return Promise.resolve({
        json: () => Promise.resolve({ success: true, relative_paths: ['models/example.safetensors'] }),
      });
    });

    caretHelperInstance.getBeforeCursor.mockReturnValue('alpha, example');

    const input = document.createElement('textarea');
    input.value = 'alpha, example';
    input.selectionStart = input.value.length;
    input.focus = vi.fn();
    input.setSelectionRange = vi.fn();
    document.body.append(input);

    const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
    const autoComplete = new AutoComplete(input,'loras', { debounceDelay: 0, showPreview: false });

    await autoComplete.insertSelection('models/example.safetensors');

    expect(fetchApiMock).toHaveBeenCalledWith(
      '/lm/loras/usage-tips-by-path?relative_path=models%2Fexample.safetensors',
    );
    expect(input.value).toContain('<lora:example:1.5:0.9>,');
    expect(autoComplete.dropdown.style.display).toBe('none');
    expect(input.focus).toHaveBeenCalled();
    expect(input.setSelectionRange).toHaveBeenCalled();
  });

  it('accepts the selected suggestion with Tab', async () => {
    caretHelperInstance.getBeforeCursor.mockReturnValue('example');

    const input = document.createElement('textarea');
    input.value = 'example';
    input.selectionStart = input.value.length;
    input.focus = vi.fn();
    input.setSelectionRange = vi.fn();
    document.body.append(input);

    const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
    const autoComplete = new AutoComplete(input,'custom_words', { showPreview: false });

    autoComplete.items = ['example_completion'];
    autoComplete.selectedIndex = 0;
    autoComplete.isVisible = true;
    const insertSelectionSpy = vi.spyOn(autoComplete,'insertSelection').mockResolvedValue();

    const tabEvent = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true });
    input.dispatchEvent(tabEvent);

    expect(tabEvent.defaultPrevented).toBe(true);
    expect(insertSelectionSpy).toHaveBeenCalledWith('example_completion');
  });

  it('formats duplicate commas and extra spaces when the textarea loses focus', async () => {
    const input = document.createElement('textarea');
    input.value = 'foo   bar, , baz  ,,   qux';
    document.body.append(input);

    const inputListener = vi.fn();
    input.addEventListener('input', inputListener);

    const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
    new AutoComplete(input,'prompt', { showPreview: false });

    input.dispatchEvent(new Event('blur', { bubbles: true }));

    expect(input.value).toBe('foo bar, baz, qux');
    expect(inputListener).toHaveBeenCalledTimes(1);
  });

  it('skips blur formatting when autocomplete auto format is disabled', async () => {
    settingGetMock.mockImplementation((key) => {
      if (key === 'loramanager.autocomplete_append_comma') {
        return true;
      }
      if (key === 'loramanager.autocomplete_auto_format') {
        return false;
      }
      if (key === 'loramanager.autocomplete_accept_key') {
        return 'both';
      }
      if (key === 'loramanager.prompt_tag_autocomplete') {
        return true;
      }
      if (key === 'loramanager.tag_space_replacement') {
        return false;
      }
      return undefined;
    });

    const input = document.createElement('textarea');
    input.value = 'foo   bar, , baz  ,,   qux';
    document.body.append(input);

    const inputListener = vi.fn();
    input.addEventListener('input', inputListener);

    const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
    new AutoComplete(input,'prompt', { showPreview: false });

    input.dispatchEvent(new Event('blur', { bubbles: true }));

    expect(input.value).toBe('foo   bar, , baz  ,,   qux');
    expect(inputListener).not.toHaveBeenCalled();
  });

  it('shows the full command list when typing a single slash', async () => {
    const input = document.createElement('textarea');
    input.value = '/';
    input.selectionStart = input.value.length;
    document.body.append(input);

    caretHelperInstance.getBeforeCursor.mockReturnValue('/');
    caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });

    const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
    const autoComplete = new AutoComplete(input,'prompt', { showPreview: false, minChars: 1 });

    input.dispatchEvent(new Event('input', { bubbles: true }));

    const commandNames = autoComplete.items.map((item) => item.command);

    expect(commandNames).toContain('/character');
    expect(commandNames).toContain('/artist');
    expect(commandNames).toContain('/general');
    expect(commandNames).toContain('/copyright');
    expect(commandNames).toContain('/meta');
    expect(commandNames).toContain('/species');
    expect(commandNames).toContain('/lore');
    expect(commandNames).toContain('/emb');
    expect(commandNames).toContain('/embedding');
    expect(commandNames).toContain('/wildcard');
  });

  it('renders every command item when slash opens the command list', async () => {
    const input = document.createElement('textarea');
    input.value = '/';
    input.selectionStart = input.value.length;
    document.body.append(input);

    caretHelperInstance.getBeforeCursor.mockReturnValue('/');
    caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });

    const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
    const autoComplete = new AutoComplete(input, 'prompt', { showPreview: false, minChars: 1 });

    input.dispatchEvent(new Event('input', { bubbles: true }));

    const renderedCommands = autoComplete.contentContainer.querySelectorAll('.lm-autocomplete-command-name');

    expect(renderedCommands).toHaveLength(autoComplete.items.length);
  });

  it('accepts the selected suggestion with Enter', async () => {
    caretHelperInstance.getBeforeCursor.mockReturnValue('example');

    const input = document.createElement('textarea');
    input.value = 'example';
    input.selectionStart = input.value.length;
    input.focus = vi.fn();
    input.setSelectionRange = vi.fn();
    document.body.append(input);

    const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
    const autoComplete = new AutoComplete(input,'custom_words', { showPreview: false });

    autoComplete.items = ['example_completion'];
    autoComplete.selectedIndex = 0;
    autoComplete.isVisible = true;
    const insertSelectionSpy = vi.spyOn(autoComplete,'insertSelection').mockResolvedValue();

    const enterEvent = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true });
    input.dispatchEvent(enterEvent);

    expect(enterEvent.defaultPrevented).toBe(true);
    expect(insertSelectionSpy).toHaveBeenCalledWith('example_completion');
  });

  it('prefers the latest best match when Tab is pressed before debounced suggestions fully refresh', async () => {
    caretHelperInstance.getBeforeCursor.mockReturnValue('loop');

    const input = document.createElement('textarea');
    input.value = 'loop';
    input.selectionStart = input.value.length;
    input.focus = vi.fn();
    input.setSelectionRange = vi.fn();
    document.body.append(input);

    const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
    const autoComplete = new AutoComplete(input,'prompt', { showPreview: false, minChars: 1 });

    autoComplete.searchType = 'custom_words';
    autoComplete.items = [
      { tag_name: 'looking_to_the_side', category: 0, post_count: 1000 },
      { tag_name: 'loop', category: 0, post_count: 500 },
    ];
    autoComplete.currentSearchTerm = 'loo';
    autoComplete.selectedIndex = 0;
    autoComplete.isVisible = true;
    const insertSelectionSpy = vi.spyOn(autoComplete,'insertSelection').mockResolvedValue();

    const tabEvent = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true });
    input.dispatchEvent(tabEvent);

    expect(tabEvent.defaultPrevented).toBe(true);
    expect(autoComplete.selectedIndex).toBe(1);
    expect(insertSelectionSpy).toHaveBeenCalledWith('loop');
  });

  it('preserves manual ArrowDown selection when Tab accepts a suggestion', async () => {
    caretHelperInstance.getBeforeCursor.mockReturnValue('loop');

    const input = document.createElement('textarea');
    input.value = 'loop';
    input.selectionStart = input.value.length;
    input.focus = vi.fn();
    input.setSelectionRange = vi.fn();
    document.body.append(input);

    const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
    const autoComplete = new AutoComplete(input,'prompt', { showPreview: false, minChars: 1 });

    autoComplete.searchType = 'custom_words';
    autoComplete.items = [
      { tag_name: 'looking_to_the_side', category: 0, post_count: 1000 },
      { tag_name: 'loop', category: 0, post_count: 500 },
    ];
    autoComplete.currentSearchTerm = 'loo';
    autoComplete.selectedIndex = 0;
    autoComplete.isVisible = true;
    const insertSelectionSpy = vi.spyOn(autoComplete,'insertSelection').mockResolvedValue();

    input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
    input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }));

    expect(autoComplete.selectedIndex).toBe(1);
    expect(insertSelectionSpy).toHaveBeenCalledWith('loop');
  });

  it('preserves manual ArrowDown selection when Enter accepts a suggestion', async () => {
    caretHelperInstance.getBeforeCursor.mockReturnValue('loop');

    const input = document.createElement('textarea');
    input.value = 'loop';
    input.selectionStart = input.value.length;
    input.focus = vi.fn();
    input.setSelectionRange = vi.fn();
    document.body.append(input);

    const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
    const autoComplete = new AutoComplete(input,'prompt', { showPreview: false, minChars: 1 });

    autoComplete.searchType = 'custom_words';
    autoComplete.items = [
      { tag_name: 'looking_to_the_side', category: 0, post_count: 1000 },
      { tag_name: 'loop', category: 0, post_count: 500 },
    ];
    autoComplete.currentSearchTerm = 'loo';
    autoComplete.selectedIndex = 0;
    autoComplete.isVisible = true;
    const insertSelectionSpy = vi.spyOn(autoComplete,'insertSelection').mockResolvedValue();

    input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
    input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));

    expect(autoComplete.selectedIndex).toBe(1);
    expect(insertSelectionSpy).toHaveBeenCalledWith('loop');
  });

  it('accepts the first available suggestion with Tab even if delayed auto-selection has not happened yet', async () => {
    caretHelperInstance.getBeforeCursor.mockReturnValue('loop');

    const input = document.createElement('textarea');
    input.value = 'loop';
    input.selectionStart = input.value.length;
    input.focus = vi.fn();
    input.setSelectionRange = vi.fn();
    document.body.append(input);

    const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
    const autoComplete = new AutoComplete(input,'custom_words', { showPreview: false });

    autoComplete.items = ['loop'];
    autoComplete.selectedIndex = -1;
    autoComplete.isVisible = true;
    const insertSelectionSpy = vi.spyOn(autoComplete,'insertSelection').mockResolvedValue();

    const tabEvent = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true });
    input.dispatchEvent(tabEvent);

    expect(tabEvent.defaultPrevented).toBe(true);
    expect(autoComplete.selectedIndex).toBe(0);
    expect(insertSelectionSpy).toHaveBeenCalledWith('loop');
  });

  it('only accepts with Tab when autocomplete accept key is set to tab_only', async () => {
    settingGetMock.mockImplementation((key) => {
      if (key === 'loramanager.autocomplete_append_comma') {
        return true;
      }
      if (key === 'loramanager.autocomplete_auto_format') {
        return true;
      }
      if (key === 'loramanager.autocomplete_accept_key') {
        return 'tab_only';
      }
      if (key === 'loramanager.prompt_tag_autocomplete') {
        return true;
      }
      if (key === 'loramanager.tag_space_replacement') {
        return false;
      }
      return undefined;
    });

    caretHelperInstance.getBeforeCursor.mockReturnValue('example');

    const input = document.createElement('textarea');
    input.value = 'example';
    input.selectionStart = input.value.length;
    input.focus = vi.fn();
    input.setSelectionRange = vi.fn();
    document.body.append(input);

    const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
    const autoComplete = new AutoComplete(input,'custom_words', { showPreview: false });

    autoComplete.items = ['example_completion'];
    autoComplete.selectedIndex = 0;
    autoComplete.isVisible = true;
    const insertSelectionSpy = vi.spyOn(autoComplete,'insertSelection').mockResolvedValue();

    const enterEvent = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true });
    input.dispatchEvent(enterEvent);

    expect(enterEvent.defaultPrevented).toBe(false);
    expect(insertSelectionSpy).not.toHaveBeenCalled();

    const tabEvent = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true });
    input.dispatchEvent(tabEvent);

    expect(tabEvent.defaultPrevented).toBe(true);
    expect(insertSelectionSpy).toHaveBeenCalledWith('example_completion');
  });

  it('only accepts with Enter when autocomplete accept key is set to enter_only', async () => {
    settingGetMock.mockImplementation((key) => {
      if (key === 'loramanager.autocomplete_append_comma') {
        return true;
      }
      if (key === 'loramanager.autocomplete_auto_format') {
        return true;
      }
      if (key === 'loramanager.autocomplete_accept_key') {
        return 'enter_only';
      }
      if (key === 'loramanager.prompt_tag_autocomplete') {
        return true;
      }
      if (key === 'loramanager.tag_space_replacement') {
        return false;
      }
      return undefined;
    });

    caretHelperInstance.getBeforeCursor.mockReturnValue('example');

    const input = document.createElement('textarea');
    input.value = 'example';
    input.selectionStart = input.value.length;
    input.focus = vi.fn();
    input.setSelectionRange = vi.fn();
    document.body.append(input);

    const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
    const autoComplete = new AutoComplete(input,'custom_words', { showPreview: false });

    autoComplete.items = ['example_completion'];
    autoComplete.selectedIndex = 0;
    autoComplete.isVisible = true;
    const insertSelectionSpy = vi.spyOn(autoComplete,'insertSelection').mockResolvedValue();

    const tabEvent = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true });
    input.dispatchEvent(tabEvent);

    expect(tabEvent.defaultPrevented).toBe(false);
    expect(insertSelectionSpy).not.toHaveBeenCalled();

    const enterEvent = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true });
    input.dispatchEvent(enterEvent);

    expect(enterEvent.defaultPrevented).toBe(true);
    expect(insertSelectionSpy).toHaveBeenCalledWith('example_completion');
  });

  it('does not intercept Tab when the dropdown is not visible', async () => {
    caretHelperInstance.getBeforeCursor.mockReturnValue('example');

    const input = document.createElement('textarea');
    input.value = 'example';
    input.selectionStart = input.value.length;
    input.focus = vi.fn();
    input.setSelectionRange = vi.fn();
    document.body.append(input);

    const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
    const autoComplete = new AutoComplete(input,'custom_words', { showPreview: false });

    autoComplete.items = ['example_completion'];
    autoComplete.selectedIndex = 0;
    autoComplete.isVisible = false;
    const insertSelectionSpy = vi.spyOn(autoComplete,'insertSelection').mockResolvedValue();

    const tabEvent = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true });
    input.dispatchEvent(tabEvent);

    expect(tabEvent.defaultPrevented).toBe(false);
    expect(insertSelectionSpy).not.toHaveBeenCalled();
  });

  it('highlights multiple include tokens while ignoring excluded ones', async () => {
    const input = document.createElement('textarea');
    document.body.append(input);

    const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
    const autoComplete = new AutoComplete(input,'loras', { showPreview: false });

    const highlighted = autoComplete.highlightMatch(
      'models/flux/beta-detail.safetensors',
      'flux detail -beta',
    );

    const highlightCount = (highlighted.match(/<span/g) || []).length;
    expect(highlightCount).toBe(2);
    expect(highlighted).toContain('flux');
    expect(highlighted).toContain('detail');
    expect(highlighted).not.toMatch(/beta<\/span>/i);
  });

  it('handles arrow key navigation with virtual scrolling', async () => {
    vi.useFakeTimers();

    const mockItems = Array.from({ length: 50 }, (_, i) => `model_${i.toString().padStart(2,'0')}.safetensors`);

    fetchApiMock.mockResolvedValue({
      json: () => Promise.resolve({ success: true, relative_paths: mockItems }),
    });

    caretHelperInstance.getBeforeCursor.mockReturnValue('model');
    caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });

    const input = document.createElement('textarea');
    document.body.append(input);

    const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
    const autoComplete = new AutoComplete(input,'loras', {
      debounceDelay: 0,
      showPreview: false,
      enableVirtualScroll: true,
      itemHeight: 40,
      visibleItems: 15,
      pageSize: 20,
    });

    input.value = 'model';
    input.dispatchEvent(new Event('input', { bubbles: true }));

    await vi.runAllTimersAsync();
    await Promise.resolve();

    expect(autoComplete.items.length).toBeGreaterThan(0);
    expect(autoComplete.selectedIndex).toBe(0);

    const initialSelectedEl = autoComplete.contentContainer?.querySelector('.comfy-autocomplete-item-selected');
    expect(initialSelectedEl).toBeDefined();

    const arrowDownEvent = new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true });
    input.dispatchEvent(arrowDownEvent);

    expect(autoComplete.selectedIndex).toBe(1);

    const secondSelectedEl = autoComplete.contentContainer?.querySelector('.comfy-autocomplete-item-selected');
    expect(secondSelectedEl).toBeDefined();
    expect(secondSelectedEl?.dataset.index).toBe('1');

    const arrowUpEvent = new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true });
    input.dispatchEvent(arrowUpEvent);

    expect(autoComplete.selectedIndex).toBe(0);

    const firstSelectedElAgain = autoComplete.contentContainer?.querySelector('.comfy-autocomplete-item-selected');
    expect(firstSelectedElAgain).toBeDefined();
    expect(firstSelectedElAgain?.dataset.index).toBe('0');
  });

  it('maintains selection when scrolling to invisible items', async () => {
    vi.useFakeTimers();

    const mockItems = Array.from({ length: 100 }, (_, i) => `item_${i.toString().padStart(3,'0')}.safetensors`);

    fetchApiMock.mockResolvedValue({
      json: () => Promise.resolve({ success: true, relative_paths: mockItems }),
    });

    caretHelperInstance.getBeforeCursor.mockReturnValue('item');
    caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });

    const input = document.createElement('textarea');
    input.style.width = '400px';
    input.style.height = '200px';
    document.body.append(input);

    const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
    const autoComplete = new AutoComplete(input,'loras', {
      debounceDelay: 0,
      showPreview: false,
      enableVirtualScroll: true,
      itemHeight: 40,
      visibleItems: 15,
      pageSize: 20,
    });

    input.value = 'item';
    input.dispatchEvent(new Event('input', { bubbles: true }));

    await vi.runAllTimersAsync();
    await Promise.resolve();

    expect(autoComplete.items.length).toBeGreaterThan(0);

    autoComplete.selectedIndex = 14;

    const scrollTopBefore = autoComplete.scrollContainer?.scrollTop || 0;

    const arrowDownEvent = new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true });
    input.dispatchEvent(arrowDownEvent);

    await vi.runAllTimersAsync();
    await Promise.resolve();

    expect(autoComplete.selectedIndex).toBe(15);

    const selectedEl = autoComplete.contentContainer?.querySelector('.comfy-autocomplete-item-selected');
    expect(selectedEl).toBeDefined();
    expect(selectedEl?.dataset.index).toBe('15');

    const scrollTopAfter = autoComplete.scrollContainer?.scrollTop || 0;
    expect(scrollTopAfter).toBeGreaterThanOrEqual(scrollTopBefore);
  });

  it('replaces entire multi-word phrase when it matches selected tag (Danbooru convention)', async () => {
    const mockTags = [
      { tag_name: 'looking_to_the_side', category: 0, post_count: 1234 },
      { tag_name: 'looking_away', category: 0, post_count: 5678 },
    ];

    fetchApiMock.mockResolvedValue({
      json: () => Promise.resolve({ success: true, words: mockTags }),
    });

    caretHelperInstance.getBeforeCursor.mockReturnValue('looking to the side');
    caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });

    const input = document.createElement('textarea');
    input.value = 'looking to the side';
    input.selectionStart = input.value.length;
    input.focus = vi.fn();
    input.setSelectionRange = vi.fn();
    document.body.append(input);

    const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
    const autoComplete = new AutoComplete(input,'prompt', {
      debounceDelay: 0,
      showPreview: false,
      minChars: 1,
    });

    autoComplete.searchType = 'custom_words';
    autoComplete.activeCommand = null;
    autoComplete.items = mockTags;
    autoComplete.selectedIndex = 0;

    await autoComplete.insertSelection('looking_to_the_side');

    expect(input.value).toBe('looking_to_the_side,');
    expect(autoComplete.dropdown.style.display).toBe('none');
    expect(input.focus).toHaveBeenCalled();
  });

  it('replaces only last token when typing partial match (e.g., "hello 1gi" -> "1girl")', async () => {
    const mockTags = [
      { tag_name: '1girl', category: 4, post_count: 500000 },
      { tag_name: '1boy', category: 4, post_count: 300000 },
    ];

    fetchApiMock.mockResolvedValue({
      json: () => Promise.resolve({ success: true, words: mockTags }),
    });

    caretHelperInstance.getBeforeCursor.mockReturnValue('hello 1gi');
    caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });

    const input = document.createElement('textarea');
    input.value = 'hello 1gi';
    input.selectionStart = input.value.length;
    input.focus = vi.fn();
    input.setSelectionRange = vi.fn();
    document.body.append(input);

    const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
    const autoComplete = new AutoComplete(input,'prompt', {
      debounceDelay: 0,
      showPreview: false,
      minChars: 1,
    });

    autoComplete.searchType = 'custom_words';
    autoComplete.activeCommand = null;
    autoComplete.items = mockTags;
    autoComplete.selectedIndex = 0;
    autoComplete.currentSearchTerm = 'hello 1gi';

    await autoComplete.insertSelection('1girl');

    expect(input.value).toBe('hello 1girl,');
  });

  it('replaces entire phrase for underscore tag match (e.g., "blue hair" -> "blue_hair")', async () => {
    const mockTags = [
      { tag_name: 'blue_hair', category: 0, post_count: 45000 },
      { tag_name: 'blue_eyes', category: 0, post_count: 80000 },
    ];

    fetchApiMock.mockResolvedValue({
      json: () => Promise.resolve({ success: true, words: mockTags }),
    });

    caretHelperInstance.getBeforeCursor.mockReturnValue('blue hair');
    caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });

    const input = document.createElement('textarea');
    input.value = 'blue hair';
    input.selectionStart = input.value.length;
    input.focus = vi.fn();
    input.setSelectionRange = vi.fn();
    document.body.append(input);

    const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
    const autoComplete = new AutoComplete(input,'prompt', {
      debounceDelay: 0,
      showPreview: false,
      minChars: 1,
    });

    autoComplete.searchType = 'custom_words';
    autoComplete.activeCommand = null;
    autoComplete.items = mockTags;
    autoComplete.selectedIndex = 0;
    autoComplete.currentSearchTerm = 'blue hair';

    await autoComplete.insertSelection('blue_hair');

    expect(input.value).toBe('blue_hair,');
  });

  it('handles multi-word phrase with preceding text correctly', async () => {
    const mockTags = [
      { tag_name: 'looking_to_the_side', category: 0, post_count: 1234 },
    ];

    fetchApiMock.mockResolvedValue({
      json: () => Promise.resolve({ success: true, words: mockTags }),
    });

    caretHelperInstance.getBeforeCursor.mockReturnValue('1girl, looking to the side');
    caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });

    const input = document.createElement('textarea');
    input.value = '1girl, looking to the side';
    input.selectionStart = input.value.length;
    input.focus = vi.fn();
    input.setSelectionRange = vi.fn();
    document.body.append(input);

    const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
    const autoComplete = new AutoComplete(input,'prompt', {
      debounceDelay: 0,
      showPreview: false,
      minChars: 1,
    });

    autoComplete.searchType = 'custom_words';
    autoComplete.activeCommand = null;
    autoComplete.items = mockTags;
    autoComplete.selectedIndex = 0;
    autoComplete.currentSearchTerm = 'looking to the side';

    await autoComplete.insertSelection('looking_to_the_side');

    expect(input.value).toBe('1girl, looking_to_the_side,');
  });

  it('replaces entire command and search term when using command mode with multi-word phrase', async () => {
    const mockTags = [
      { tag_name: 'looking_to_the_side', category: 4, post_count: 1234 },
      { tag_name: 'looking_away', category: 4, post_count: 5678 },
    ];

    fetchApiMock.mockResolvedValue({
      json: () => Promise.resolve({ success: true, words: mockTags }),
    });

    // Simulate "/character looking to the side" input
    caretHelperInstance.getBeforeCursor.mockReturnValue('/character looking to the side');
    caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });

    const input = document.createElement('textarea');
    input.value = '/character looking to the side';
    input.selectionStart = input.value.length;
    input.focus = vi.fn();
    input.setSelectionRange = vi.fn();
    document.body.append(input);

    const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
    const autoComplete = new AutoComplete(input,'prompt', {
      debounceDelay: 0,
      showPreview: false,
      minChars: 1,
    });

    // Set up command mode state
    autoComplete.searchType = 'custom_words';
    autoComplete.activeCommand = { categories: [4, 11], label: 'Character' };
    autoComplete.items = mockTags;
    autoComplete.selectedIndex = 0;
    autoComplete.currentSearchTerm = '/character looking to the side';

    await autoComplete.insertSelection('looking_to_the_side');

    // Command part should be replaced along with search term
    expect(input.value).toBe('looking_to_the_side,');
  });

  it('replaces only last token when multi-word query does not exactly match selected tag', async () => {
    const mockTags = [
      { tag_name: 'blue_hair', category: 0, post_count: 45000 },
      { tag_name: 'blue_eyes', category: 0, post_count: 80000 },
    ];

    fetchApiMock.mockResolvedValue({
      json: () => Promise.resolve({ success: true, words: mockTags }),
    });

    // User types "looking to the blue" but selects "blue_hair" (doesn't match entire phrase)
    caretHelperInstance.getBeforeCursor.mockReturnValue('looking to the blue');
    caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });

    const input = document.createElement('textarea');
    input.value = 'looking to the blue';
    input.selectionStart = input.value.length;
    input.focus = vi.fn();
    input.setSelectionRange = vi.fn();
    document.body.append(input);

    const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
    const autoComplete = new AutoComplete(input,'prompt', {
      debounceDelay: 0,
      showPreview: false,
      minChars: 1,
    });

    autoComplete.searchType = 'custom_words';
    autoComplete.activeCommand = null;
    autoComplete.items = mockTags;
    autoComplete.selectedIndex = 0;
    autoComplete.currentSearchTerm = 'looking to the blue';

    await autoComplete.insertSelection('blue_hair');

    // Only "blue" should be replaced, not the entire phrase
    expect(input.value).toBe('looking to the blue_hair,');
  });

  it('handles multiple consecutive spaces in multi-word phrase correctly', async () => {
    const mockTags = [
      { tag_name: 'looking_to_the_side', category: 0, post_count: 1234 },
    ];

    fetchApiMock.mockResolvedValue({
      json: () => Promise.resolve({ success: true, words: mockTags }),
    });

    // Input with multiple spaces between words
    caretHelperInstance.getBeforeCursor.mockReturnValue('looking  to   the side');
    caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });

    const input = document.createElement('textarea');
    input.value = 'looking  to   the side';
    input.selectionStart = input.value.length;
    input.focus = vi.fn();
    input.setSelectionRange = vi.fn();
    document.body.append(input);

    const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
    const autoComplete = new AutoComplete(input,'prompt', {
      debounceDelay: 0,
      showPreview: false,
      minChars: 1,
    });

    autoComplete.searchType = 'custom_words';
    autoComplete.activeCommand = null;
    autoComplete.items = mockTags;
    autoComplete.selectedIndex = 0;
    autoComplete.currentSearchTerm = 'looking  to   the side';

    await autoComplete.insertSelection('looking_to_the_side');

    // Multiple spaces should be normalized to single underscores for matching
    expect(input.value).toBe('looking_to_the_side,');
  });

  it('handles command mode with partial match replacing only last token', async () => {
    const mockTags = [
      { tag_name: 'blue_hair', category: 0, post_count: 45000 },
    ];

    fetchApiMock.mockResolvedValue({
      json: () => Promise.resolve({ success: true, words: mockTags }),
    });

    // Command mode but selected tag doesn't match entire search phrase
    caretHelperInstance.getBeforeCursor.mockReturnValue('/general looking to the blue');
    caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });

    const input = document.createElement('textarea');
    input.value = '/general looking to the blue';
    input.selectionStart = input.value.length;
    input.focus = vi.fn();
    input.setSelectionRange = vi.fn();
    document.body.append(input);

    const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
    const autoComplete = new AutoComplete(input,'prompt', {
      debounceDelay: 0,
      showPreview: false,
      minChars: 1,
    });

    // Command mode with activeCommand
    autoComplete.searchType = 'custom_words';
    autoComplete.activeCommand = { categories: [0, 7], label: 'General' };
    autoComplete.items = mockTags;
    autoComplete.selectedIndex = 0;
    autoComplete.currentSearchTerm = '/general looking to the blue';

    await autoComplete.insertSelection('blue_hair');

    // In command mode, the entire command + search term should be replaced
    expect(input.value).toBe('blue_hair,');
  });

  it('replaces entire phrase when selected tag starts with underscore version of search term (prefix match)', async () => {
    const mockTags = [
      { tag_name: 'looking_to_the_side', category: 0, post_count: 1234 },
    ];

    fetchApiMock.mockResolvedValue({
      json: () => Promise.resolve({ success: true, words: mockTags }),
    });

    // User types partial phrase "looking to the" and selects "looking_to_the_side"
    caretHelperInstance.getBeforeCursor.mockReturnValue('looking to the');
    caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });

    const input = document.createElement('textarea');
    input.value = 'looking to the';
    input.selectionStart = input.value.length;
    input.focus = vi.fn();
    input.setSelectionRange = vi.fn();
    document.body.append(input);

    const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
    const autoComplete = new AutoComplete(input,'prompt', {
      debounceDelay: 0,
      showPreview: false,
      minChars: 1,
    });

    autoComplete.searchType = 'custom_words';
    autoComplete.activeCommand = null;
    autoComplete.items = mockTags;
    autoComplete.selectedIndex = 0;
    autoComplete.currentSearchTerm = 'looking to the';

    await autoComplete.insertSelection('looking_to_the_side');

    // Entire phrase should be replaced with selected tag (with underscores)
    expect(input.value).toBe('looking_to_the_side,');
  });

  it('inserts tag with underscores regardless of space replacement setting', async () => {
    const mockTags = [
      { tag_name: 'blue_hair', category: 0, post_count: 45000 },
    ];

    fetchApiMock.mockResolvedValue({
      json: () => Promise.resolve({ success: true, words: mockTags }),
    });

    caretHelperInstance.getBeforeCursor.mockReturnValue('blue');
    caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });

    const input = document.createElement('textarea');
    input.value = 'blue';
    input.selectionStart = input.value.length;
    input.focus = vi.fn();
    input.setSelectionRange = vi.fn();
    document.body.append(input);

    const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
    const autoComplete = new AutoComplete(input,'prompt', {
      debounceDelay: 0,
      showPreview: false,
      minChars: 1,
    });

    autoComplete.searchType = 'custom_words';
    autoComplete.activeCommand = null;
    autoComplete.items = mockTags;
    autoComplete.selectedIndex = 0;

    await autoComplete.insertSelection('blue_hair');

    // Tag should be inserted with underscores, not spaces
    expect(input.value).toBe('blue_hair,');
  });

  it('omits the trailing comma when the append comma setting is disabled', async () => {
    settingGetMock.mockImplementation((key) => {
      if (key === 'loramanager.autocomplete_append_comma') {
        return false;
      }
      if (key === 'loramanager.prompt_tag_autocomplete') {
        return true;
      }
      if (key === 'loramanager.tag_space_replacement') {
        return false;
      }
      return undefined;
    });

    const mockTags = [
      { tag_name: 'blue_hair', category: 0, post_count: 45000 },
    ];

    caretHelperInstance.getBeforeCursor.mockReturnValue('blue hair');
    caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });

    const input = document.createElement('textarea');
    input.value = 'blue hair';
    input.selectionStart = input.value.length;
    input.focus = vi.fn();
    input.setSelectionRange = vi.fn();
    document.body.append(input);

    const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
    const autoComplete = new AutoComplete(input,'prompt', {
      debounceDelay: 0,
      showPreview: false,
      minChars: 1,
    });

    autoComplete.searchType = 'custom_words';
    autoComplete.activeCommand = null;
    autoComplete.items = mockTags;
    autoComplete.selectedIndex = 0;
    autoComplete.currentSearchTerm = 'blue hair';

    await autoComplete.insertSelection('blue_hair');

    expect(input.value).toBe('blue_hair ');
  });

  it('uses persisted autocomplete metadata as the next search start when comma append is disabled', async () => {
    vi.useFakeTimers();

    settingGetMock.mockImplementation((key) => {
      if (key === 'loramanager.autocomplete_append_comma') {
        return false;
      }
      if (key === 'loramanager.prompt_tag_autocomplete') {
        return true;
      }
      if (key === 'loramanager.tag_space_replacement') {
        return false;
      }
      return undefined;
    });

    fetchApiMock.mockResolvedValue({
      json: () => Promise.resolve({ success: true, words: [{ tag_name: 'cat_ears', category: 0, post_count: 1234 }] }),
    });

    caretHelperInstance.getBeforeCursor.mockReturnValue('1girl cat');
    caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });

    const input = document.createElement('textarea');
    input.value = '1girl cat';
    input.selectionStart = input.value.length;
    input.focus = vi.fn();
    input.setSelectionRange = vi.fn();
    input._autocompleteMetadataWidget = {
      value: {
        version: 1,
        textWidgetName: 'text',
        lastAccepted: {
          start: 0,
          end: 6,
          insertedText: '1girl ',
          textSnapshot: '1girl ',
        },
      },
    };
    document.body.append(input);

    const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
    const autoComplete = new AutoComplete(input,'prompt', {
      debounceDelay: 0,
      showPreview: false,
      minChars: 1,
    });

    expect(autoComplete.getSearchTerm(input.value)).toBe('cat');

    input.dispatchEvent(new Event('input', { bubbles: true }));
    await vi.runAllTimersAsync();
    await Promise.resolve();

    expect(fetchApiMock).toHaveBeenCalledWith('/lm/custom-words/search?enriched=true&search=cat&limit=100');
  });

  it('searches wildcard keys when using the /wildcard command', async () => {
    vi.useFakeTimers();

    fetchApiMock.mockResolvedValue({
      json: () => Promise.resolve({
        success: true,
        words: ['animals/cat'],
        meta: {
          has_wildcards: true,
          wildcards_dir: '/tmp/settings/wildcards',
          supported_formats: ['.txt', '.yaml', '.yml', '.json'],
        },
      }),
    });

    caretHelperInstance.getBeforeCursor.mockReturnValue('/wildcard cat');
    caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });

    const input = document.createElement('textarea');
    input.value = '/wildcard cat';
    input.selectionStart = input.value.length;
    document.body.append(input);

    const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
    const autoComplete = new AutoComplete(input,'prompt', {
      debounceDelay: 0,
      showPreview: false,
      minChars: 1,
    });

    fetchApiMock.mockClear();
    input.dispatchEvent(new Event('input', { bubbles: true }));
    await vi.runAllTimersAsync();
    await Promise.resolve();

    expect(fetchApiMock).toHaveBeenCalledWith('/lm/wildcards/search?search=cat&limit=100');
    expect(autoComplete.searchType).toBe('wildcards');
    expect(autoComplete.items).toEqual(['animals/cat']);
  });

  it('shows wildcard onboarding when /wildcard is used before any files exist', async () => {
    vi.useFakeTimers();

    fetchApiMock.mockResolvedValue({
      json: () => Promise.resolve({
        success: true,
        words: [],
        meta: {
          has_wildcards: false,
          wildcards_dir: '/tmp/settings/wildcards',
          supported_formats: ['.txt', '.yaml', '.yml', '.json'],
        },
      }),
    });

    caretHelperInstance.getBeforeCursor.mockReturnValue('/wildcard cat');
    caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });

    const input = document.createElement('textarea');
    input.value = '/wildcard cat';
    input.selectionStart = input.value.length;
    document.body.append(input);

    const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
    const autoComplete = new AutoComplete(input,'prompt', {
      debounceDelay: 0,
      showPreview: false,
      minChars: 1,
    });

    input.dispatchEvent(new Event('input', { bubbles: true }));
    await vi.runAllTimersAsync();
    await Promise.resolve();

    expect(autoComplete.isVisible).toBe(true);
    expect(autoComplete.items).toHaveLength(1);
    expect(autoComplete.items[0].type).toBe('wildcard_empty_state');
    expect(autoComplete.dropdown.textContent).toContain('No wildcards found yet');
    expect(autoComplete.dropdown.textContent).toContain('/tmp/settings/wildcards');
    expect(autoComplete.dropdown.textContent).toContain('.txt, .yaml, .yml, .json');
  });

  it('shows wildcard onboarding when only the /wildcard command is entered', async () => {
    vi.useFakeTimers();

    fetchApiMock.mockResolvedValue({
      json: () => Promise.resolve({
        success: true,
        words: [],
        meta: {
          has_wildcards: false,
          wildcards_dir: '/tmp/settings/wildcards',
          supported_formats: ['.txt', '.yaml', '.yml', '.json'],
        },
      }),
    });

    caretHelperInstance.getBeforeCursor.mockReturnValue('/wildcard ');
    caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });

    const input = document.createElement('textarea');
    input.value = '/wildcard ';
    input.selectionStart = input.value.length;
    document.body.append(input);

    const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
    const autoComplete = new AutoComplete(input,'prompt', {
      debounceDelay: 0,
      showPreview: false,
      minChars: 1,
    });

    input.dispatchEvent(new Event('input', { bubbles: true }));
    await vi.runAllTimersAsync();
    await Promise.resolve();

    expect(fetchApiMock).toHaveBeenCalledWith('/lm/wildcards/search?search=&limit=100');
    expect(autoComplete.isVisible).toBe(true);
    expect(autoComplete.items).toHaveLength(1);
    expect(autoComplete.items[0].type).toBe('wildcard_empty_state');
    expect(autoComplete.dropdown.textContent).toContain('No wildcards found yet');
  });

  it('shows a lightweight no-match state when wildcard files exist but search misses', async () => {
    vi.useFakeTimers();

    fetchApiMock.mockResolvedValue({
      json: () => Promise.resolve({
        success: true,
        words: [],
        meta: {
          has_wildcards: true,
          wildcards_dir: '/tmp/settings/wildcards',
          supported_formats: ['.txt', '.yaml', '.yml', '.json'],
        },
      }),
    });

    caretHelperInstance.getBeforeCursor.mockReturnValue('/wildcard dragon');
    caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });

    const input = document.createElement('textarea');
    input.value = '/wildcard dragon';
    input.selectionStart = input.value.length;
    document.body.append(input);

    const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
    const autoComplete = new AutoComplete(input,'prompt', {
      debounceDelay: 0,
      showPreview: false,
      minChars: 1,
    });

    input.dispatchEvent(new Event('input', { bubbles: true }));
    await vi.runAllTimersAsync();
    await Promise.resolve();

    expect(autoComplete.items).toHaveLength(1);
    expect(autoComplete.items[0].type).toBe('wildcard_no_matches');
    expect(autoComplete.dropdown.textContent).toContain('No wildcard matches');
    expect(autoComplete.dropdown.textContent).not.toContain('Open wildcards folder');
  });

  it('inserts wildcard references when accepting a /wildcard result', async () => {
    caretHelperInstance.getBeforeCursor.mockReturnValue('/wildcard animals/cat');

    const input = document.createElement('textarea');
    input.value = '/wildcard animals/cat';
    input.selectionStart = input.value.length;
    input.focus = vi.fn();
    input.setSelectionRange = vi.fn();
    document.body.append(input);

    const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
    const autoComplete = new AutoComplete(input,'prompt', {
      debounceDelay: 0,
      showPreview: false,
      minChars: 1,
    });

    autoComplete.searchType = 'wildcards';
    autoComplete.activeCommand = { type: 'wildcard', label: 'Wildcards' };
    autoComplete.items = ['animals/cat'];
    autoComplete.selectedIndex = 0;

    await autoComplete.insertSelection('animals/cat');

    expect(input.value).toBe('__animals/cat__,');
    expect(input.focus).toHaveBeenCalled();
    expect(input.setSelectionRange).toHaveBeenCalled();
  });

  it('does not reopen autocomplete on blur after inserting a wildcard literal', async () => {
    const input = document.createElement('textarea');
    input.value = '__flower__,';
    input.selectionStart = input.value.length;
    document.body.append(input);

    caretHelperInstance.getBeforeCursor.mockReturnValue('__flower__,');

    const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
    const autoComplete = new AutoComplete(input,'prompt', {
      debounceDelay: 0,
      showPreview: false,
      minChars: 1,
    });

    const hideSpy = vi.spyOn(autoComplete, 'hide');
    input.dispatchEvent(new Event('blur', { bubbles: true }));

    expect(fetchApiMock).not.toHaveBeenCalled();
    expect(hideSpy).toHaveBeenCalled();
    expect(autoComplete.isVisible).toBe(false);
  });

  it('treats a command after a wildcard literal as the active token', async () => {
    vi.useFakeTimers();

    fetchApiMock.mockResolvedValue({
      json: () => Promise.resolve({
        success: true,
        words: [{ tag_name: 'flower_field', category: 4, post_count: 1234 }],
      }),
    });

    const input = document.createElement('textarea');
    input.value = '__flower__ /character f';
    input.selectionStart = input.value.length;
    document.body.append(input);

    caretHelperInstance.getBeforeCursor.mockReturnValue('__flower__ /character f');
    caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });

    const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
    const autoComplete = new AutoComplete(input,'prompt', {
      debounceDelay: 0,
      showPreview: false,
      minChars: 1,
    });

    input.dispatchEvent(new Event('input', { bubbles: true }));
    await vi.runAllTimersAsync();
    await Promise.resolve();

    expect(autoComplete.getSearchTerm(input.value)).toBe('/character f');
  });

  it('invalidates stale autocomplete metadata and falls back to delimiter-based matching', async () => {
    settingGetMock.mockImplementation((key) => {
      if (key === 'loramanager.autocomplete_append_comma') {
        return false;
      }
      if (key === 'loramanager.prompt_tag_autocomplete') {
        return true;
      }
      if (key === 'loramanager.tag_space_replacement') {
        return false;
      }
      return undefined;
    });

    caretHelperInstance.getBeforeCursor.mockReturnValue('1boy cat');

    const metadataWidget = {
      value: {
        version: 1,
        textWidgetName: 'text',
        lastAccepted: {
          start: 0,
          end: 6,
          insertedText: '1girl ',
          textSnapshot: '1girl ',
        },
      },
    };

    const input = document.createElement('textarea');
    input.value = '1boy cat';
    input.selectionStart = input.value.length;
    input._autocompleteMetadataWidget = metadataWidget;
    document.body.append(input);

    const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
    const autoComplete = new AutoComplete(input,'prompt', {
      debounceDelay: 0,
      showPreview: false,
      minChars: 1,
    });

    expect(autoComplete.getSearchTerm(input.value)).toBe('1boy cat');
    expect(metadataWidget.value.lastAccepted).toBeUndefined();
  });

  it('does not duplicate the first character when accepting a suggestion after a trailing space', async () => {
    settingGetMock.mockImplementation((key) => {
      if (key === 'loramanager.autocomplete_append_comma') {
        return false;
      }
      if (key === 'loramanager.prompt_tag_autocomplete') {
        return true;
      }
      if (key === 'loramanager.tag_space_replacement') {
        return false;
      }
      return undefined;
    });

    const mockTags = [
      { tag_name: '1girl', category: 4, post_count: 500000 },
    ];

    caretHelperInstance.getBeforeCursor.mockReturnValue('1girl ');

    const input = document.createElement('textarea');
    input.value = '1girl ';
    input.selectionStart = input.value.length;
    input.focus = vi.fn();
    input.setSelectionRange = vi.fn();
    document.body.append(input);

    const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
    const autoComplete = new AutoComplete(input,'prompt', {
      debounceDelay: 0,
      showPreview: false,
      minChars: 1,
    });

    autoComplete.searchType = 'custom_words';
    autoComplete.activeCommand = null;
    autoComplete.items = mockTags;
    autoComplete.selectedIndex = 0;

    await autoComplete.insertSelection('1girl');

    expect(input.value).toBe('1girl ');
  });

  it('treats a newline as a hard boundary after dismissing autocomplete', async () => {
    vi.useFakeTimers();

    fetchApiMock.mockResolvedValue({
      json: () => Promise.resolve({ success: true, words: [{ tag_name: '1girl', category: 4, post_count: 500000 }] }),
    });

    const input = document.createElement('textarea');
    input.value = '1gi\n';
    input.selectionStart = input.value.length;
    document.body.append(input);

    const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
    const autoComplete = new AutoComplete(input,'prompt', {
      debounceDelay: 0,
      showPreview: false,
      minChars: 1,
    });

    caretHelperInstance.getBeforeCursor.mockReturnValue('1gi');
    autoComplete.handleInput('1gi');
    await vi.runAllTimersAsync();
    await Promise.resolve();
    expect(fetchApiMock).toHaveBeenCalled();

    fetchApiMock.mockClear();
    autoComplete.hide();

    caretHelperInstance.getBeforeCursor.mockReturnValue('1gi\n');
    input.dispatchEvent(new Event('input', { bubbles: true }));
    await vi.runAllTimersAsync();
    await Promise.resolve();

    expect(autoComplete.getSearchTerm(input.value)).toBe('');
    expect(fetchApiMock).not.toHaveBeenCalled();
    expect(autoComplete.isVisible).toBe(false);
  });

  it('omits the trailing comma for LoRA insertions when the setting is disabled', async () => {
    settingGetMock.mockImplementation((key) => {
      if (key === 'loramanager.autocomplete_append_comma') {
        return false;
      }
      if (key === 'loramanager.prompt_tag_autocomplete') {
        return true;
      }
      if (key === 'loramanager.tag_space_replacement') {
        return false;
      }
      return undefined;
    });

    fetchApiMock.mockImplementation((url) => {
      if (url.includes('usage-tips-by-path')) {
        return Promise.resolve({
          ok: true,
          json: () => Promise.resolve({
            success: true,
            usage_tips: JSON.stringify({ strength: '1.2' }),
          }),
        });
      }

      return Promise.resolve({
        json: () => Promise.resolve({ success: true, relative_paths: ['models/example.safetensors'] }),
      });
    });

    caretHelperInstance.getBeforeCursor.mockReturnValue('alpha, example');

    const input = document.createElement('textarea');
    input.value = 'alpha, example';
    input.selectionStart = input.value.length;
    input.focus = vi.fn();
    input.setSelectionRange = vi.fn();
    document.body.append(input);

    const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
    const autoComplete = new AutoComplete(input,'loras', { debounceDelay: 0, showPreview: false });

    await autoComplete.insertSelection('models/example.safetensors');

    expect(input.value).toContain('<lora:example:1.2>');
    expect(input.value).not.toContain('<lora:example:1.2>,');
  });

  it('replaces entire phrase when selected tag ends with underscore version of search term (suffix match)', async () => {
    const mockTags = [
      { tag_name: 'looking_to_the_side', category: 0, post_count: 1234 },
    ];

    fetchApiMock.mockResolvedValue({
      json: () => Promise.resolve({ success: true, words: mockTags }),
    });

    // User types suffix "to the side" and selects "looking_to_the_side"
    caretHelperInstance.getBeforeCursor.mockReturnValue('to the side');
    caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });

    const input = document.createElement('textarea');
    input.value = 'to the side';
    input.selectionStart = input.value.length;
    input.focus = vi.fn();
    input.setSelectionRange = vi.fn();
    document.body.append(input);

    const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
    const autoComplete = new AutoComplete(input,'prompt', {
      debounceDelay: 0,
      showPreview: false,
      minChars: 1,
    });

    autoComplete.searchType = 'custom_words';
    autoComplete.activeCommand = null;
    autoComplete.items = mockTags;
    autoComplete.selectedIndex = 0;
    autoComplete.currentSearchTerm = 'to the side';

    await autoComplete.insertSelection('looking_to_the_side');

    // Entire phrase should be replaced with selected tag
    expect(input.value).toBe('looking_to_the_side,');
  });

  it('shows /activefilters command for loras when active-filters autocomplete is off (default)', async () => {
    const input = document.createElement('textarea');
    input.value = '/';
    input.selectionStart = input.value.length;
    document.body.append(input);

    caretHelperInstance.getBeforeCursor.mockReturnValue('/');
    caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });

    const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
    const autoComplete = new AutoComplete(input, 'loras', { showPreview: false, minChars: 1 });

    input.dispatchEvent(new Event('input', { bubbles: true }));

    const commandNames = autoComplete.items.map((item) => item.command);
    expect(commandNames).toContain('/activefilters');
    expect(commandNames).not.toContain('/noactivefilters');
  });

  it('does not trigger preview for command items when selecting the loras command list', async () => {
    // Regression: with showPreview enabled (the default for loras widgets), the
    // auto-selected first command item was passed to showPreviewForItem() as a
    // relative path, crashing on relativePath.split.
    const input = document.createElement('textarea');
    input.value = '/';
    input.selectionStart = input.value.length;
    document.body.append(input);

    caretHelperInstance.getBeforeCursor.mockReturnValue('/');
    caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });

    const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
    const autoComplete = new AutoComplete(input, 'loras', { showPreview: true, minChars: 1 });

    input.dispatchEvent(new Event('input', { bubbles: true }));

    // Allow the async preview tooltip import to resolve
    await Promise.resolve();
    await Promise.resolve();

    const commandNames = autoComplete.items.map((item) => item.command);
    expect(commandNames).toContain('/activefilters');
    expect(previewTooltipMock.show).not.toHaveBeenCalled();
  });

  it('shows /noactivefilters command for loras when active-filters autocomplete is on', async () => {
    settingGetMock.mockImplementation((key) => {
      if (key === 'loramanager.lora_active_filters_autocomplete') {
        return true;
      }
      return undefined;
    });

    const input = document.createElement('textarea');
    input.value = '/';
    input.selectionStart = input.value.length;
    document.body.append(input);

    caretHelperInstance.getBeforeCursor.mockReturnValue('/');
    caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });

    const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
    const autoComplete = new AutoComplete(input, 'loras', { showPreview: false, minChars: 1 });

    input.dispatchEvent(new Event('input', { bubbles: true }));

    const commandNames = autoComplete.items.map((item) => item.command);
    expect(commandNames).toContain('/noactivefilters');
    expect(commandNames).not.toContain('/activefilters');
  });

  it('toggles the active-filters setting when /activefilters alias is used', async () => {
    const input = document.createElement('textarea');
    input.value = '/activefilters';
    input.selectionStart = input.value.length;
    input.focus = vi.fn();
    input.setSelectionRange = vi.fn();
    document.body.append(input);

    caretHelperInstance.getBeforeCursor.mockReturnValue('/activefilters');
    caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });

    const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
    const autoComplete = new AutoComplete(input, 'loras', { showPreview: false, minChars: 1 });

    const commandResult = autoComplete._parseCommandInput('/activefilters');
    expect(commandResult.command).toBeDefined();
    expect(commandResult.command.type).toBe('toggle_setting');
    expect(commandResult.command.value).toBe(true);

    await autoComplete._handleToggleSettingCommand(commandResult.command);

    expect(settingSetMock).toHaveBeenCalledWith('loramanager.lora_active_filters_autocomplete', true);
  });

  it('toggles the active-filters setting when /activefilters is accepted', async () => {
    const input = document.createElement('textarea');
    input.value = '/';
    input.selectionStart = input.value.length;
    input.focus = vi.fn();
    input.setSelectionRange = vi.fn();
    document.body.append(input);

    caretHelperInstance.getBeforeCursor.mockReturnValue('/');
    caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });

    const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
    const autoComplete = new AutoComplete(input, 'loras', { showPreview: false, minChars: 1 });

    input.dispatchEvent(new Event('input', { bubbles: true }));

    const afItem = autoComplete.items.find((item) => item.command === '/activefilters');
    expect(afItem).toBeDefined();

    // Simulate the input being cleared after the command is accepted so the
    // cleared-token input event does not re-trigger command parsing.
    caretHelperInstance.getBeforeCursor.mockReturnValue('');
    await autoComplete._handleToggleSettingCommand(afItem);

    expect(settingSetMock).toHaveBeenCalledWith('loramanager.lora_active_filters_autocomplete', true);
  });

  it('appends active filter params to loras autocomplete requests when enabled', async () => {
    vi.useFakeTimers();

    settingGetMock.mockImplementation((key) => {
      if (key === 'loramanager.lora_active_filters_autocomplete') {
        return true;
      }
      return undefined;
    });

    localStorage.setItem('lora_manager_loras_filters', JSON.stringify({
      baseModel: ['SD 1.5'],
      tags: { anime: 'include', nsfw: 'exclude', __no_tags__: 'exclude' },
      autoTags: { I2V: 'include' },
      modelTypes: ['standard'],
      tagLogic: 'all',
      license: { noCredit: 'include', allowSelling: 'exclude' },
    }));
    localStorage.setItem('lora_manager_loras_activeFolder', 'MyLoras');
    localStorage.setItem('lora_manager_loras_recursiveSearch', 'true');

    fetchApiMock.mockResolvedValue({
      json: () => Promise.resolve({ success: true, relative_paths: ['models/example.safetensors'] }),
    });

    caretHelperInstance.getBeforeCursor.mockReturnValue('example');
    caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });

    const input = document.createElement('textarea');
    document.body.append(input);

    const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
    new AutoComplete(input, 'loras', { debounceDelay: 0, showPreview: false, minChars: 1 });

    input.value = 'example';
    input.dispatchEvent(new Event('input', { bubbles: true }));

    await vi.runAllTimersAsync();
    await Promise.resolve();

    const calledUrl = fetchApiMock.mock.calls[0][0];
    expect(calledUrl).toContain('/lm/loras/relative-paths?search=example&limit=100');
    expect(calledUrl).toContain('folder=MyLoras');
    expect(calledUrl).toContain('recursive=true');
    expect(calledUrl).toContain('tag_include=anime');
    expect(calledUrl).toContain('tag_exclude=nsfw');
    expect(calledUrl).toContain('tag_exclude=__no_tags__');
    expect(calledUrl).toContain('auto_tag_include=I2V');
    expect(calledUrl).toContain('tag_logic=all');
    expect(calledUrl).toContain('credit_required=false');
    expect(calledUrl).toContain('allow_selling_generated_content=false');
    const parsed = new URL(calledUrl, 'https://example.com');
    expect(parsed.searchParams.get('base_model')).toBe('SD 1.5');
    expect(parsed.searchParams.get('model_type')).toBe('standard');
  });

  it('keeps the default loras autocomplete URL when active-filters mode is off', async () => {
    vi.useFakeTimers();

    fetchApiMock.mockResolvedValue({
      json: () => Promise.resolve({ success: true, relative_paths: ['models/example.safetensors'] }),
    });

    caretHelperInstance.getBeforeCursor.mockReturnValue('example');
    caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });

    const input = document.createElement('textarea');
    document.body.append(input);

    const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
    new AutoComplete(input, 'loras', { debounceDelay: 0, showPreview: false, minChars: 1 });

    input.value = 'example';
    input.dispatchEvent(new Event('input', { bubbles: true }));

    await vi.runAllTimersAsync();
    await Promise.resolve();

    expect(fetchApiMock).toHaveBeenCalledWith('/lm/loras/relative-paths?search=example&limit=100');
  });

  it('sends the filter-pipeline signal even when no filters are stored', async () => {
    // Regression: with filter mode on but no folder/filters stored, the request
    // carried no params, so the backend skipped the filter pipeline and global
    // settings like show_only_sfw diverged from the list endpoint.
    vi.useFakeTimers();

    settingGetMock.mockImplementation((key) => {
      if (key === 'loramanager.lora_active_filters_autocomplete') {
        return true;
      }
      return undefined;
    });

    localStorage.removeItem('lora_manager_loras_filters');
    localStorage.removeItem('lora_manager_loras_activeFolder');
    localStorage.removeItem('lora_manager_loras_recursiveSearch');

    fetchApiMock.mockResolvedValue({
      json: () => Promise.resolve({ success: true, relative_paths: ['models/example.safetensors'] }),
    });

    caretHelperInstance.getBeforeCursor.mockReturnValue('example');
    caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });

    const input = document.createElement('textarea');
    document.body.append(input);

    const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
    new AutoComplete(input, 'loras', { debounceDelay: 0, showPreview: false, minChars: 1 });

    input.value = 'example';
    input.dispatchEvent(new Event('input', { bubbles: true }));

    await vi.runAllTimersAsync();
    await Promise.resolve();

    const calledUrl = fetchApiMock.mock.calls[0][0];
    expect(calledUrl).toContain('recursive=true');
  });

  it('omits folder param when active folder is root and recursion is enabled', async () => {
    vi.useFakeTimers();

    settingGetMock.mockImplementation((key) => {
      if (key === 'loramanager.lora_active_filters_autocomplete') {
        return true;
      }
      return undefined;
    });

    localStorage.setItem('lora_manager_loras_filters', JSON.stringify({
      baseModel: ['SD 1.5'],
      tags: { anime: 'include' },
    }));
    localStorage.setItem('lora_manager_loras_activeFolder', '');
    localStorage.removeItem('lora_manager_loras_recursiveSearch');

    fetchApiMock.mockResolvedValue({
      json: () => Promise.resolve({ success: true, relative_paths: ['models/example.safetensors'] }),
    });

    caretHelperInstance.getBeforeCursor.mockReturnValue('example');
    caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });

    const input = document.createElement('textarea');
    document.body.append(input);

    const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
    new AutoComplete(input, 'loras', { debounceDelay: 0, showPreview: false, minChars: 1 });

    input.value = 'example';
    input.dispatchEvent(new Event('input', { bubbles: true }));

    await vi.runAllTimersAsync();
    await Promise.resolve();

    const calledUrl = fetchApiMock.mock.calls[0][0];
    expect(calledUrl).not.toContain('folder=');
    expect(calledUrl).toContain('recursive=true');
  });

  it('sends an empty folder param for root with recursion disabled, mirroring the page list', async () => {
    vi.useFakeTimers();

    settingGetMock.mockImplementation((key) => {
      if (key === 'loramanager.lora_active_filters_autocomplete') {
        return true;
      }
      return undefined;
    });

    localStorage.setItem('lora_manager_loras_filters', JSON.stringify({
      baseModel: ['SD 1.5'],
      tags: { anime: 'include' },
    }));
    localStorage.setItem('lora_manager_loras_activeFolder', '');
    localStorage.setItem('lora_manager_loras_recursiveSearch', 'false');

    fetchApiMock.mockResolvedValue({
      json: () => Promise.resolve({ success: true, relative_paths: ['models/example.safetensors'] }),
    });

    caretHelperInstance.getBeforeCursor.mockReturnValue('example');
    caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });

    const input = document.createElement('textarea');
    document.body.append(input);

    const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
    new AutoComplete(input, 'loras', { debounceDelay: 0, showPreview: false, minChars: 1 });

    input.value = 'example';
    input.dispatchEvent(new Event('input', { bubbles: true }));

    await vi.runAllTimersAsync();
    await Promise.resolve();

    const calledUrl = fetchApiMock.mock.calls[0][0];
    expect(calledUrl).toContain('folder=');
    expect(calledUrl).toContain('recursive=false');
    const parsed = new URL(calledUrl, 'https://example.com');
    expect(parsed.searchParams.get('folder')).toBe('');
  });

  it('applies the active folder even when no filter-panel filters are set', async () => {
    // Regression: folder was skipped when lora_manager_loras_filters was
    // missing because the filters key gate returned early.
    vi.useFakeTimers();

    settingGetMock.mockImplementation((key) => {
      if (key === 'loramanager.lora_active_filters_autocomplete') {
        return true;
      }
      return undefined;
    });

    localStorage.removeItem('lora_manager_loras_filters');
    localStorage.setItem('lora_manager_loras_activeFolder', 'Flux.1 D/style');

    fetchApiMock.mockResolvedValue({
      json: () => Promise.resolve({ success: true, relative_paths: ['Flux.1 D/style/3D_Fairytales.safetensors'] }),
    });

    caretHelperInstance.getBeforeCursor.mockReturnValue('3D');
    caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });

    const input = document.createElement('textarea');
    document.body.append(input);

    const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
    new AutoComplete(input, 'loras', { debounceDelay: 0, showPreview: false, minChars: 1 });

    input.value = '3D';
    input.dispatchEvent(new Event('input', { bubbles: true }));

    await vi.runAllTimersAsync();
    await Promise.resolve();

    const calledUrl = fetchApiMock.mock.calls[0][0];
    expect(calledUrl).toContain('folder=Flux.1+D%2Fstyle');
    expect(calledUrl).toContain('recursive=true');
  });

  describe('discoverability hints', () => {
    beforeEach(() => {
      localStorage.clear();
    });

    const typeSlashCommand = async () => {
      const input = document.createElement('textarea');
      input.value = '/';
      input.selectionStart = 1;
      document.body.append(input);

      caretHelperInstance.getBeforeCursor.mockReturnValue('/');

      const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
      const autoComplete = new AutoComplete(input, 'prompt', { showPreview: false, minChars: 1 });

      input.dispatchEvent(new Event('input', { bubbles: true }));
      return autoComplete;
    };

    it('shows the current autocomplete state below the slash command list', async () => {
      const autoComplete = await typeSlashCommand();

      const footer = autoComplete.dropdown.querySelector('.lm-autocomplete-command-footer');
      expect(footer).not.toBeNull();
      expect(footer.textContent).toContain('/noautocomplete to disable');
    });

    it('shows how to re-enable autocomplete in the footer when it is off', async () => {
      settingGetMock.mockImplementation((key) => {
        if (key === 'loramanager.prompt_tag_autocomplete') {
          return false;
        }
        return undefined;
      });

      const autoComplete = await typeSlashCommand();

      const footer = autoComplete.dropdown.querySelector('.lm-autocomplete-command-footer');
      expect(footer).not.toBeNull();
      expect(footer.textContent).toContain('/autocomplete to enable');
    });

    it('stays silent when typing with tag autocomplete disabled', async () => {
      settingGetMock.mockImplementation((key) => {
        if (key === 'loramanager.prompt_tag_autocomplete') {
          return false;
        }
        if (key === 'loramanager.autocomplete_accept_key') {
          return 'both';
        }
        return undefined;
      });

      const input = document.createElement('textarea');
      input.value = 'hello';
      input.selectionStart = 5;
      document.body.append(input);

      caretHelperInstance.getBeforeCursor.mockReturnValue('hello');

      const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
      const autoComplete = new AutoComplete(input, 'prompt', { showPreview: false, minChars: 1 });

      input.dispatchEvent(new Event('input', { bubbles: true }));

      expect(autoComplete.isVisible).toBe(false);
      expect(fetchApiMock).not.toHaveBeenCalled();
    });

    it('shows a dismissible first-run hint on tag suggestions and remembers dismissal', async () => {
      vi.useFakeTimers();

      fetchApiMock.mockResolvedValue({
        json: () => Promise.resolve({
          success: true,
          words: [{ tag_name: '1girl', category: 4, post_count: 500000 }],
        }),
      });

      caretHelperInstance.getBeforeCursor.mockReturnValue('1gi');

      const triggerSearch = async () => {
        const input = document.createElement('textarea');
        input.value = '1gi';
        input.selectionStart = 3;
        document.body.append(input);

        const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
        const autoComplete = new AutoComplete(input, 'prompt', {
          debounceDelay: 0,
          showPreview: false,
          minChars: 1,
        });

        input.dispatchEvent(new Event('input', { bubbles: true }));
        await vi.runAllTimersAsync();
        await Promise.resolve();
        return autoComplete;
      };

      const autoComplete = await triggerSearch();

      const hint = autoComplete.dropdown.querySelector('.lm-autocomplete-first-run-hint');
      expect(hint).not.toBeNull();
      expect(hint.textContent).toContain('/noautocomplete');

      hint.querySelector('button').click();

      expect(autoComplete.dropdown.querySelector('.lm-autocomplete-first-run-hint')).toBeNull();
      expect(localStorage.getItem('lm:autocomplete-disable-tip-dismissed')).toBe('1');

      // A fresh instance no longer shows the hint once dismissed
      const autoComplete2 = await triggerSearch();
      expect(autoComplete2.dropdown.querySelector('.lm-autocomplete-first-run-hint')).toBeNull();
    });
  });
});
