import { flushPromises, mount } from '@vue/test-utils';

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

import MarkdownLiveEditor from './MarkdownLiveEditor.vue';

interface MockFeatureConfig {
  advancedGroup?: Record<string, unknown>;
  listGroup?: Record<string, unknown>;
  onUpload?: (file: File) => Promise<string>;
  proxyDomURL?: (url: string) => Promise<string> | string;
  text?: string;
  textGroup?: Record<string, unknown>;
}

interface MockCrepeConfig {
  defaultValue?: string;
  featureConfigs?: Record<string, MockFeatureConfig>;
}

const mockState = vi.hoisted(() => ({
  config: undefined as MockCrepeConfig | undefined,
  dispatch: vi.fn(),
  destroy: vi.fn(async () => undefined),
  listSpreadNodes: [] as Array<{
    attrs: Record<string, unknown>;
    marks: unknown[];
    position: number;
    type: { name: string };
  }>,
  createPromise: undefined as Promise<void> | undefined,
  firstNode: undefined as
    | undefined
    | { nodeSize: number; type: { name: string } },
  markdown: '# Initial',
  markdownUpdated: undefined as
    | ((ctx: unknown, value: string) => void)
    | undefined,
  replaceAll: vi.fn(),
  resolve: vi.fn((position: number) => ({ position })),
  selectionFrom: 2,
  setSelection: vi.fn(),
  setNodeMarkup: vi.fn(),
  setReadonly: vi.fn(),
  setTransactionMeta: vi.fn(),
}));

vi.mock('@milkdown/kit/prose/state', () => ({
  TextSelection: {
    near: (position: { position: number }) => ({
      position: position.position,
    }),
  },
}));

vi.mock('@milkdown/kit/utils', () => ({
  $nodeSchema: () => [{ id: 'nodeSchema' }, { id: 'node' }],
  $remark: () => [{ id: 'remark' }, { id: 'plugin' }],
  replaceAll: (value: string, flush: boolean) => {
    mockState.replaceAll(value, flush);
    return () => {
      mockState.markdown = value;
    };
  },
}));

vi.mock('@milkdown/crepe', () => {
  class MockCrepe {
    static Feature = {
      AI: 'ai',
      BlockEdit: 'block-edit',
      ImageBlock: 'image-block',
      Latex: 'latex',
      Placeholder: 'placeholder',
      TopBar: 'top-bar',
    };

    create = vi.fn(async () => mockState.createPromise);

    destroy = mockState.destroy;

    editor = {
      action: (action: (ctx: unknown) => void) =>
        action({
          get: (slice: { name?: string }) => {
            if (slice.name === 'editorState') {
              return {
                doc: {
                  firstChild: mockState.firstNode,
                  resolve: mockState.resolve,
                  descendants: (
                    callback: (
                      node: (typeof mockState.listSpreadNodes)[number],
                      position: number,
                    ) => void,
                  ) =>
                    mockState.listSpreadNodes.forEach((node) =>
                      callback(node, node.position),
                    ),
                },
                selection: { from: mockState.selectionFrom },
                tr: {
                  setMeta: mockState.setTransactionMeta,
                  setNodeMarkup: mockState.setNodeMarkup,
                  setSelection: mockState.setSelection,
                },
              };
            }
            if (slice.name === 'editorView') {
              return { dispatch: mockState.dispatch };
            }
            return undefined;
          },
        }),
      config: () => undefined,
      use: () => undefined,
    };
    setReadonly = mockState.setReadonly;
    constructor(config: MockCrepeConfig) {
      mockState.config = config;
      mockState.markdown = config.defaultValue || '';
    }
    getMarkdown = () => mockState.markdown;
    on = (
      register: (listener: {
        markdownUpdated: (callback: typeof mockState.markdownUpdated) => void;
      }) => void,
    ) => {
      register({
        markdownUpdated: (callback) => (mockState.markdownUpdated = callback),
      });
    };
  }
  return { Crepe: MockCrepe };
});

