import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { describe, expect, it, vi } from 'vitest';
import {
  decodeRichText, decodeRichTextDocument, encodeRichText, MAX_RICH_TEXT_CHARACTERS,
  MAX_RICH_TEXT_ENCODED_CHARACTERS, MAX_RICH_TEXT_SEGMENT_CHARACTERS, MAX_RICH_TEXT_SEGMENTS,
  mergeRichTextVariableCatalog, RICH_TEXT_CLIPBOARD_MIME, RICH_TEXT_PREFIX, RichTextEditor, richTextIsValid, richTextPlainText,
  type RichTextVariableDefinition,
} from './rich-text-editor';

function pointAtOffset(root: HTMLElement, requestedOffset: number): { node: Node; offset: number } {
  const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
  let remaining = requestedOffset;
  let last: Text | undefined;
  for (let current = walker.nextNode() as Text | null; current; current = walker.nextNode() as Text | null) {
    last = current;
    if (remaining <= current.data.length) return { node: current, offset: remaining };
    remaining -= current.data.length;
  }
  return last ? { node: last, offset: last.data.length } : { node: root, offset: 0 };
}

function selectEditorText(root: HTMLElement, startOffset: number, endOffset: number): void {
  const start = pointAtOffset(root, startOffset);
  const end = pointAtOffset(root, endOffset);
  const range = document.createRange();
  range.setStart(start.node, start.offset);
  range.setEnd(end.node, end.offset);
  const selection = window.getSelection();
  selection?.removeAllRanges();
  selection?.addRange(range);
  document.dispatchEvent(new Event('selectionchange'));
  fireEvent.mouseUp(root);
}

function replaceEditorDomText(root: HTMLElement, text: string, caret = text.length): void {
  root.textContent = text;
  selectEditorText(root, caret, caret);
}

function editorSelectionOffsets(root: HTMLElement): { start: number; end: number } | undefined {
  const selection = window.getSelection();
  if (!selection || selection.rangeCount === 0) return undefined;
  const range = selection.getRangeAt(0);
  if (!root.contains(range.startContainer) || !root.contains(range.endContainer)) return undefined;
  const start = document.createRange();
  start.selectNodeContents(root);
  start.setEnd(range.startContainer, range.startOffset);
  const end = document.createRange();
  end.selectNodeContents(root);
  end.setEnd(range.endContainer, range.endOffset);
  return { start: start.toString().length, end: end.toString().length };
}

function selectionToolbar(): HTMLElement {
  return screen.getByLabelText('所选文本工具栏 / Selected text toolbar');
}

function catalogVariable(key: string, overrides: Partial<RichTextVariableDefinition> = {}): RichTextVariableDefinition {
  return {
    key, nameZh: `变量 ${key}`, nameEn: `Variable ${key}`,
    descriptionZh: `变量 ${key} 的中文说明`, descriptionEn: `English description for ${key}`,
    category: 'server', scopes: ['*'], example: `{${key}}`, sampleValue: `value-${key}`,
    templateAllowed: true, conditionAllowed: true, ...overrides,
  };
}

