import { lazy, type ComponentProps, type ComponentType } from 'react';
import type { Editor } from '@tiptap/core';
import { SlotFillProvider } from '@ablogcms/components/slot-fill';
import { getGlobalFillBridge } from '@ablogcms/plugins/core';
import createProps from '@ablogcms/block-editor/create-props';
import { render } from '@ablogcms/react-utils';
import useBlockEditorSettingsSWR from './hooks/use-block-editor-settings-swr';

type BlockEditorComponentType = typeof import('@ablogcms/block-editor/block-editor').default;

/**
 * BlockEditor 本体・Tiptap ランタイム・プラグイン提供の拡張/ブロック定義（`registerExtension` /
 * `registerBlockDefinition`）を同じ `webpackChunkName: "block-editor"` でまとめてロードし、
 * `extensions` / `blockDefinitions` prop として解決してから BlockEditor に注入するラッパーを返す。
 * レジストリの読み出し（`getBlockExtensions`/`getBlockDefinitions` の呼び出し）はこのチャンクが
 * ロードされた時点（プラグイン登録が完了している `ACMS.Ready` 以降）で行われる。Tiptap ランタイムを
 * ここで動的 import することで、admin.js 初期バンドルに Tiptap を巻き込まない
 * （PACKAGING.md「Tiptap / ProseMirror の再エクスポート」参照）。
 *
 * `editorSettings`（EditorSettings）の取得もこのチャンク内で行う。`normalizeSettings` の
 * `blockDefinitions` 合成（`{ ...BLOCK_DEFINITIONS, ...blockDefinitions }`）はここで解決した
 * `blockDefinitions` を渡さないと機能しない（プラグイン提供の type がメニューに反映されない）ため、
 * SWR 取得をチャンク外（`ConnectedBlockEditor`）ではなくここに置く。
 */
const BlockEditor = lazy(async () => {
  const [{ default: BlockEditorComponent }, { tiptap }, { getBlockExtensions, getBlockDefinitions }] =
    await Promise.all([
      import(/* webpackChunkName: "block-editor" */ '@ablogcms/block-editor/block-editor'),
      import(/* webpackChunkName: "block-editor" */ '@ablogcms/block-editor/tiptap'),
      import(/* webpackChunkName: "block-editor" */ './block-definitions'),
    ]);

  const extensions = getBlockExtensions(tiptap);
  const blockDefinitions = getBlockDefinitions(tiptap);

  // eslint-disable-next-line react/function-component-definition -- 変数の型注釈（ComponentType<...>）を維持するため関数宣言への変換を見送る
  const BlockEditorWithExtensions: ComponentType<Omit<ComponentProps<BlockEditorComponentType>, 'editorSettings'>> = (
    props
  ) => {
    const editorSettings = useBlockEditorSettingsSWR(blockDefinitions);
    return (
      <BlockEditorComponent
        {...props}
        editorSettings={editorSettings}
        extensions={extensions}
        blockDefinitions={blockDefinitions}
      />
    );
  };

  return { default: BlockEditorWithExtensions };
});

type BlockEditorProps = ComponentProps<typeof BlockEditor>;

/**
 * `<Slot>`（`block-editor.menus` 等）は `<SlotFillProvider>` の外側で使われると例外を投げる設計
 * （`@ablogcms/components/slot-fill` の `useSlotFillStore`）のため、unit-editor 系 3 ルートと同じ
 * `getGlobalFillBridge()` でラップする。EditorSettings の取得は `BlockEditor`（lazy コールバック内）
 * に移設済みのため、ここではラップするだけの薄いコンポーネントになる。
 */
function ConnectedBlockEditor(props: BlockEditorProps) {
  return (
    <SlotFillProvider store={getGlobalFillBridge()}>
      <BlockEditor {...props} />
    </SlotFillProvider>
  );
}

export interface BlockEditorFieldController {
  unmount: () => void;
}

