import { describe, it, expect, vi, afterEach, beforeAll, beforeEach } from 'vitest';
import { waitFor, fireEvent } from '@testing-library/react';
import { fetchClient } from '@ablogcms/fetch-client';
import { registerAcmsElements } from './register';
import type { BlockEditorElement } from './block-editor-element';

vi.mock('@ablogcms/fetch-client', () => ({
  fetchClient: { get: vi.fn() },
}));

vi.mock('@ablogcms/block-editor/block-editor', () => ({
  default: ({
    defaultValue,
    onUpdate,
    onTransaction,
  }: {
    defaultValue?: string;
    onUpdate: (value: string) => void;
    onTransaction?: (props: { editor: { id: string } }) => void;
  }) => (
    <div data-testid="block-editor-stub">
      <span data-testid="default-value">{defaultValue}</span>
      <button type="button" onClick={() => onUpdate('<p>updated</p>')}>
        update
      </button>
      <button type="button" onClick={() => onTransaction?.({ editor: { id: 'fake-editor' } })}>
        transaction
      </button>
    </div>
  ),
}));

const getStub = () => document.querySelector('[data-testid="block-editor-stub"]');

const createElement = (): BlockEditorElement => document.createElement('acms-block-editor') as BlockEditorElement;

describe('acms-block-editor', () => {
  beforeAll(() => {
    registerAcmsElements();
    (window as unknown as { ACMS: { Config: Record<string, unknown>; Library: unknown } }).ACMS = {
      Config: {
        bid: 1,
        cid: 2,
        blockEditorMark: '.js-block-editor',
        blockEditorConfig: { editorProps: {} },
      },
      Library: {
        acmsLink: vi.fn(() => 'ajax/edit/block-editor/settings.json'),
      },
    };
  });

  beforeEach(() => {
    vi.mocked(fetchClient.get).mockReset();
    vi.mocked(fetchClient.get).mockResolvedValue({
      data: {},
      status: 200,
      statusText: 'OK',
      headers: new Headers(),
    });
  });

  afterEach(() => {
    document.body.innerHTML = '';
    vi.clearAllMocks();
  });

  it('自身に BlockEditor をマウントし、子孫の hidden input の現在値を defaultValue にする', async () => {
    const el = createElement();
    el.innerHTML = `<input type="hidden" name="body" value="<p>hello</p>" />`;
    document.body.appendChild(el);

    await waitFor(() => {
      expect(getStub()).not.toBeNull();
    });
    expect(el.querySelector('[data-testid="default-value"]')?.textContent).toBe('<p>hello</p>');
  });

  it('editor は接続前は null、transaction 後にインスタンスを返す', async () => {
    const el = createElement();
    el.innerHTML = `<input type="hidden" name="body" value="" />`;
    expect(el.editor).toBeNull();
    document.body.appendChild(el);

    await waitFor(() => {
      expect(getStub()).not.toBeNull();
    });
    fireEvent.click(el.querySelectorAll('button')[1]);

    expect(el.editor).toEqual({ id: 'fake-editor' });
  });

  it('入力更新で change イベントが発火し、hidden input が同期される', async () => {
    const el = createElement();
    el.innerHTML = `<input type="hidden" name="body" value="" />`;
    document.body.appendChild(el);
    const handleChange = vi.fn();
    el.addEventListener('change', handleChange);

    await waitFor(() => {
      expect(getStub()).not.toBeNull();
    });

    fireEvent.click(el.querySelectorAll('button')[0]);

    const input = el.querySelector<HTMLInputElement>('input')!;
    expect(input.value).toBe('<p>updated</p>');
    expect(handleChange).toHaveBeenCalledTimes(1);
  });

  it('子孫に hidden input が無い場合は例外を投げずに console.error に留める', async () => {
    const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
    const el = createElement();

    expect(() => document.body.appendChild(el)).not.toThrow();
    expect(consoleErrorSpy).toHaveBeenCalled();
    consoleErrorSpy.mockRestore();
  });

  it('host の root-class 属性がマウントコンテナへ転送される', async () => {
    const el = createElement();
    el.setAttribute('root-class', 'my-custom-class');
    el.innerHTML = `<input type="hidden" name="body" value="" />`;
    document.body.appendChild(el);

    await waitFor(() => {
      expect(getStub()).not.toBeNull();
    });

    // host（<acms-block-editor>）は display: contents で自身のボックスを持たないため、
    // root-class は実際にレンダリングされるマウントコンテナ側で反映される必要がある。
    expect(el.querySelector('[data-block-editor-mount]')?.className).toBe('my-custom-class');
    // root-class は class と違い host 自身の見た目に意味を持つ標準属性ではないため、
    // 取り除かず残す（グループユニットの外に出す操作等で connectedCallback が再発火しても
    // 同じ値を読み直せるようにするため）。
    expect(el.getAttribute('root-class')).toBe('my-custom-class');
  });

  it('disconnectedCallback でアンマウントする', async () => {
    const el = createElement();
    el.innerHTML = `<input type="hidden" name="body" value="" />`;
    document.body.appendChild(el);

    await waitFor(() => {
      expect(getStub()).not.toBeNull();
    });

    el.remove();

    await waitFor(() => {
      expect(getStub()).toBeNull();
    });
  });

  // グループユニットの外にカスタムブロック（block-editor 内蔵）を移動する操作は、この要素を
  // 内包する外側の React ツリー（unit-editor 本体の ServerSideRenderer）の commit フェーズ中に
  // DOM からこの要素を同期的に取り除く。disconnectedCallback がその commit の最中に同期発火し、
  // そこで内側の root.unmount() を同期実行すると、React が「Attempted to synchronously unmount
  // a root while React was already rendering」という警告を出す（外側の commit 完了前に内側の
  // root を畳もうとするため）。root.unmount() 自体は次のマイクロタスクへ遅延させ、この
  // disconnectedCallback 呼び出しの中では同期実行しないことを特徴づける。
  it('disconnectedCallback は root.unmount() を同期実行せずマイクロタスクへ遅延させる', async () => {
    const el = createElement();
    el.innerHTML = `<input type="hidden" name="body" value="" />`;
    document.body.appendChild(el);

    await waitFor(() => {
      expect(getStub()).not.toBeNull();
    });

    el.remove();

    // el.remove() 自体で document から切り離されるため document.querySelector は使えない
    // （el がまだ保持している部分木に対して直接問い合わせる）。disconnectedCallback 直後
    // （同期区間）では、まだ React root が unmount されておらず stub が残っている。
    expect(el.querySelector('[data-testid="block-editor-stub"]')).not.toBeNull();

    await waitFor(() => {
      expect(el.querySelector('[data-testid="block-editor-stub"]')).toBeNull();
    });
  });

  // Why not: グループユニットの外に出す操作等でこの要素が物理的に DOM 移動されると、Custom
  // Elements の仕様上 disconnectedCallback → connectedCallback が同期的に（マイクロタスクが
  // 実行される前に）再発火する。disconnectedCallback で this.controller を同期的に null に
  // すると、再発火した connectedCallback の「既にマウント済みなら何もしない」ガードが効かず、
  // まだ unmount されていない旧 root を残したまま同じ host に新しい root を二重に作ってしまう。
  // マイクロタスクの中で this.isConnected を見て「移動で戻ってきた」場合は何もしないことで、
  // 既存の root を壊さず・二重マウントもしないことを特徴づける。
  it('マイクロタスクが実行される前に再接続された場合（物理的な DOM 移動）は unmount せず、二重マウントもしない', async () => {
    const el = createElement();
    el.innerHTML = `<input type="hidden" name="body" value="" />`;
    const parent = document.createElement('div');
    document.body.appendChild(parent);
    parent.appendChild(el);

    await waitFor(() => {
      expect(getStub()).not.toBeNull();
    });

    // React の commit が DOM ノードを移動する際の disconnectedCallback → connectedCallback の
    // 同期的な再発火を模す（マイクロタスクが実行される前に、同じ要素を再接続する）。
    parent.removeChild(el);
    parent.appendChild(el);

    // マイクロタスクを待つ（unmount が実行されるならここで実行される）。
    await Promise.resolve();
    await Promise.resolve();

    expect(document.querySelectorAll('[data-testid="block-editor-stub"]').length).toBe(1);
  });
});
