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

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

import MarkdownLiveEditor from './MarkdownLiveEditor.vue';

const mockState = vi.hoisted(() => ({
  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,
  markdown: '# Initial',
  markdownUpdated: undefined as
    | ((ctx: unknown, value: string) => void)
    | undefined,
  replaceAll: vi.fn(),
  setNodeMarkup: vi.fn(),
  setReadonly: vi.fn(),
  setTransactionMeta: vi.fn(),
}));

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',
      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: {
                  descendants: (
                    callback: (
                      node: (typeof mockState.listSpreadNodes)[number],
                      position: number,
                    ) => void,
                  ) =>
                    mockState.listSpreadNodes.forEach((node) =>
                      callback(node, node.position),
                    ),
                },
                tr: {
                  setMeta: mockState.setTransactionMeta,
                  setNodeMarkup: mockState.setNodeMarkup,
                },
              };
            }
            if (slice.name === 'editorView') {
              return { dispatch: mockState.dispatch };
            }
            return undefined;
          },
        }),
      config: () => undefined,
      use: () => undefined,
    };
    setReadonly = mockState.setReadonly;
    constructor(config: { defaultValue?: string }) {
      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.destroy.mockClear();
    mockState.createPromise = undefined;
    mockState.dispatch.mockClear();
    mockState.replaceAll.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 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('beforeinput');
    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('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);
  });
});