export interface BlockEditorMountCallbacks {
  /**
   * tiptap の Editor インスタンスが得られるたびに呼ばれる（`<acms-block-editor>` の `el.editor` 用）。
   * transaction が起きるたび（1文字入力ごと含む）に何度も呼ばれるが、渡ってくる editor は
   * 常に同一インスタンスなので呼び出し側は最新の参照で上書きするだけでよい。
   */
  onEditorReady?: (editor: Editor) => void;
  /** 参照先 input（data-html）への値の同期が完了した直後に呼ばれる（`change` イベント発火用）。 */
  onChange?: () => void;
  /** host（`<acms-block-editor>`）の root-class 属性から転送される。マウントコンテナへ直接合成する。 */
  className?: string;
}

const MOUNT_CONTAINER_MARKER = 'data-block-editor-mount';

/**
 * hostElement 自身に既に BlockEditor 用のマウントコンテナ（後述）以外のコンテンツがあるかどうかを
 * 判定する。何らかのコンテンツが既にある（二重マウント、またはサーバーレンダリングされた静的
 * コンテンツ）とみなしてマウントを見送るためのガード。elements/upgrade-legacy-marks.ts の
 * isAlreadyMounted も同じ判定を、置換前のガードとして踏襲している。
 */
function hasContentOtherThanHiddenInputOrMountContainer(hostElement: HTMLElement): boolean {
  return Array.from(hostElement.children).some(
    (child) =>
      !(child instanceof HTMLInputElement && child.type === 'hidden') && !child.hasAttribute(MOUNT_CONTAINER_MARKER)
  );
}

/**
 * hostElement 自身に BlockEditor をマウントし、選択内容を子孫の hidden input へ同期する。
 * `<acms-block-editor>`（elements/block-editor-element.ts）の connectedCallback から呼ばれる。
 * `.js-block-editor`（旧マーク経路）は elements/upgrade-legacy-marks.ts が `<acms-block-editor>` へ
 * 置換（旧構造から本関数が前提とする構造への変換込み）したうえで同じ経路に合流するため、
 * 直接この関数を呼ぶことはない。
 *
 * hidden input 以外のコンテンツが既にある場合は null を返しマウントしない（二重マウント防止）。
 *
 * React は hostElement 自身ではなく、hostElement 内に動的に追加する専用のマウントコンテナへ
 * レンダリングする。hostElement 自身を React のマウント先にすると、テンプレートが用意した
 * hidden input（値の同期先）が React のレンダリング結果で上書きされて消えてしまうため
 * （`<acms-unit-editor>` のようにコンポーネント自身が hidden input を描画する自己完結型設計とは
 * 異なり、`<acms-block-editor>` は外部が用意した hidden input を読み書きする設計のため）。
 *
 * el.editor（tiptap の Editor インスタンス）を捕捉する手段には onCreate ではなく onTransaction を使う。
 * `useBlockEditor`（@ablogcms/block-editor）内部の onCreate は `editor.commands.setContent(defaultValue)`
 * という初期化に必須のロジックを持ち、渡した onCreate で完全に上書きされてしまう（内部の options オブジェクトが
 * `onCreate: {...}` を定義した後に `...editorOptions` をスプレッドしているため、呼び出し側の onCreate が
 * 常に内部の既定値を潰す）。onTransaction は内部で使われていないため、渡しても内部の初期化ロジックを
 * 壊さずに editor インスタンスへアクセスできる。
 *
 * @throws {Error} 子孫に hidden input が見つからない場合（createProps 由来）
 */
export function mountBlockEditorField(
  hostElement: HTMLElement,
  callbacks: BlockEditorMountCallbacks = {}
): BlockEditorFieldController | null {
  if (hasContentOtherThanHiddenInputOrMountContainer(hostElement)) {
    return null;
  }

  const { onEditorReady, onChange, className } = callbacks;
  const props = createProps(hostElement, {
    onTransaction: ({ editor }: { editor: Editor }) => {
      onEditorReady?.(editor);
    },
    onInputChange: () => {
      onChange?.();
    },
  });

  const mountContainer = document.createElement('div');
  mountContainer.setAttribute(MOUNT_CONTAINER_MARKER, '');
  if (className) {
    mountContainer.className = className;
  }
  hostElement.append(mountContainer);

  const root = render(<ConnectedBlockEditor {...props} />, mountContainer);

  return {
    unmount() {
      root.unmount();
      mountContainer.remove();
    },
  };
}