describe('markdownLiveEditor', () => {
  beforeEach(() => {
    mockState.config = undefined;
    mockState.destroy.mockClear();
    mockState.createPromise = undefined;
    mockState.dispatch.mockClear();
    mockState.firstNode = undefined;
    mockState.replaceAll.mockClear();
    mockState.resolve.mockClear();
    mockState.selectionFrom = 2;
    mockState.setSelection.mockClear();
    mockState.setReadonly.mockClear();
    mockState.markdown = '# Initial';
    mockState.markdownUpdated = undefined;
    mockState.listSpreadNodes.length = 0;
    mockState.setNodeMarkup.mockClear();
    mockState.setTransactionMeta.mockClear();
  });

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

  it('uses generic Markdown copy by default', async () => {
    const wrapper = mount(MarkdownLiveEditor, {
      props: { modelValue: '' },
    });
    await flushPromises();

    expect(mockState.config?.featureConfigs?.placeholder?.text).toBe(
      '输入 Markdown 内容…',
    );

    const upload = mockState.config?.featureConfigs?.['image-block']?.onUpload;
    await expect(upload?.(new File(['image'], 'image.png'))).rejects.toThrow(
      '当前编辑器未配置图片上传',
    );
    expect(wrapper.emitted('error')?.[0]?.[0]).toEqual(
      new Error('当前编辑器未配置图片上传'),
    );

    wrapper.unmount();
  });

  it('passes reusable placeholder and image callbacks to Crepe', async () => {
    const resolveImageUrl = vi.fn((url: string) => `/preview/${url}`);
    const uploadImage = vi.fn(async (file: File) => `/uploads/${file.name}`);
    const wrapper = mount(MarkdownLiveEditor, {
      props: {
        modelValue: '',
        placeholder: '编写发布说明…',
        resolveImageUrl,
        uploadImage,
      },
    });
    await flushPromises();

    const config = mockState.config?.featureConfigs;
    expect(config?.placeholder?.text).toBe('编写发布说明…');
    expect(await config?.['image-block']?.proxyDomURL?.('asset.png')).toBe(
      '/preview/asset.png',
    );
    expect(
      await config?.['image-block']?.onUpload?.(
        new File(['image'], 'asset.png'),
      ),
    ).toBe('/uploads/asset.png');
    expect(resolveImageUrl).toHaveBeenCalledWith('asset.png');
    expect(uploadImage).toHaveBeenCalledOnce();

    wrapper.unmount();
  });

  it('localizes the block menu and exposes one keyboard menu trigger', async () => {
    const wrapper = mount(MarkdownLiveEditor, {
      props: { modelValue: '# Initial' },
    });
    await flushPromises();

    expect(mockState.config?.featureConfigs?.['block-edit']).toMatchObject({
      advancedGroup: {
        codeBlock: { label: '代码块' },
        image: { label: '图片' },
        label: '更多',
        table: { label: '表格' },
      },
      listGroup: {
        bulletList: { label: '无序列表' },
        label: '列表',
        orderedList: { label: '有序列表' },
      },
      textGroup: {
        h1: { label: '一级标题' },
        label: '文本',
        text: { label: '正文' },
      },
    });
    const host = wrapper.get('.easyflow-markdown-live-editor__host').element;
    const handle = document.createElement('div');
    handle.className = 'milkdown-block-handle';
    handle.innerHTML =
      '<div class="operation-item"></div><div class="operation-item"></div>';
    const menu = document.createElement('div');
    menu.className = 'milkdown-slash-menu';
    host.append(handle, menu);
    await flushPromises();

    const hiddenAddButton = handle.firstElementChild as HTMLElement;
    const trigger = handle.lastElementChild as HTMLElement;
    await vi.waitFor(() => {
      expect(hiddenAddButton.getAttribute('aria-hidden')).toBe('true');
      expect(trigger.getAttribute('aria-label')).toBe('打开内容块菜单');
      expect(trigger.getAttribute('role')).toBe('button');
      expect(trigger.tabIndex).toBe(0);
      expect(menu.getAttribute('aria-label')).toBe('内容块菜单');
    });

    const pointerup = vi.fn();
    hiddenAddButton.addEventListener('pointerup', pointerup);
    trigger.click();
    expect(pointerup).toHaveBeenCalledOnce();
    trigger.dispatchEvent(
      new KeyboardEvent('keydown', { bubbles: true, key: 'Enter' }),
    );
    expect(pointerup).toHaveBeenCalledTimes(2);
    mockState.markdownUpdated?.({}, '# Menu edit');
    expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([
      '# Menu edit',
    ]);

    wrapper.unmount();
  });

  it('emits the save shortcut only while the editor is writable', async () => {
    const wrapper = mount(MarkdownLiveEditor, {
      props: { modelValue: '# Initial' },
    });
    await flushPromises();
    const host = wrapper.get('.easyflow-markdown-live-editor__host');

    await host.trigger('keydown', { key: 's', metaKey: true });
    expect(wrapper.emitted('save')).toHaveLength(1);

    await wrapper.setProps({ readonly: true });
    await host.trigger('keydown', { ctrlKey: true, key: 's' });
    expect(wrapper.emitted('save')).toHaveLength(1);

    wrapper.unmount();
  });

  it('uses a flush replace for external values without emitting a delayed model loop', async () => {
    vi.useFakeTimers();
    const wrapper = mount(MarkdownLiveEditor, {
      props: { modelValue: '# Initial' },
    });
    await flushPromises();
    await wrapper.setProps({ modelValue: '# External' });
    expect(mockState.replaceAll).toHaveBeenCalledWith('# External', true);

    vi.advanceTimersByTime(250);
    expect(wrapper.emitted('update:modelValue')).toBeUndefined();

    await wrapper
      .get('.easyflow-markdown-live-editor__host')
      .trigger('keydown', { key: 'Enter' });
    mockState.markdownUpdated?.({}, '# User edit');
    expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([
      '# User edit',
    ]);
  });

  it('does not mark the document changed while Crepe normalizes its initial value', async () => {
    let resolveCreate: (() => void) | undefined;
    mockState.createPromise = new Promise<void>((resolve) => {
      resolveCreate = resolve;
    });
    const wrapper = mount(MarkdownLiveEditor, {
      props: { modelValue: '# Initial' },
    });
    mockState.markdownUpdated?.({}, '# Normalized during create');
    expect(wrapper.emitted('update:modelValue')).toBeUndefined();

    resolveCreate?.();
    await flushPromises();
    await wrapper
      .get('.easyflow-markdown-live-editor__host')
      .trigger('beforeinput');
    mockState.markdownUpdated?.({}, '# User edit');
    expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([
      '# User edit',
    ]);
  });

  it('redirects body input after immutable leading frontmatter', async () => {
    mockState.firstNode = {
      nodeSize: 1,
      type: { name: 'frontmatter' },
    };
    mockState.selectionFrom = 0;
    mockState.setSelection.mockReturnValue({ redirected: true });
    const wrapper = mount(MarkdownLiveEditor, {
      props: { modelValue: '---\nname: review\n---\n\n# Initial' },
    });
    await flushPromises();

    await wrapper
      .get('.easyflow-markdown-live-editor__host')
      .trigger('beforeinput');

    expect(mockState.resolve).toHaveBeenCalledWith(1);
    expect(mockState.setSelection).toHaveBeenCalledWith({ position: 1 });
    expect(mockState.dispatch).toHaveBeenCalledWith({ redirected: true });
  });

  it('ignores lossless normalization emitted after the editor is ready', async () => {
    const wrapper = mount(MarkdownLiveEditor, {
      props: { modelValue: '- first\n- second' },
    });
    await flushPromises();

    mockState.markdownUpdated?.({}, '* first\n* second\n');

    expect(wrapper.emitted('update:modelValue')).toBeUndefined();
  });

  it('locks the live editor when initial markdown cannot be preserved', async () => {
    mockState.createPromise = Promise.resolve().then(() => {
      mockState.markdown = '* normalized';
    });
    const wrapper = mount(MarkdownLiveEditor, {
      props: { modelValue: '- original' },
    });
    await flushPromises();

    expect(wrapper.emitted('fidelityLoss')).toHaveLength(1);
    expect(mockState.setReadonly).toHaveBeenLastCalledWith(true);
    mockState.markdownUpdated?.({}, '* normalized');
    expect(wrapper.emitted('update:modelValue')).toBeUndefined();

    mockState.markdownUpdated?.({}, '* user edit');
    expect(wrapper.emitted('update:modelValue')).toBeUndefined();
  });

  it('accepts line-ending and final-newline normalization as lossless', async () => {
    mockState.createPromise = Promise.resolve().then(() => {
      mockState.markdown = '# Heading\n';
    });
    const wrapper = mount(MarkdownLiveEditor, {
      props: { modelValue: '# Heading\r\n' },
    });
    await flushPromises();

    expect(wrapper.emitted('fidelityLoss')).toBeUndefined();
    expect(mockState.setReadonly).toHaveBeenLastCalledWith(false);
  });

  it('accepts common Crepe list and delimiter normalization as lossless', async () => {
    const source = [
      '# Review',
      '',
      '- first',
      '- second',
      '',
      '---',
      '',
      '| A | B |',
      '| --- | :---: |',
      '| 1 | 2 |',
    ].join('\n');
    mockState.createPromise = Promise.resolve().then(() => {
      mockState.markdown = [
        '# Review',
        '',
        '* first',
        '* second',
        '',
        '***',
        '',
        '| A | B |',
        '| - | :-: |',
        '| 1 | 2 |',
        '',
      ].join('\n');
    });
    const wrapper = mount(MarkdownLiveEditor, {
      props: { modelValue: source },
    });
    await flushPromises();

    expect(wrapper.emitted('fidelityLoss')).toBeUndefined();
    expect(mockState.setReadonly).toHaveBeenLastCalledWith(false);
  });

  it('keeps internal list spread repairs out of undo history', async () => {
    mockState.listSpreadNodes.push({
      attrs: { spread: 'false' },
      marks: [],
      position: 4,
      type: { name: 'bullet_list' },
    });
    const wrapper = mount(MarkdownLiveEditor, {
      props: { modelValue: '- first\n- second' },
    });
    await flushPromises();

    expect(mockState.setNodeMarkup).toHaveBeenCalledWith(
      4,
      undefined,
      { spread: false },
      [],
    );
    expect(mockState.setTransactionMeta).toHaveBeenCalledWith(
      'addToHistory',
      false,
    );
    expect(mockState.dispatch).toHaveBeenCalledOnce();
    wrapper.unmount();
  });

  it('repairs delayed list spread normalization before updating the model', async () => {
    const wrapper = mount(MarkdownLiveEditor, {
      props: { modelValue: '- first\n- second' },
    });
    await flushPromises();
    mockState.listSpreadNodes.push({
      attrs: { spread: 'false' },
      marks: [],
      position: 4,
      type: { name: 'bullet_list' },
    });
    mockState.markdown = '* first\n\n* second';
    mockState.dispatch.mockImplementationOnce(() => {
      mockState.markdown = '* first\n* second';
    });

    mockState.markdownUpdated?.({}, '* first\n\n* second');

    expect(mockState.setNodeMarkup).toHaveBeenCalledWith(
      4,
      undefined,
      { spread: false },
      [],
    );
    expect(wrapper.emitted('update:modelValue')).toBeUndefined();
    wrapper.unmount();
  });

  it('does not mask changes inside fenced code blocks', async () => {
    mockState.createPromise = Promise.resolve().then(() => {
      mockState.markdown = ['```text', '* changed', '```'].join('\n');
    });
    const wrapper = mount(MarkdownLiveEditor, {
      props: {
        modelValue: ['```text', '- changed', '```'].join('\n'),
      },
    });
    await flushPromises();

    expect(wrapper.emitted('fidelityLoss')).toHaveLength(1);
    expect(mockState.setReadonly).toHaveBeenLastCalledWith(true);
  });

  it('does not mask changes inside indented code blocks', async () => {
    mockState.createPromise = Promise.resolve().then(() => {
      mockState.markdown = '    * changed';
    });
    const wrapper = mount(MarkdownLiveEditor, {
      props: { modelValue: '    - changed' },
    });
    await flushPromises();

    expect(wrapper.emitted('fidelityLoss')).toHaveLength(1);
    expect(mockState.setReadonly).toHaveBeenLastCalledWith(true);
  });

  it('does not treat a setext heading underline as a thematic break', async () => {
    mockState.createPromise = Promise.resolve().then(() => {
      mockState.markdown = ['Heading', '***'].join('\n');
    });
    const wrapper = mount(MarkdownLiveEditor, {
      props: { modelValue: ['Heading', '---'].join('\n') },
    });
    await flushPromises();

    expect(wrapper.emitted('fidelityLoss')).toHaveLength(1);
    expect(mockState.setReadonly).toHaveBeenLastCalledWith(true);
  });

  it('does not merge lists separated by different bullet markers', async () => {
    mockState.createPromise = Promise.resolve().then(() => {
      mockState.markdown = '* first\n* second';
    });
    const wrapper = mount(MarkdownLiveEditor, {
      props: { modelValue: '- first\n* second' },
    });
    await flushPromises();

    expect(wrapper.emitted('fidelityLoss')).toHaveLength(1);
  });

  it('does not collapse a tight list into a loose list', async () => {
    mockState.createPromise = Promise.resolve().then(() => {
      mockState.markdown = '* first\n\n* second';
    });
    const wrapper = mount(MarkdownLiveEditor, {
      props: { modelValue: '- first\n- second' },
    });
    await flushPromises();

    expect(wrapper.emitted('fidelityLoss')).toHaveLength(1);
  });

  it('does not normalize invalid table-like text as a GFM table', async () => {
    mockState.createPromise = Promise.resolve().then(() => {
      mockState.markdown = 'A | B | C\n- | -';
    });
    const wrapper = mount(MarkdownLiveEditor, {
      props: { modelValue: 'A | B | C\n--- | ---' },
    });
    await flushPromises();

    expect(wrapper.emitted('fidelityLoss')).toHaveLength(1);
  });

  it('awaits editor destruction when unmounted', async () => {
    const wrapper = mount(MarkdownLiveEditor, {
      props: { modelValue: '# Initial' },
    });
    await flushPromises();
    wrapper.unmount();
    await flushPromises();
    expect(mockState.destroy).toHaveBeenCalledOnce();
  });

  it('applies the latest model value when it changes during creation', async () => {
    let resolveCreate: (() => void) | undefined;
    mockState.createPromise = new Promise<void>((resolve) => {
      resolveCreate = resolve;
    });
    const wrapper = mount(MarkdownLiveEditor, {
      props: { modelValue: '# Initial' },
    });
    await wrapper.setProps({ modelValue: '# Latest' });
    resolveCreate?.();
    await flushPromises();

    expect(mockState.replaceAll).toHaveBeenCalledWith('# Latest', true);
  });

  it('applies the latest readonly state when it changes during creation', async () => {
    let resolveCreate: (() => void) | undefined;
    mockState.createPromise = new Promise<void>((resolve) => {
      resolveCreate = resolve;
    });
    const wrapper = mount(MarkdownLiveEditor, {
      props: { modelValue: '# Initial', readonly: false },
    });
    await wrapper.setProps({ readonly: true });
    resolveCreate?.();
    await flushPromises();

    expect(mockState.setReadonly).toHaveBeenLastCalledWith(true);
  });
});