describe('RichTextEditor', () => {
  it('preserves literal technical strings through typing, line breaks, and plain paste when opted in', () => {
    const changes = vi.fn();
    function Harness() {
      const [value, setValue] = useState('{player.name} == "Alex"');
      return <><RichTextEditor value={value} preservePlainText onChange={(next) => { changes(next); setValue(next); }} />
        <output data-testid="literal-value">{value}</output></>;
    }
    render(<Harness />);
    const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
    replaceEditorDomText(editor, '{player.name} == "Alex" && ready');
    fireEvent.input(editor, { data: ' && ready', inputType: 'insertText' });
    expect(changes).toHaveBeenLastCalledWith('{player.name} == "Alex" && ready');
    fireEvent.keyDown(editor, { key: 'Enter' });
    expect(changes).toHaveBeenLastCalledWith('{player.name} == "Alex" && ready\n');
    fireEvent.paste(editor, { clipboardData: { getData: (type: string) => type === 'text/plain' ? 'minecraft:diamond_sword' : '' } });
    expect(screen.getByTestId('literal-value').textContent).toBe('{player.name} == "Alex" && ready\nminecraft:diamond_sword');
    expect(changes.mock.calls.every(([next]) => !(next as string).startsWith(RICH_TEXT_PREFIX))).toBe(true);
  });

  it('retains the previous encoded-on-edit behavior unless preservePlainText is enabled', () => {
    const changes = vi.fn();
    render(<RichTextEditor value="plain" onChange={changes} />);
    const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
    replaceEditorDomText(editor, 'plain text');
    fireEvent.input(editor, { data: ' text', inputType: 'insertText' });
    const next = changes.mock.calls.at(-1)?.[0] as string;
    expect(next.startsWith(RICH_TEXT_PREFIX)).toBe(true);
    expect(richTextPlainText(next)).toBe('plain text');
  });

  it('upgrades a preserved string when formatting is added and never silently flattens it again', async () => {
    function Harness() {
      const [value, setValue] = useState('Styled');
      return <><RichTextEditor value={value} onChange={setValue} preservePlainText />
        <output data-testid="styled-literal-value">{value}</output></>;
    }
    const user = userEvent.setup();
    render(<Harness />);
    const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
    selectEditorText(editor, 0, 6);
    const bold = within(selectionToolbar()).getByTitle('粗体 / Bold');
    await user.click(bold);
    let next = screen.getByTestId('styled-literal-value').textContent ?? '';
    expect(next.startsWith(RICH_TEXT_PREFIX)).toBe(true);
    expect(decodeRichText(next)[0]).toMatchObject({ text: 'Styled', bold: true });
    await user.click(bold);
    next = screen.getByTestId('styled-literal-value').textContent ?? '';
    expect(next.startsWith(RICH_TEXT_PREFIX)).toBe(true);
    expect(decodeRichText(next)[0].bold).toBe(false);
  });

  it('keeps an existing unstyled rich document encoded with preservePlainText', () => {
    const changes = vi.fn();
    render(<RichTextEditor value={encodeRichText(decodeRichText('already rich'))} onChange={changes} preservePlainText />);
    const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
    replaceEditorDomText(editor, 'already rich!');
    fireEvent.input(editor, { data: '!', inputType: 'insertText' });
    expect((changes.mock.calls.at(-1)?.[0] as string).startsWith(RICH_TEXT_PREFIX)).toBe(true);
  });

  it.each([
    ['所选文字悬停说明 / Selected text hover details', 'More information'],
    ['所选文字插入内容 / Selected text insertion', 'insert value'],
    ['所选文字点击动作 / Selected text click action', 'copy_to_clipboard'],
    ['所选文字颜色 / Selected text color', '#ff0000'],
    ['浮点变量保留位数 / Floating-point fraction digits', '4'],
  ])('encodes preserved text when %s becomes non-default', (label, targetValue) => {
    const changes = vi.fn();
    render(<RichTextEditor value="literal" onChange={changes} preservePlainText />);
    selectEditorText(screen.getByRole('textbox', { name: '富文本内容 / Rich text content' }), 0, 7);
    fireEvent.change(screen.getByLabelText(label), { target: { value: targetValue } });
    expect(changes).toHaveBeenCalledTimes(1);
    expect((changes.mock.calls[0][0] as string).startsWith(RICH_TEXT_PREFIX)).toBe(true);
    expect(richTextPlainText(changes.mock.calls[0][0] as string)).toBe('literal');
  });

  it('encodes an explicitly configured sender even if it matches the displayed default', () => {
    const changes = vi.fn();
    render(<RichTextEditor value="literal" onChange={changes} preservePlainText />);
    fireEvent.click(screen.getByLabelText('使用全局默认发送者 / Use global default sender'));
    const next = changes.mock.calls.at(-1)?.[0] as string;
    expect(next.startsWith(RICH_TEXT_PREFIX)).toBe(true);
    expect(decodeRichTextDocument(next).sender?.text).toBe('XFEServerManager');
  });

  it('makes read-only content and every mutation control non-editable while still allowing copy', () => {
    const changes = vi.fn();
    const value = encodeRichText([{ ...decodeRichText('Read only')[0], clickAction: 'copy_to_clipboard', clickValue: 'copied' }], {
      visible: true, text: 'Custom', color: '#abcdef', bold: true, italic: false, underlined: false, strikethrough: false,
    });
    const { container } = render(<RichTextEditor value={value} onChange={changes} readOnly />);
    const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
    expect(editor).toHaveAttribute('contenteditable', 'false');
    expect(editor).toHaveAttribute('aria-readonly', 'true');
    selectEditorText(editor, 0, 9);
    for (const control of container.querySelectorAll('button,input,select,textarea')) expect(control).toBeDisabled();
    fireEvent.change(screen.getByLabelText('浮点变量保留位数 / Floating-point fraction digits'), { target: { value: '6' } });
    fireEvent.change(screen.getByLabelText('所选文字悬停说明 / Selected text hover details'), { target: { value: 'forged' } });
    fireEvent.keyDown(editor, { key: 'b', ctrlKey: true });
    fireEvent.keyDown(editor, { key: 'Enter' });
    const clipboardData = { getData: vi.fn(() => 'injected'), setData: vi.fn() };
    expect(fireEvent.paste(editor, { clipboardData })).toBe(false);
    expect(clipboardData.getData).not.toHaveBeenCalled();
    fireEvent.copy(editor, { clipboardData });
    expect(clipboardData.setData).toHaveBeenCalledWith('text/plain', 'Read only');
    replaceEditorDomText(editor, 'forged DOM');
    fireEvent.input(editor, { data: 'forged DOM', inputType: 'insertText' });
    expect(editor).toHaveTextContent('Read only');
    expect(changes).not.toHaveBeenCalled();
  });

  it('cancels pending IME commits when changed to read-only and allows a later editable session', async () => {
    const changes = vi.fn();
    const { rerender } = render(<RichTextEditor value="原文" onChange={changes} preservePlainText />);
    const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
    fireEvent.compositionStart(editor, { data: '' });
    replaceEditorDomText(editor, '原文修改');
    fireEvent.compositionEnd(editor, { data: '修改' });
    rerender(<RichTextEditor value="原文" onChange={changes} preservePlainText readOnly />);
    await new Promise((resolve) => setTimeout(resolve, 5));
    expect(changes).not.toHaveBeenCalled();
    expect(editor).toHaveTextContent('原文');
    fireEvent(editor, new InputEvent('beforeinput', { bubbles: true, cancelable: true, data: '!', inputType: 'insertText' }));
    fireEvent.compositionStart(editor, { data: '' });
    replaceEditorDomText(editor, '不应保存');
    fireEvent.compositionEnd(editor, { data: '不应保存' });
    expect(changes).not.toHaveBeenCalled();
    expect(editor).toHaveTextContent('原文');
    rerender(<RichTextEditor value="原文" onChange={changes} preservePlainText />);
    replaceEditorDomText(editor, '原文!');
    fireEvent.input(editor, { data: '!', inputType: 'insertText' });
    expect(changes).toHaveBeenLastCalledWith('原文!');
  });

  it('upgrades legacy text and preserves Minecraft text styles and events', async () => {
    const change = vi.fn();
    const user = userEvent.setup();
    render(<RichTextEditor value="Welcome {player}" onChange={change} />);

    expect(richTextPlainText('Welcome {player}')).toBe('Welcome {player}');
    const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
    selectEditorText(editor, 0, 7);
    await user.click(within(selectionToolbar()).getByTitle('粗体 / Bold'));
    const encoded = change.mock.calls.at(-1)?.[0] as string;
    const decoded = decodeRichText(encoded);
    expect(richTextPlainText(encoded)).toBe('Welcome {player}');
    expect(decoded).toHaveLength(2);
    expect(decoded[0]).toMatchObject({ text: 'Welcome', bold: true, color: '#ffffff' });
    expect(decoded[1]).toMatchObject({ text: ' {player}', bold: false, color: '#ffffff' });
    expect(decodeRichTextDocument(encoded).sender).toBeUndefined();
  });

  it('defaults message numbers to two decimals and persists a custom precision', async () => {
    function Harness() {
      const [value, setValue] = useState('X={player.x}');
      return <><RichTextEditor value={value} onChange={setValue} />
        <output data-testid="precision-value">{value}</output></>;
    }
    const user = userEvent.setup();
    render(<Harness />);

    const precision = screen.getByLabelText('浮点变量保留位数 / Floating-point fraction digits');
    expect(precision).toHaveValue('2');
    await user.selectOptions(precision, '4');
    expect(decodeRichTextDocument(screen.getByTestId('precision-value').textContent ?? '').numberPrecision)
      .toBe(4);
  });

  it('inherits the server default until the sender is explicitly customized', async () => {
    function Harness() {
      const [value, setValue] = useState('News');
      return <><RichTextEditor value={value} onChange={setValue} defaultSenderName="Network Default" />
        <output data-testid="rich-value">{value}</output></>;
    }
    const user = userEvent.setup();
    render(<Harness />);

    expect(screen.getByLabelText('Minecraft rich text preview')).toHaveTextContent('[Network Default] News');
    expect(screen.getByLabelText('使用全局默认发送者 / Use global default sender')).toBeChecked();
    await user.click(screen.getByLabelText('使用全局默认发送者 / Use global default sender'));
    const sender = screen.getByLabelText('发送者名称 / Sender name');
    await user.clear(sender);
    await user.type(sender, 'Server News');
    const document = decodeRichTextDocument(screen.getByTestId('rich-value').textContent ?? '');
    expect(document.sender).toMatchObject({ visible: true, text: 'Server News', color: '#71e6a1' });

    await user.click(screen.getByLabelText('使用全局默认发送者 / Use global default sender'));
    const inherited = decodeRichTextDocument(screen.getByTestId('rich-value').textContent ?? '');
    expect(inherited.sender).toBeUndefined();
    expect(screen.getByLabelText('Minecraft rich text preview')).toHaveTextContent('[Network Default] News');
  });

  it('preserves an explicitly hidden sender while editing message content', async () => {
    const hidden = {
      visible: false, text: 'Hidden sender', color: '#71e6a1', bold: false,
      italic: false, underlined: false, strikethrough: false,
    };
    function Harness() {
      const [value, setValue] = useState(encodeRichText(decodeRichText('News'), hidden));
      return <><RichTextEditor value={value} onChange={setValue} />
        <output data-testid="hidden-value">{value}</output></>;
    }
    const user = userEvent.setup();
    render(<Harness />);

    expect(screen.getByLabelText('使用全局默认发送者 / Use global default sender')).not.toBeChecked();
    expect(screen.getByLabelText('Minecraft rich text preview')).not.toHaveTextContent('[Hidden sender]');
    selectEditorText(screen.getByRole('textbox', { name: '富文本内容 / Rich text content' }), 0, 4);
    await user.click(within(selectionToolbar()).getByTitle('粗体 / Bold'));
    expect(decodeRichTextDocument(screen.getByTestId('hidden-value').textContent ?? '').sender)
      .toMatchObject({ visible: false, text: 'Hidden sender' });
  });

  it('previews a blank explicit sender with the same global fallback as the server', () => {
    const explicitBlank = {
      visible: true, text: '   ', color: '#71e6a1', bold: false,
      italic: false, underlined: false, strikethrough: false,
    };

    render(<RichTextEditor value={encodeRichText(decodeRichText('News'), explicitBlank)}
      defaultSenderName="Network Default" onChange={vi.fn()} />);

    expect(screen.getByLabelText('Minecraft rich text preview')).toHaveTextContent('[Network Default] News');
  });

  it('round-trips safe URL and hover details', () => {
    const encoded = encodeRichText([{
      text: 'Rules', color: '#71e6a1', bold: false, italic: true,
      underlined: true, strikethrough: false, obfuscated: false, insertion: 'rules', clickAction: 'open_url',
      clickValue: 'https://example.com/rules', hoverText: 'Open server rules',
    }]);
    expect(decodeRichText(encoded)[0]).toMatchObject({
      text: 'Rules', italic: true, underlined: true,
      clickAction: 'open_url', clickValue: 'https://example.com/rules',
      hoverText: 'Open server rules', insertion: 'rules',
    });
  });

  it('loads old multi-run envelopes and applies formatting across run boundaries without data loss', async () => {
    const alpha = { ...decodeRichText('Alpha')[0], text: 'Alpha', bold: true, color: '#ff5555' };
    const beta = { ...decodeRichText('Beta')[0], text: 'Beta', clickAction: 'open_url' as const,
      clickValue: 'https://example.com', hoverText: 'Beta link' };
    const initial = encodeRichText([
      alpha,
      beta,
    ]);
    function Harness() {
      const [value, setValue] = useState(initial);
      return <><RichTextEditor value={value} onChange={setValue} /><output data-testid="rich-value">{value}</output></>;
    }
    const user = userEvent.setup();
    render(<Harness />);

    const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
    expect(editor).toHaveTextContent('AlphaBeta');
    expect(screen.getByTestId('rich-value')).toHaveTextContent(initial);
    selectEditorText(editor, 2, 7);
    await user.click(within(selectionToolbar()).getByTitle('下划线 / Underline'));

    const decoded = decodeRichText(screen.getByTestId('rich-value').textContent ?? '');
    expect(decoded.map((item) => item.text).join('')).toBe('AlphaBeta');
    expect(decoded).toEqual([
      expect.objectContaining({ text: 'Al', bold: true, underlined: false, color: '#ff5555' }),
      expect.objectContaining({ text: 'pha', bold: true, underlined: true, color: '#ff5555' }),
      expect.objectContaining({ text: 'Be', underlined: true, clickAction: 'open_url',
        clickValue: 'https://example.com', hoverText: 'Beta link' }),
      expect.objectContaining({ text: 'ta', underlined: false, clickAction: 'open_url',
        clickValue: 'https://example.com', hoverText: 'Beta link' }),
    ]);
  });

  it('enforces the aggregate visible-text limit and rejects edits that exceed it', async () => {
    const base = decodeRichText('')[0];
    const initial = encodeRichText(Array.from({ length: 5 }, () => ({ ...base, text: 'x'.repeat(1638) })));
    const change = vi.fn();
    const user = userEvent.setup();
    render(<RichTextEditor value={initial} onChange={change} />);

    expect(screen.getByText((content) => content.includes(`文本字符 / Text characters: 8190 / ${MAX_RICH_TEXT_CHARACTERS}`)))
      .toBeInTheDocument();
    await user.click(screen.getByRole('button', { name: /插入变量 \/ Insert variable/ }));
    await user.click(screen.getByRole('button', { name: /插入变量 \{player\}/ }));

    expect(change).not.toHaveBeenCalled();
    expect(screen.getByRole('alert')).toHaveTextContent('文本总长度不能超过 8192 个字符');
  });

  it('rejects documents whose bounded auxiliary fields exceed the server encoded-size limit', () => {
    const base = decodeRichText('x')[0];
    const value = encodeRichText(Array.from({ length: 32 }, () => ({
      ...base,
      insertion: 'i'.repeat(1024),
      clickAction: 'copy_to_clipboard' as const,
      clickValue: 'c'.repeat(1024),
      hoverText: 'h'.repeat(1024),
    })));

    expect(value.length).toBeGreaterThan(MAX_RICH_TEXT_ENCODED_CHARACTERS);
    expect(richTextIsValid(value)).toBe(false);
    render(<RichTextEditor value={value} onChange={vi.fn()} />);
    expect(screen.getByRole('alert')).toHaveTextContent('富文本编码不能超过 65536 个字符');
  });

  it('flags unsafe click targets and command values', () => {
    const base = decodeRichText('')[0];
    const value = encodeRichText([
      { ...base, text: 'Website', clickAction: 'open_url', clickValue: 'ftp://example.com' },
      { ...base, text: 'Command', clickAction: 'run_command', clickValue: 'say hello' },
    ]);

    render(<RichTextEditor value={value} onChange={vi.fn()} />);

    expect(richTextIsValid(value)).toBe(false);
    expect(screen.getByRole('alert')).toHaveTextContent('http:// 或 https://');
    expect(screen.getByRole('alert')).toHaveTextContent('必须以 / 开头');
    const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
    selectEditorText(editor, 0, 'Website'.length);
    expect(screen.getByLabelText('所选文字点击动作 / Selected text click action')).toHaveValue('open_url');
    expect(screen.getByLabelText('所选动作内容 / Selected action value')).toHaveAttribute('aria-invalid', 'true');

    selectEditorText(editor, 'Website'.length, 'WebsiteCommand'.length);
    expect(screen.getByLabelText('所选文字点击动作 / Selected text click action')).toHaveValue('run_command');
    expect(screen.getByLabelText('所选动作内容 / Selected action value')).toHaveAttribute('aria-invalid', 'true');
  });

  it('applies every visual style and interaction field to only the selected text', async () => {
    function Harness() {
      const [value, setValue] = useState('Format rest');
      return <><RichTextEditor value={value} onChange={setValue} /><output data-testid="rich-value">{value}</output></>;
    }
    const user = userEvent.setup();
    render(<Harness />);
    const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
    selectEditorText(editor, 0, 6);

    for (const title of ['粗体 / Bold', '斜体 / Italic', '下划线 / Underline',
      '删除线 / Strikethrough', '随机字符 / Obfuscated']) {
      await user.click(within(selectionToolbar()).getByTitle(title));
    }
    fireEvent.change(screen.getByLabelText('所选文字颜色 / Selected text color'), {
      target: { value: '#55ffff' },
    });
    await user.selectOptions(screen.getByLabelText('所选文字点击动作 / Selected text click action'),
      'copy_to_clipboard');
    await user.type(screen.getByLabelText('所选动作内容 / Selected action value'), 'copied value');
    await user.type(screen.getByLabelText('所选文字悬停说明 / Selected text hover details'), 'Hover help');
    await user.type(screen.getByLabelText('所选文字插入内容 / Selected text insertion'), 'inserted');

    const decoded = decodeRichText(screen.getByTestId('rich-value').textContent ?? '');
    expect(decoded[0]).toMatchObject({
      text: 'Format', color: '#55ffff', bold: true, italic: true, underlined: true,
      strikethrough: true, obfuscated: true, clickAction: 'copy_to_clipboard',
      clickValue: 'copied value', hoverText: 'Hover help', insertion: 'inserted',
    });
    expect(decoded[1]).toMatchObject({ text: ' rest', bold: false, clickAction: 'none' });
  });

  it('supports keyboard formatting, line breaks, and ordinary contenteditable typing', async () => {
    function Harness() {
      const [value, setValue] = useState('Key');
      return <><RichTextEditor value={value} onChange={setValue} /><output data-testid="rich-value">{value}</output></>;
    }
    const user = userEvent.setup();
    render(<Harness />);
    let editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
    editor.focus();
    selectEditorText(editor, 0, 3);

    expect(fireEvent.keyDown(editor, { key: 'b', ctrlKey: true })).toBe(false);
    expect(decodeRichText(screen.getByTestId('rich-value').textContent ?? '')[0]).toMatchObject({
      text: 'Key', bold: true,
    });

    editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
    selectEditorText(editor, 3, 3);
    expect(fireEvent.keyDown(editor, { key: 'Enter' })).toBe(false);
    const echoedBeforeInput = new InputEvent('beforeinput', {
      bubbles: true, cancelable: true, inputType: 'insertParagraph',
    });
    expect(fireEvent(editor, echoedBeforeInput)).toBe(false);
    fireEvent.input(editor, { inputType: 'insertParagraph' });
    expect(richTextPlainText(screen.getByTestId('rich-value').textContent ?? '')).toBe('Key\n');
    await user.type(editor, 'next', { skipClick: true });
    expect(richTextPlainText(screen.getByTestId('rich-value').textContent ?? '')).toBe('Key\nnext');

    editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
    selectEditorText(editor, 'Key\nnext'.length, 'Key\nnext'.length);
    expect(fireEvent.keyDown(editor, { key: 'Enter', shiftKey: true })).toBe(false);
    expect(richTextPlainText(screen.getByTestId('rich-value').textContent ?? '')).toBe('Key\nnext\n');
  });

  it('keeps IME composition browser-owned, commits once at compositionend, and restores the caret', () => {
    const changes = vi.fn();
    function Harness() {
      const [value, setValue] = useState('欢迎');
      return <><RichTextEditor value={value} onChange={(next) => { changes(next); setValue(next); }} />
        <output data-testid="ime-value">{value}</output></>;
    }
    render(<Harness />);
    const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
    editor.focus();
    selectEditorText(editor, 2, 2);

    fireEvent.compositionStart(editor, { data: '' });
    replaceEditorDomText(editor, '欢迎n');
    fireEvent.compositionUpdate(editor, { data: 'n' });
    fireEvent.input(editor, { data: 'n', inputType: 'insertCompositionText', isComposing: true });
    replaceEditorDomText(editor, '欢迎ni');
    fireEvent.compositionUpdate(editor, { data: 'ni' });
    fireEvent.input(editor, { data: 'ni', inputType: 'insertCompositionText', isComposing: true });

    expect(changes).not.toHaveBeenCalled();
    expect(editor).toHaveTextContent('欢迎ni');

    replaceEditorDomText(editor, '欢迎你');
    fireEvent.compositionEnd(editor, { data: '你' });

    // Chromium/WebKit may emit one final non-composing input after compositionend.
    fireEvent.input(editor, { data: '你', inputType: 'insertText', isComposing: false });
    expect(changes).toHaveBeenCalledTimes(1);
    expect(richTextPlainText(screen.getByTestId('ime-value').textContent ?? '')).toBe('欢迎你');
    expect(editorSelectionOffsets(editor)).toEqual({ start: 3, end: 3 });
  });

  it('leaves Enter to the IME candidate window without inserting a line break', () => {
    const changes = vi.fn();
    render(<RichTextEditor value="候选" onChange={changes} />);
    const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
    editor.focus();
    selectEditorText(editor, 2, 2);
    fireEvent.compositionStart(editor, { data: '' });

    expect(fireEvent.keyDown(editor, { key: 'Enter', isComposing: true, keyCode: 229 })).toBe(true);
    expect(editor).toHaveTextContent('候选');
    expect(changes).not.toHaveBeenCalled();

    fireEvent.compositionEnd(editor, { data: '' });
    fireEvent.input(editor, { data: '', inputType: 'insertCompositionText' });
    expect(editor).toHaveTextContent('候选');
    expect(changes).not.toHaveBeenCalled();
  });

  it('commits a completed composition through the zero-delay fallback when no tail input arrives', async () => {
    const changes = vi.fn();
    function Harness() {
      const [value, setValue] = useState('中');
      return <><RichTextEditor value={value} onChange={(next) => { changes(next); setValue(next); }} />
        <output data-testid="ime-fallback-value">{value}</output></>;
    }
    render(<Harness />);
    const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
    editor.focus();
    selectEditorText(editor, 1, 1);
    fireEvent.compositionStart(editor, { data: '' });
    replaceEditorDomText(editor, '中文');
    fireEvent.compositionEnd(editor, { data: '文' });
    expect(changes).not.toHaveBeenCalled();

    await waitFor(() => expect(changes).toHaveBeenCalledTimes(1));
    expect(richTextPlainText(screen.getByTestId('ime-fallback-value').textContent ?? '')).toBe('中文');
    expect(editorSelectionOffsets(editor)).toEqual({ start: 2, end: 2 });
  });

  it('accepts two legitimate consecutive full-width punctuation inputs from a soft keyboard', () => {
    const changes = vi.fn();
    function Harness() {
      const [value, setValue] = useState('标点');
      return <><RichTextEditor value={value} onChange={(next) => { changes(next); setValue(next); }} />
        <output data-testid="soft-punctuation-value">{value}</output></>;
    }
    render(<Harness />);
    const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
    editor.focus();
    selectEditorText(editor, 2, 2);

    for (const text of ['标点，', '标点，，']) {
      expect(fireEvent(editor, new InputEvent('beforeinput', {
        bubbles: true, cancelable: true, data: '，', inputType: 'insertText',
      }))).toBe(true);
      replaceEditorDomText(editor, text);
      fireEvent.input(editor, { data: '，', inputType: 'insertText' });
    }

    expect(changes).toHaveBeenCalledTimes(2);
    expect(richTextPlainText(screen.getByTestId('soft-punctuation-value').textContent ?? '')).toBe('标点，，');
    expect(editor).toHaveTextContent('标点，，');
  });

  it('gives a controlled external value priority over an in-progress composition', () => {
    const changes = vi.fn();
    function Harness() {
      const [value, setValue] = useState('旧值');
      return <><button type="button" onClick={() => setValue('外部新值')}>Apply external value</button>
        <RichTextEditor value={value} onChange={(next) => { changes(next); setValue(next); }} />
        <output data-testid="external-value">{value}</output></>;
    }
    render(<Harness />);
    const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
    editor.focus();
    selectEditorText(editor, 2, 2);
    fireEvent.compositionStart(editor, { data: '' });
    replaceEditorDomText(editor, '旧值ji');
    fireEvent.input(editor, { data: 'ji', inputType: 'insertCompositionText', isComposing: true });

    fireEvent.click(screen.getByRole('button', { name: 'Apply external value' }));
    expect(editor).toHaveTextContent('外部新值');
    expect(screen.getByTestId('external-value')).toHaveTextContent('外部新值');

    // A stale compositionend dispatched after the controlled update cannot restore
    // the old base or its provisional DOM.
    replaceEditorDomText(editor, '旧值机');
    fireEvent.compositionEnd(editor, { data: '机' });
    expect(changes).not.toHaveBeenCalled();
    expect(editor).toHaveTextContent('外部新值');
    expect(richTextPlainText(screen.getByTestId('external-value').textContent ?? '')).toBe('外部新值');
  });

  it.each([
    ['ASCII punctuation', ','],
    ['中文全角标点', '，'],
  ])('deduplicates a trailing composition input for %s', (_label, punctuation) => {
    const changes = vi.fn();
    function Harness() {
      const [value, setValue] = useState('A');
      return <><RichTextEditor value={value} onChange={(next) => { changes(next); setValue(next); }} />
        <output data-testid="punctuation-value">{value}</output></>;
    }
    render(<Harness />);
    let editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
    editor.focus();
    selectEditorText(editor, 1, 1);
    fireEvent.compositionStart(editor, { data: '' });
    replaceEditorDomText(editor, `A${punctuation}`);
    fireEvent.compositionEnd(editor, { data: punctuation });

    expect(changes).not.toHaveBeenCalled();
    // Emulate a browser that has already inserted the compositionend echo before
    // dispatching its trailing input event. The editor must roll this duplicate back.
    replaceEditorDomText(editor, `A${punctuation}${punctuation}`);
    fireEvent.input(editor, { data: punctuation, inputType: 'insertText', isComposing: false });

    editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
    expect(changes).toHaveBeenCalledTimes(1);
    expect(richTextPlainText(screen.getByTestId('punctuation-value').textContent ?? ''))
      .toBe(`A${punctuation}`);
    expect(editor).toHaveTextContent(`A${punctuation}`);
    expect(editorSelectionOffsets(editor)).toEqual({ start: 2, end: 2 });
  });

  it('keeps two consecutive beforeinput-only line breaks without waiting for keydown', () => {
    function Harness() {
      const [value, setValue] = useState('Line');
      return <><RichTextEditor value={value} onChange={setValue} />
        <output data-testid="beforeinput-value">{value}</output></>;
    }
    render(<Harness />);
    const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
    editor.focus();
    selectEditorText(editor, 4, 4);
    const beforeInput = new InputEvent('beforeinput', {
      bubbles: true, cancelable: true, inputType: 'insertLineBreak',
    });

    expect(fireEvent(editor, beforeInput)).toBe(false);
    const secondBeforeInput = new InputEvent('beforeinput', {
      bubbles: true, cancelable: true, inputType: 'insertLineBreak',
    });
    expect(fireEvent(editor, secondBeforeInput)).toBe(false);
    expect(richTextPlainText(screen.getByTestId('beforeinput-value').textContent ?? '')).toBe('Line\n\n');
    expect(editorSelectionOffsets(editor)).toEqual({ start: 6, end: 6 });
  });

  it('commits one ordinary symbol from an empty editor exactly once', () => {
    const changes = vi.fn();
    function Harness() {
      const [value, setValue] = useState('');
      return <><RichTextEditor value={value} onChange={(next) => { changes(next); setValue(next); }} />
        <output data-testid="empty-symbol-value">{value}</output></>;
    }
    render(<Harness />);
    const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
    editor.focus();
    fireEvent(editor, new InputEvent('beforeinput', {
      bubbles: true, cancelable: true, data: '.', inputType: 'insertText',
    }));
    replaceEditorDomText(editor, '.');
    fireEvent.input(editor, { data: '.', inputType: 'insertText' });
    // A duplicate notification for the same DOM mutation must be a no-op.
    fireEvent.input(editor, { data: '.', inputType: 'insertText' });

    expect(changes).toHaveBeenCalledTimes(1);
    expect(richTextPlainText(screen.getByTestId('empty-symbol-value').textContent ?? '')).toBe('.');
    expect(editor).toHaveTextContent('.');
    expect([...editor.childNodes]).toHaveLength(1);
    expect(editor.firstChild).toBeInstanceOf(HTMLSpanElement);
  });

  it('normalizes one symbol typed at a differently-styled run boundary without duplication or bare nodes', () => {
    const left = { ...decodeRichText('左')[0], text: '左', color: '#ff5555', bold: true };
    const right = { ...decodeRichText('右')[0], text: '右', color: '#55ffff', italic: true };
    const changes = vi.fn();
    function Harness() {
      const [value, setValue] = useState(encodeRichText([left, right]));
      return <><RichTextEditor value={value} onChange={(next) => { changes(next); setValue(next); }} />
        <output data-testid="boundary-symbol-value">{value}</output></>;
    }
    render(<Harness />);
    const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
    editor.focus();
    selectEditorText(editor, 1, 1);
    const rightRun = editor.querySelectorAll('span')[1];
    editor.insertBefore(document.createTextNode('。'), rightRun);
    selectEditorText(editor, 2, 2);
    fireEvent.input(editor, { data: '。', inputType: 'insertText' });
    fireEvent.input(editor, { data: '。', inputType: 'insertText' });

    const value = screen.getByTestId('boundary-symbol-value').textContent ?? '';
    expect(changes).toHaveBeenCalledTimes(1);
    expect(richTextPlainText(value)).toBe('左。右');
    expect(decodeRichText(value)).toEqual([
      expect.objectContaining({ text: '左。', color: '#ff5555', bold: true }),
      expect.objectContaining({ text: '右', color: '#55ffff', italic: true }),
    ]);
    expect([...editor.childNodes].every((node) => node instanceof HTMLSpanElement)).toBe(true);
  });

  it('rebuilds canonical styled spans after a cancelled composition without emitting a change', () => {
    const left = { ...decodeRichText('A')[0], text: 'A', bold: true };
    const right = { ...decodeRichText('B')[0], text: 'B', italic: true };
    const changes = vi.fn();
    render(<RichTextEditor value={encodeRichText([left, right])} onChange={changes} />);
    const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
    editor.focus();
    selectEditorText(editor, 1, 1);
    fireEvent.compositionStart(editor, { data: '' });
    replaceEditorDomText(editor, 'AB', 1);
    fireEvent.compositionEnd(editor, { data: '' });
    fireEvent.input(editor, { data: '', inputType: 'insertCompositionText' });

    expect(changes).not.toHaveBeenCalled();
    expect(editor.querySelectorAll(':scope > span')).toHaveLength(2);
    expect(editor.querySelectorAll(':scope > span')[0]).toHaveStyle({ fontWeight: '700' });
    expect(editor.querySelectorAll(':scope > span')[1]).toHaveStyle({ fontStyle: 'italic' });
    expect(editorSelectionOffsets(editor)).toEqual({ start: 1, end: 1 });
  });

  it('copies and pastes selected rich runs with all style and interaction metadata', () => {
    const first = {
      ...decodeRichText('AB')[0], text: 'AB', color: '#ff5555', bold: true, underlined: true,
      insertion: 'insert-alpha', clickAction: 'copy_to_clipboard' as const,
      clickValue: 'copy-alpha', hoverText: 'Alpha details',
    };
    const second = {
      ...decodeRichText('CD')[0], text: 'CD', color: '#55ffff', italic: true, strikethrough: true,
      insertion: 'insert-beta', clickAction: 'open_url' as const,
      clickValue: 'https://example.com/details', hoverText: 'Beta details',
    };
    const changes = vi.fn();
    function Harness() {
      const [value, setValue] = useState(encodeRichText([first, second, { ...decodeRichText(' target')[0] }]));
      return <><RichTextEditor value={value} onChange={(next) => { changes(next); setValue(next); }} />
        <output data-testid="rich-clipboard-value">{value}</output></>;
    }
    render(<Harness />);
    const data = new Map<string, string>();
    const clipboardData = {
      getData: vi.fn((type: string) => data.get(type) ?? ''),
      setData: vi.fn((type: string, content: string) => { data.set(type, content); }),
    };
    const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
    editor.focus();
    selectEditorText(editor, 1, 3);
    expect(fireEvent.copy(editor, { clipboardData })).toBe(false);
    expect(data.get('text/plain')).toBe('BC');
    const envelope = JSON.parse(data.get(RICH_TEXT_CLIPBOARD_MIME) ?? '{}') as Record<string, unknown>;
    expect(envelope).toEqual({ version: 1, id: expect.stringMatching(/^[0-9a-f]{32}$/) });
    expect(data.get(RICH_TEXT_CLIPBOARD_MIME)).not.toContain('copy-alpha');

    selectEditorText(editor, 5, 11);
    expect(fireEvent.paste(editor, { clipboardData })).toBe(false);
    fireEvent.input(editor, { inputType: 'insertFromPaste' });

    const decoded = decodeRichText(screen.getByTestId('rich-clipboard-value').textContent ?? '');
    expect(changes).toHaveBeenCalledTimes(1);
    expect(decoded.map((item) => item.text).join('')).toBe('ABCD BC');
    expect(decoded.slice(-2)).toEqual([
      expect.objectContaining({ ...first, text: 'B' }),
      expect.objectContaining({ ...second, text: 'C' }),
    ]);
  });

  it('leaves copy and paste to the active IME while composition text is provisional', () => {
    const clipboardData = { getData: vi.fn(() => 'ignored'), setData: vi.fn() };
    const changes = vi.fn();
    render(<RichTextEditor value="正在" onChange={changes} />);
    const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
    editor.focus();
    selectEditorText(editor, 2, 2);
    fireEvent.compositionStart(editor, { data: '' });

    expect(fireEvent.copy(editor, { clipboardData })).toBe(true);
    expect(fireEvent.paste(editor, { clipboardData })).toBe(true);
    expect(clipboardData.setData).not.toHaveBeenCalled();
    expect(clipboardData.getData).not.toHaveBeenCalled();

    fireEvent.compositionEnd(editor, { data: '' });
    expect(changes).not.toHaveBeenCalled();
  });

  it.each([
    ['mismatched plain text', encodeRichText([{ ...decodeRichText('forged')[0], text: 'forged', bold: true }])],
    ['forged run-command metadata', encodeRichText([{
      ...decodeRichText('safe')[0], text: 'safe', bold: true,
      clickAction: 'run_command' as const, clickValue: '/op @a',
    }])],
  ])('falls back to safe plain text for %s in a custom clipboard payload', (_label, customPayload) => {
    function Harness() {
      const [value, setValue] = useState('Replace');
      return <><RichTextEditor value={value} onChange={setValue} />
        <output data-testid="untrusted-clipboard-value">{value}</output></>;
    }
    render(<Harness />);
    const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
    selectEditorText(editor, 0, 7);
    fireEvent.paste(editor, { clipboardData: {
      getData: (type: string) => type === RICH_TEXT_CLIPBOARD_MIME ? customPayload : 'safe',
    } });

    const decoded = decodeRichText(screen.getByTestId('untrusted-clipboard-value').textContent ?? '');
    expect(decoded).toEqual([expect.objectContaining({
      text: 'safe', bold: false, clickAction: 'none', clickValue: '', hoverText: '', insertion: '',
    })]);
  });

  it('replaces the current selection with custom variables and deduplicates defaults', async () => {
    function Harness() {
      const [value, setValue] = useState('Hello name');
      return <><RichTextEditor value={value} onChange={setValue}
        variables={['{args.name}', '{player}']} /><output data-testid="rich-value">{value}</output></>;
    }
    const user = userEvent.setup();
    render(<Harness />);
    const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
    selectEditorText(editor, 6, 10);

    await user.click(screen.getByRole('button', { name: /插入变量 \/ Insert variable/ }));
    expect(screen.getAllByRole('button', { name: /插入变量 \{player\}/ })).toHaveLength(1);
    await user.click(screen.getByRole('button', { name: /插入变量 \{args\.name\}/ }));
    expect(richTextPlainText(screen.getByTestId('rich-value').textContent ?? ''))
      .toBe('Hello {args.name}');
  });

  it('uses a server catalog as authoritative, exposes 100+ searchable entries, and omits unsafe entries', async () => {
    const serverCatalog = [
      ...Array.from({ length: 101 }, (_, index) => catalogVariable(`server.metric${index}`, index === 73 ? {
        nameZh: '特别游戏速度', nameEn: 'Special game speed',
        descriptionZh: '用于观察当前游戏速度的指标。',
        descriptionEn: 'Metric used to observe the current game speed.',
      } : {})),
      catalogVariable('server.secret', { sensitive: true }),
      catalogVariable('server.conditionOnly', { templateAllowed: false }),
    ];
    function Harness() {
      const [value, setValue] = useState('Speed: ');
      return <><RichTextEditor value={value} onChange={setValue} variableCatalog={serverCatalog} />
        <output data-testid="catalog-value">{value}</output></>;
    }
    const user = userEvent.setup();
    render(<Harness />);

    const open = screen.getByRole('button', { name: /Search 101/ });
    await user.click(open);
    const dialog = screen.getByRole('dialog', { name: '插入变量 / Insert variable' });
    expect(dialog.parentElement?.parentElement).toBe(document.body);
    expect(within(dialog).queryByText('{player}')).not.toBeInTheDocument();
    expect(within(dialog).queryByText('{server.secret}')).not.toBeInTheDocument();

    await user.type(within(dialog).getByRole('searchbox', { name: '搜索变量 / Search variables' }), '特别游戏速度');
    expect(within(dialog).getByText(/Showing 1 of 101/)).toBeInTheDocument();
    expect(within(dialog).getByText('用于观察当前游戏速度的指标。')).toBeInTheDocument();
    await user.click(within(dialog).getByRole('button', { name: /\{server\.metric73\}/ }));
    expect(richTextPlainText(screen.getByTestId('catalog-value').textContent ?? ''))
      .toBe('Speed: {server.metric73}');
  });

  it('uses a left category navigation and selects all variables by default', async () => {
    const catalog = [
      catalogVariable('server.online'),
      catalogVariable('player.name', { category: 'player', nameZh: '玩家名称' }),
      catalogVariable('world.day', { category: 'world', nameZh: '世界天数' }),
    ];
    const user = userEvent.setup();
    render(<RichTextEditor value="" onChange={vi.fn()} variableCatalog={catalog} />);

    await user.click(screen.getByRole('button', { name: /插入变量 \/ Insert variable/ }));
    const dialog = screen.getByRole('dialog', { name: '插入变量 / Insert variable' });
    const navigation = within(dialog).getByRole('navigation', { name: '变量分类 / Variable categories' });
    const all = within(navigation).getByRole('button', { name: /全部/ });
    expect(all).toHaveAttribute('aria-current', 'page');
    expect(within(dialog).getByText(/Showing 3 of 3/)).toBeInTheDocument();

    await user.click(within(navigation).getByRole('button', { name: /玩家/ }));
    expect(within(navigation).getByRole('button', { name: /玩家/ })).toHaveAttribute('aria-current', 'page');
    expect(within(dialog).getByText(/Showing 1 of 3/)).toBeInTheDocument();
    expect(within(dialog).getByText('{player.name}')).toBeInTheDocument();
    expect(within(dialog).queryByText('{world.day}')).not.toBeInTheDocument();
  });

  it('documents the safe date pattern and inserts a formatted server-time variable', async () => {
    function Harness() {
      const [value, setValue] = useState('At ');
      return <><RichTextEditor value={value} onChange={setValue} variableCatalog={[
        catalogVariable('server.time:uuuu-MM-dd HH:mm:ss', {
          nameZh: '完整日期时间（推荐）', nameEn: 'Full date and time (recommended)',
          category: 'time', sampleValue: '2026-09-04 13:05:09', conditionAllowed: false,
          formatHint: 'MM=月份，mm=分钟，HH=24 小时制。',
        }),
      ]} /><output data-testid="time-value">{value}</output></>;
    }
    const user = userEvent.setup();
    render(<Harness />);
    const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
    await user.click(screen.getByRole('button', { name: /插入变量 \/ Insert variable/ }));
    const dialog = screen.getByRole('dialog', { name: '插入变量 / Insert variable' });

    expect(within(dialog).getByRole('note')).toHaveTextContent('YYYY 是周历年');
    expect(within(dialog).getByText('2026-09-04 13:05:09')).toBeInTheDocument();
    const formattedTime = within(dialog).getByRole('button', { name: /server\.time:uuuu-MM-dd HH:mm:ss/ });
    expect(formattedTime).toHaveTextContent('MM=月份，mm=分钟');
    await user.click(formattedTime);
    expect(richTextPlainText(screen.getByTestId('time-value').textContent ?? ''))
      .toBe('At {server.time:uuuu-MM-dd HH:mm:ss}');
    await waitFor(() => expect(editor).toHaveFocus());
    expect(editor.contains(window.getSelection()?.anchorNode ?? null)).toBe(true);
    expect(window.getSelection()?.anchorOffset).toBe('At {server.time:uuuu-MM-dd HH:mm:ss}'.length);
  });

  it('merges dynamic event arguments without reintroducing the legacy fallback', () => {
    const merged = mergeRichTextVariableCatalog(
      [catalogVariable('server.tickRate')], ['{args.target}', '{server.tickRate}'],
    );
    expect(merged.map((item) => item.key)).toEqual(['server.tickRate', 'args.target']);
    expect(merged.find((item) => item.key === 'args.target')).toMatchObject({
      category: 'command', scopes: ['player.command_trigger'],
    });
  });

  it('accepts only plain text from paste and automatically splits oversized style runs', () => {
    function Harness() {
      const [value, setValue] = useState('Replace me');
      return <><RichTextEditor value={value} onChange={setValue} /><output data-testid="rich-value">{value}</output></>;
    }
    const { rerender } = render(<Harness />);
    let editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
    selectEditorText(editor, 0, 'Replace me'.length);
    fireEvent.paste(editor, { clipboardData: { getData: (type: string) => type === 'text/plain'
      ? 'Plain only' : '<img src=x onerror=alert(1)><b>HTML</b>' } });

    expect(richTextPlainText(screen.getByTestId('rich-value').textContent ?? '')).toBe('Plain only');
    expect(editor.querySelector('img, b')).toBeNull();

    function LongHarness() {
      const [value, setValue] = useState('');
      return <><RichTextEditor value={value} onChange={setValue} /><output data-testid="long-value">{value}</output></>;
    }
    rerender(<LongHarness />);
    editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
    fireEvent.paste(editor, { clipboardData: { getData: (type: string) => type === 'text/plain'
      ? 'x'.repeat(MAX_RICH_TEXT_SEGMENT_CHARACTERS + 2) : '<script>bad()</script>' } });
    const runs = decodeRichText(screen.getByTestId('long-value').textContent ?? '');
    expect(runs.map((item) => item.text.length)).toEqual([MAX_RICH_TEXT_SEGMENT_CHARACTERS, 2]);
    expect(runs.map((item) => item.text).join('')).toBe('x'.repeat(MAX_RICH_TEXT_SEGMENT_CHARACTERS + 2));
  });

  it('rejects a formatting split that would exceed the 256-run document limit', async () => {
    const initial = encodeRichText(Array.from({ length: MAX_RICH_TEXT_SEGMENTS }, (_, index) => ({
      ...decodeRichText('ab')[0], text: 'ab', color: `#${(index + 1).toString(16).padStart(6, '0')}`,
    })));
    const change = vi.fn();
    const user = userEvent.setup();
    render(<RichTextEditor value={initial} onChange={change} />);
    const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
    selectEditorText(editor, 21, 22);
    await user.click(within(selectionToolbar()).getByTitle('粗体 / Bold'));

    expect(change).not.toHaveBeenCalled();
    expect(screen.getByRole('alert')).toHaveTextContent('富文本样式段不能超过 256 个');
  });
});
