import {
  useCallback,
  useEffect,
  useLayoutEffect,
  useRef,
  useState,
  type CSSProperties,
} from "react";
import { createPortal } from "react-dom";
import { useEditor, EditorContent, type Editor, Extension } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { Underline } from "@tiptap/extension-underline";
import { TextStyle } from "@tiptap/extension-text-style";
import { Color } from "@tiptap/extension-color";
import { FontFamily } from "@tiptap/extension-font-family";
import { TextAlign } from "@tiptap/extension-text-align";
import { safeHtml } from "@/lib/safe-html";
import { useVisualViewport } from "@/hooks/use-visual-viewport";
import {
  Bold as BoldIcon,
  Italic as ItalicIcon,
  Underline as UnderlineIcon,
  AlignLeft as AlignLeftIcon,
  AlignCenter as AlignCenterIcon,
  AlignRight as AlignRightIcon,
  Check,
  X as XIcon,
} from "lucide-react";

const COLOR_SWATCHES = [
  { value: "", label: "default" },
  { value: "#0B1E3F", label: "navy" },
  { value: "#dc2626", label: "red" },
  { value: "#ea580c", label: "orange" },
  { value: "#ca8a04", label: "amber" },
  { value: "#16a34a", label: "green" },
  { value: "#2563eb", label: "blue" },
  { value: "#7c3aed", label: "violet" },
  { value: "#6b7280", label: "gray" },
];

const FONT_OPTIONS = [
  { value: "", label: "default" },
  { value: "DIN Next LT Arabic", label: "DIN Next LT Arabic" },
  { value: "Tajawal", label: "Tajawal" },
  { value: "Helvetica Neue LT Arabic", label: "Helvetica Neue Arabic" },
  { value: "Helvetica Neue", label: "Helvetica Neue" },
  { value: "Majalla", label: "Majalla" },
  { value: "IBM Plex Sans Arabic", label: "IBM Plex" },
  { value: "system-ui", label: "system-ui" },
];

const FONT_SIZE_OPTIONS = [
  { value: "", label: "default" },
  { value: "10px", label: "10" },
  { value: "12px", label: "12" },
  { value: "14px", label: "14" },
  { value: "16px", label: "16" },
  { value: "18px", label: "18" },
  { value: "20px", label: "20" },
  { value: "24px", label: "24" },
  { value: "28px", label: "28" },
];

declare module "@tiptap/core" {
  interface Commands<ReturnType> {
    fontSize: {
      setFontSize: (size: string) => ReturnType;
      unsetFontSize: () => ReturnType;
    };
  }
}

const FontSize = Extension.create({
  name: "fontSize",
  addOptions() {
    return { types: ["textStyle"] };
  },
  addGlobalAttributes() {
    return [
      {
        types: this.options.types,
        attributes: {
          fontSize: {
            default: null,
            parseHTML: (element: HTMLElement) =>
              element.style.fontSize?.replace(/['"]+/g, "") || null,
            renderHTML: (attrs: { fontSize?: string | null }) => {
              if (!attrs.fontSize) return {};
              return { style: `font-size: ${attrs.fontSize}` };
            },
          },
        },
      },
    ];
  },
  addCommands() {
    return {
      setFontSize:
        (size: string) =>
        ({ chain }) =>
          chain().setMark("textStyle", { fontSize: size }).run(),
      unsetFontSize:
        () =>
        ({ chain }) =>
          chain()
            .setMark("textStyle", { fontSize: null })
            .removeEmptyTextStyle()
            .run(),
    };
  },
});

/**
 * In-place rich text editor used in the executive-meetings schedule for
 * Word-like editing of meeting names + attendee names.
 *
 * - When not editing, renders the saved sanitized HTML via dangerouslySetInnerHTML.
 * - Click (or focus + Enter) enters edit mode with a floating toolbar.
 * - Saves on blur outside the editor+toolbar or Ctrl/Cmd+Enter.
 * - Esc cancels.
 *
 * The server is the source of sanitization. This component only sends what
 * Tiptap produces; the server strips anything outside the allowlist.
 */
export function EditableCell({
  value,
  onSave,
  placeholder,
  ariaLabel,
  dir,
  className,
  multiline = false,
  disabled = false,
  fontStyle,
  testId,
  startInEditMode = false,
  onStartedEditing,
  forceSaveOnBlur = false,
  onCancel,
  onTabNext,
  onTabPrev,
}: {
  value: string;
  onSave: (html: string) => Promise<void> | void;
  placeholder?: string;
  ariaLabel?: string;
  dir?: "rtl" | "ltr" | "auto";
  className?: string;
  multiline?: boolean;
  disabled?: boolean;
  fontStyle?: CSSProperties;
  testId?: string;
  startInEditMode?: boolean;
  onStartedEditing?: () => void;
  /**
   * When true, `onSave` is invoked on blur even if the content is unchanged.
   * Used by the schedule's "+ Add attendee" ghost row so the caller can
   * commit a new attendee or discard an empty add when the user blurs out
   * of a freshly-opened cell.
   */
  forceSaveOnBlur?: boolean;
  /**
   * Called when the user explicitly cancels (Escape). Used by the ghost
   * "+ Add attendee" row so the caller can clear pending state and remove
   * the synthetic row from the cell.
   */
  onCancel?: () => void;
  /**
   * If provided, plain `Tab` (no Shift) inside the editor commits the
   * current value and then invokes this callback. The callback is
   * responsible for what should happen "next" — typically opening a
   * new pending attendee row immediately after this one. We swallow
   * the default Tab behaviour so the editor never inserts a tab
   * character.
   */
  onTabNext?: () => void;
  /**
   * If provided, `Shift+Tab` inside the editor commits the current
   * value and then invokes this callback. Mirror of `onTabNext` — used
   * by the attendee flow to focus the previous person row in the same
   * section. May be invoked even when there is nothing to focus
   * (callback may no-op); we still preventDefault so the editor never
   * inserts a tab character.
   */
  onTabPrev?: () => void;
}) {
  const [editing, setEditing] = useState(false);
  const wrapperRef = useRef<HTMLDivElement | null>(null);
  const toolbarRef = useRef<HTMLDivElement | null>(null);
  const blurTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

  // `useEditor` below is created once (deps `[]`), so its `handleKeyDown`
  // closes over the first render's `onTabNext` / `onTabPrev`. Stash the
  // latest in refs so Tab / Shift+Tab always invoke the current callback.
  const onTabNextRef = useRef(onTabNext);
  const onTabPrevRef = useRef(onTabPrev);
  useEffect(() => {
    onTabNextRef.current = onTabNext;
    onTabPrevRef.current = onTabPrev;
  }, [onTabNext, onTabPrev]);

  useEffect(() => {
    if (startInEditMode && !editing && !disabled) {
      setEditing(true);
      onStartedEditing?.();
    }
  }, [startInEditMode, editing, disabled, onStartedEditing]);

  const editor = useEditor(
    {
      extensions: [
        StarterKit.configure({
          heading: false,
          codeBlock: false,
          blockquote: false,
          horizontalRule: false,
          bulletList: false,
          orderedList: false,
          listItem: false,
        }),
        Underline,
        TextStyle,
        Color,
        FontFamily.configure({ types: ["textStyle"] }),
        FontSize,
        TextAlign.configure({
          types: ["paragraph", "heading"],
          alignments: ["left", "center", "right"],
        }),
      ],
      content: value || "",
      editable: editing && !disabled,
      editorProps: {
        attributes: {
          class:
            "outline-none focus:outline-none min-h-[1.5em] " +
            (multiline ? "" : "whitespace-nowrap overflow-hidden"),
          dir: dir ?? "auto",
        },
        handleKeyDown: (_view, event) => {
          if (event.key === "Escape") {
            cancelEdit();
            return true;
          }
          if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) {
            saveEdit();
            return true;
          }
          if (event.key === "Enter" && !multiline && !event.shiftKey) {
            event.preventDefault();
            saveEdit();
            return true;
          }
          // Tab / Shift+Tab → commit and hand off to the parent's
          // navigation callback (typically: open the next pending
          // attendee row, or focus the previous attendee in the same
          // section). Without a callback we leave Tab to the browser
          // so focus moves naturally to the next focusable control.
          if (event.key === "Tab") {
            const cb = event.shiftKey
              ? onTabPrevRef.current
              : onTabNextRef.current;
            if (cb) {
              event.preventDefault();
              void Promise.resolve(saveEdit()).then(() => cb());
              return true;
            }
          }
          return false;
        },
      },
    },
    [],
  );

  useEffect(() => {
    if (!editor) return;
    if (!editing && editor.getHTML() !== (value || "<p></p>")) {
      editor.commands.setContent(value || "", { emitUpdate: false });
    }
  }, [editor, value, editing]);

  useEffect(() => {
    if (!editor) return;
    editor.setEditable(editing && !disabled);
    if (editing) {
      const id = setTimeout(() => editor.commands.focus("end"), 0);
      return () => clearTimeout(id);
    }
    return undefined;
  }, [editor, editing, disabled]);

  // If the host disables this cell while we're mid-edit (e.g. the
  // schedule's global Edit toggle is flipped back to view mode), drop
  // any unsaved draft and exit edit mode immediately. Without this the
  // cell stays in editing state with a frozen toolbar even though the
  // editor itself has gone read-only via setEditable above.
  useEffect(() => {
    if (disabled && editing && editor) {
      editor.commands.setContent(value || "", { emitUpdate: false });
      setEditing(false);
      if (blurTimerRef.current) {
        clearTimeout(blurTimerRef.current);
        blurTimerRef.current = null;
      }
    }
  }, [disabled, editing, editor, value]);

  const enterEdit = () => {
    if (disabled) return;
    setEditing(true);
  };

  const cancelEdit = useCallback(() => {
    if (!editor) return;
    editor.commands.setContent(value || "", { emitUpdate: false });
    setEditing(false);
    onCancel?.();
  }, [editor, value, onCancel]);

  // #317: re-entrancy guard. The outside-pointerdown flush, the blur
  // timer, the unmount cleanup, and explicit Enter/Tab handlers can
  // all race to call saveEdit for the same interaction. Without this
  // guard a click-outside followed by the focusout's 100 ms timer
  // would issue two PATCH/PUT requests for one edit.
  const savingRef = useRef(false);

  const saveEdit = useCallback(async () => {
    if (savingRef.current) return;
    if (!editor) return;
    const html = editor.getHTML();
    const stripped = html === "<p></p>" ? "" : html;
    setEditing(false);
    if (stripped !== value || forceSaveOnBlur) {
      savingRef.current = true;
      try {
        await onSave(stripped);
      } catch {
        // #317: failure UX matches the schedule time editor (#316) —
        // re-open the editor with the user's typed draft intact so
        // they can fix the error and retry without retyping. The
        // parent's optimistic cache write is rolled back in its own
        // catch branch, so the read-only `value` we close over here
        // already reflects the original pre-edit content. We
        // intentionally do NOT call `editor.commands.setContent(value)`
        // — the editor still holds the typed HTML, and flipping
        // `editing` back to true makes it visible and focusable.
        setEditing(true);
      } finally {
        savingRef.current = false;
      }
    }
  }, [editor, onSave, value, forceSaveOnBlur]);

  // #317: stash the latest saveEdit so the unmount cleanup below can
  // call it without capturing a stale closure. We only need this for
  // the "flush on unmount" path; normal blur calls saveEdit directly.
  const saveEditRef = useRef(saveEdit);
  useEffect(() => {
    saveEditRef.current = saveEdit;
  }, [saveEdit]);

  // #317: if the cell unmounts (e.g. the row is replaced by a refetch
  // landing while the 100 ms blur timer is still pending) fire the
  // save synchronously so the user's edit is never silently dropped.
  // Without this, a fast Tab into the next cell that triggers the
  // parent's optimistic refetch can tear down the editor before the
  // setTimeout fires, losing the typed change. Fire-and-forget is
  // safe because the parent's saveTitle / saveAttendeeName / etc.
  // already write the optimistic value into the React Query cache.
  useEffect(() => {
    return () => {
      if (blurTimerRef.current) {
        clearTimeout(blurTimerRef.current);
        blurTimerRef.current = null;
        void saveEditRef.current();
      }
    };
  }, []);

  const onFocusOut = (e: React.FocusEvent<HTMLDivElement>) => {
    if (!editing) return;
    const next = e.relatedTarget as Node | null;
    if (
      next &&
      (wrapperRef.current?.contains(next) ||
        toolbarRef.current?.contains(next))
    )
      return;
    if (blurTimerRef.current) clearTimeout(blurTimerRef.current);
    blurTimerRef.current = setTimeout(() => {
      saveEdit();
    }, 100);
  };

  // When the toolbar is portalled outside wrapperRef, native pointerdown on
  // the toolbar would still trigger our onFocusOut path on some browsers.
  // We watch document-level pointerdown and ignore taps inside the toolbar.
  // #317: pointerdown OUTSIDE the wrapper+toolbar synchronously flushes
  // the save instead of waiting for the 100 ms blur timer. This closes
  // the timing window where a fast click into another cell would
  // re-render this row before the blur timer fired and silently drop
  // the typed change. The unmount cleanup above is the second safety
  // net for the case where a refetch tears down the row before any
  // pointer event fires.
  useEffect(() => {
    if (!editing) return;
    const onPointerDownDoc = (e: PointerEvent) => {
      const target = e.target as Node | null;
      if (!target) return;
      const inside =
        wrapperRef.current?.contains(target) ||
        toolbarRef.current?.contains(target);
      if (inside) {
        if (blurTimerRef.current) {
          clearTimeout(blurTimerRef.current);
          blurTimerRef.current = null;
        }
        return;
      }
      // Outside tap → cancel any pending blur timer and commit now.
      if (blurTimerRef.current) {
        clearTimeout(blurTimerRef.current);
        blurTimerRef.current = null;
      }
      void saveEditRef.current();
    };
    document.addEventListener("pointerdown", onPointerDownDoc, true);
    return () => {
      document.removeEventListener("pointerdown", onPointerDownDoc, true);
    };
  }, [editing]);

  const onFocusIn = () => {
    if (blurTimerRef.current) {
      clearTimeout(blurTimerRef.current);
      blurTimerRef.current = null;
    }
  };

  if (!editor) {
    return (
      <div
        className={className}
        style={fontStyle}
        dangerouslySetInnerHTML={{
          __html: value ? safeHtml(value) : (placeholder ?? ""),
        }}
      />
    );
  }

  if (!editing) {
    return (
      <div
        role={disabled ? undefined : "button"}
        tabIndex={disabled ? -1 : 0}
        aria-label={ariaLabel}
        className={
          (className ?? "") +
          (disabled
            ? ""
            : " cursor-text hover:bg-blue-50/50 focus:bg-blue-50/50 focus:outline-none rounded transition-colors")
        }
        style={fontStyle}
        data-testid={testId}
        onClick={enterEdit}
        onKeyDown={(e) => {
          if (e.key === "Enter" || e.key === " ") {
            e.preventDefault();
            enterEdit();
          }
        }}
      >
        {value ? (
          <span dangerouslySetInnerHTML={{ __html: safeHtml(value) }} />
        ) : (
          <span className="text-muted-foreground italic">
            {placeholder ?? ""}
          </span>
        )}
      </div>
    );
  }

  return (
    <div
      ref={wrapperRef}
      className={"relative " + (className ?? "")}
      onFocus={onFocusIn}
      onBlur={onFocusOut}
      data-testid={testId}
      data-editing="true"
      style={fontStyle}
    >
      <FormattingToolbar
        editor={editor}
        anchorRef={wrapperRef}
        toolbarRef={toolbarRef}
        onSave={saveEdit}
        onCancel={cancelEdit}
      />
      <div
        className="border border-blue-400 rounded bg-white px-1.5 py-1 shadow-sm focus-within:border-blue-600"
        style={fontStyle}
      >
        <EditorContent editor={editor} />
      </div>
    </div>
  );
}

function FormattingToolbar({
  editor,
  anchorRef,
  toolbarRef,
  onSave,
  onCancel,
}: {
  editor: Editor;
  anchorRef: React.RefObject<HTMLElement | null>;
  toolbarRef: React.MutableRefObject<HTMLDivElement | null>;
  onSave: () => void;
  onCancel: () => void;
}) {
  const [, setTick] = useState(0);
  useEffect(() => {
    const update = () => setTick((n) => n + 1);
    editor.on("selectionUpdate", update);
    editor.on("transaction", update);
    return () => {
      editor.off("selectionUpdate", update);
      editor.off("transaction", update);
    };
  }, [editor]);

  const isActive = (mark: string, attrs?: Record<string, unknown>) =>
    editor.isActive(mark, attrs);

  const btn = (active: boolean) =>
    "p-1 rounded text-xs " +
    (active
      ? "bg-[#0B1E3F] text-white"
      : "bg-white text-[#0B1E3F] hover:bg-gray-100 border border-gray-200");

  // Position the toolbar via a portal so it escapes table cells with
  // overflow:hidden. Default placement is BELOW the editor; we only
  // flip ABOVE when staying below would hide the toolbar under the iOS
  // soft keyboard or off the bottom of the visible viewport (#581).
  // We also clamp horizontally to keep it inside the table's scroll
  // container (falling back to the viewport).
  const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
  const [toolbarEl, setToolbarEl] = useState<HTMLDivElement | null>(null);
  const setToolbarNode = useCallback(
    (node: HTMLDivElement | null) => {
      toolbarRef.current = node;
      setToolbarEl(node);
    },
    [toolbarRef],
  );

  // Track the visible viewport so the toolbar can dodge the iOS soft
  // keyboard. On iPad / iOS Safari, when the keyboard opens it overlays
  // the bottom of the layout viewport — `visualViewport.height` shrinks
  // accordingly. We use that to decide when to flip the toolbar above
  // the cell instead of below, so the color swatches / Save / Cancel
  // stay reachable instead of being hidden under the keyboard.
  const vv = useVisualViewport();

  useLayoutEffect(() => {
    // Walk up to find the nearest horizontally-scrolling ancestor
    // (the table's `overflow-x-auto` wrapper). The toolbar must stay
    // inside that container's bounds so it doesn't visually escape the
    // table.
    const findScrollContainer = (el: HTMLElement | null): HTMLElement | null => {
      let cur: HTMLElement | null = el?.parentElement ?? null;
      while (cur && cur !== document.body) {
        const style = window.getComputedStyle(cur);
        const ox = style.overflowX;
        if (ox === "auto" || ox === "scroll") return cur;
        cur = cur.parentElement;
      }
      return null;
    };
    const update = () => {
      const anchor = anchorRef.current;
      if (!anchor) return;
      const rect = anchor.getBoundingClientRect();
      const tbHeight = toolbarEl?.offsetHeight ?? 36;
      const tbWidth = toolbarEl?.offsetWidth ?? 320;
      // Prefer placing the toolbar BELOW the editing cell (keeps it
      // from sitting on top of the row above the active cell, which is
      // what the schedule spec wants). We only flip ABOVE when staying
      // below would put the toolbar under the iOS soft keyboard or off
      // the bottom of the visible viewport — otherwise the color
      // swatches / Save / Cancel become unreachable on iPad. See #581.
      const vvTop = vv.offsetTop;
      const vvBottom =
        vv.offsetTop + (vv.height ?? window.innerHeight);
      // iPad / iOS adds a QuickType prediction strip ABOVE the keyboard
      // (suggested words). `visualViewport.height` doesn't subtract it,
      // so without padding the toolbar lands hidden under that strip.
      // 48px covers both the prediction strip and the Arabic-keyboard
      // shortcut row that some users have enabled.
      const KEYBOARD_PREDICTION_INSET = vv.keyboardInset > 0 ? 48 : 4;
      let top: number;
      if (vv.keyboardInset > 0) {
        // Keyboard is up — pin the toolbar to the BOTTOM of the visible
        // viewport (right above the prediction strip), iMessage/Notes
        // style. This keeps the color swatches + B/I/U + Save/Cancel
        // reachable no matter where the active cell sits, and matches
        // the user's mental model that "formatting follows the keyboard".
        top = vvBottom - tbHeight - KEYBOARD_PREDICTION_INSET;
        // Don't visually cover the cell being edited — if the keyboard-
        // pinned slot would overlap the anchor, fall back to above the
        // cell.
        if (top < rect.bottom + 4 && rect.top - tbHeight - 4 >= vvTop + 4) {
          top = rect.top - tbHeight - 4;
        }
        top = Math.max(vvTop + 4, top);
      } else {
        // No keyboard — prefer BELOW the cell (matches the schedule
        // spec). Flip above only if below would clip the viewport.
        top = rect.bottom + 4;
        if (top + tbHeight > vvBottom - 4) {
          const above = rect.top - tbHeight - 4;
          top = Math.max(vvTop + 4, above);
        }
      }
      // Clamp horizontally to the table's scroll container if we can
      // find one; fall back to the viewport otherwise. This keeps the
      // toolbar visually anchored inside the table.
      // #607: also clamp against the viewport in case the table's
      // scroll container is wider than the screen (mobile/iPad), so
      // the toolbar never lands off-screen and stays usable when it
      // wraps to multiple rows.
      const container = findScrollContainer(anchor);
      const containerRect = container?.getBoundingClientRect();
      const vpMinLeft = 8;
      const vpMaxLeft = window.innerWidth - tbWidth - 8;
      const minLeft = Math.max(
        vpMinLeft,
        containerRect ? containerRect.left + 4 : vpMinLeft,
      );
      const maxLeft = Math.min(
        vpMaxLeft,
        containerRect ? containerRect.right - tbWidth - 4 : vpMaxLeft,
      );
      let left = rect.left;
      if (left > maxLeft) left = maxLeft;
      if (left < minLeft) left = minLeft;
      setPos({ top, left });
    };
    update();
    const raf = requestAnimationFrame(update);
    let ro: ResizeObserver | null = null;
    if (typeof ResizeObserver !== "undefined") {
      ro = new ResizeObserver(update);
      if (anchorRef.current) ro.observe(anchorRef.current);
      if (toolbarEl) ro.observe(toolbarEl);
    }
    window.addEventListener("scroll", update, true);
    window.addEventListener("resize", update);
    return () => {
      cancelAnimationFrame(raf);
      ro?.disconnect();
      window.removeEventListener("scroll", update, true);
      window.removeEventListener("resize", update);
    };
  }, [anchorRef, toolbarEl, vv.height, vv.offsetTop, vv.keyboardInset]);

  const node = (
    <div
      ref={setToolbarNode}
      style={{
        position: "fixed",
        top: pos?.top ?? -9999,
        left: pos?.left ?? -9999,
        zIndex: 1000,
        visibility: pos ? "visible" : "hidden",
      }}
      className="flex flex-wrap items-center gap-x-1 gap-y-1 bg-white border border-gray-200 rounded shadow-md px-1 py-1 max-w-[calc(100vw-16px)]"
      onMouseDown={(e) => e.preventDefault()}
      data-testid="em-edit-toolbar"
    >
      <button
        type="button"
        className={btn(isActive("bold"))}
        onClick={() => editor.chain().focus().toggleBold().run()}
        aria-label="Bold"
        data-testid="em-edit-bold"
      >
        <BoldIcon className="w-3.5 h-3.5" />
      </button>
      <button
        type="button"
        className={btn(isActive("italic"))}
        onClick={() => editor.chain().focus().toggleItalic().run()}
        aria-label="Italic"
        data-testid="em-edit-italic"
      >
        <ItalicIcon className="w-3.5 h-3.5" />
      </button>
      <button
        type="button"
        className={btn(isActive("underline"))}
        onClick={() => editor.chain().focus().toggleUnderline().run()}
        aria-label="Underline"
        data-testid="em-edit-underline"
      >
        <UnderlineIcon className="w-3.5 h-3.5" />
      </button>
      <span className="w-px h-4 bg-gray-200 mx-0.5" />
      {COLOR_SWATCHES.map((c) => (
        <button
          key={c.value || "default"}
          type="button"
          className={
            "w-4 h-4 rounded-sm border " +
            (c.value
              ? "border-gray-300 hover:scale-110"
              : "border-gray-400 bg-[repeating-linear-gradient(45deg,#fff,#fff_3px,#e5e7eb_3px,#e5e7eb_6px)]")
          }
          style={c.value ? { backgroundColor: c.value } : undefined}
          onClick={() => {
            if (c.value) {
              editor.chain().focus().setColor(c.value).run();
            } else {
              editor.chain().focus().unsetColor().run();
            }
          }}
          aria-label={`color ${c.label}`}
          data-testid={`em-edit-color-${c.label}`}
        />
      ))}
      <span className="w-px h-4 bg-gray-200 mx-0.5" />
      <button
        type="button"
        className={btn(isActive("paragraph", { textAlign: "right" }))}
        onClick={() => editor.chain().focus().setTextAlign("right").run()}
        aria-label="Align right"
        data-testid="em-edit-align-right"
      >
        <AlignRightIcon className="w-3.5 h-3.5" />
      </button>
      <button
        type="button"
        className={btn(isActive("paragraph", { textAlign: "center" }))}
        onClick={() => editor.chain().focus().setTextAlign("center").run()}
        aria-label="Align center"
        data-testid="em-edit-align-center"
      >
        <AlignCenterIcon className="w-3.5 h-3.5" />
      </button>
      <button
        type="button"
        className={btn(isActive("paragraph", { textAlign: "left" }))}
        onClick={() => editor.chain().focus().setTextAlign("left").run()}
        aria-label="Align left"
        data-testid="em-edit-align-left"
      >
        <AlignLeftIcon className="w-3.5 h-3.5" />
      </button>
      <span className="w-px h-4 bg-gray-200 mx-0.5" />
      <select
        className="text-xs border border-gray-200 rounded bg-white text-[#0B1E3F] px-1 py-0.5"
        value={(editor.getAttributes("textStyle").fontFamily as string) || ""}
        onChange={(e) => {
          const v = e.target.value;
          if (v) editor.chain().focus().setFontFamily(v).run();
          else editor.chain().focus().unsetFontFamily().run();
        }}
        aria-label="Font"
        data-testid="em-edit-font"
      >
        {FONT_OPTIONS.map((f) => (
          // No `style={{ fontFamily }}` here on purpose: previewing each
          // option in its own family forces the browser to fetch every
          // custom font as soon as the dropdown is opened, defeating the
          // `font-display: swap` lazy-load. We keep labels in the host
          // UI font so a font is downloaded only when actually applied
          // to editor content.
          <option key={f.value || "default"} value={f.value}>
            {f.label}
          </option>
        ))}
      </select>
      <select
        className="text-xs border border-gray-200 rounded bg-white text-[#0B1E3F] px-1 py-0.5"
        value={(editor.getAttributes("textStyle").fontSize as string) || ""}
        onChange={(e) => {
          const v = e.target.value;
          if (v) editor.chain().focus().setFontSize(v).run();
          else editor.chain().focus().unsetFontSize().run();
        }}
        aria-label="Font size"
        data-testid="em-edit-font-size"
      >
        {FONT_SIZE_OPTIONS.map((f) => (
          <option key={f.value || "default"} value={f.value}>
            {f.label}
          </option>
        ))}
      </select>
      <span className="w-px h-4 bg-gray-200 mx-0.5" />
      <button
        type="button"
        className="p-1 rounded bg-green-600 text-white hover:bg-green-700"
        onClick={onSave}
        aria-label="Save"
        data-testid="em-edit-save"
      >
        <Check className="w-3.5 h-3.5" />
      </button>
      <button
        type="button"
        className="p-1 rounded bg-gray-200 text-[#0B1E3F] hover:bg-gray-300"
        onClick={onCancel}
        aria-label="Cancel"
        data-testid="em-edit-cancel"
      >
        <XIcon className="w-3.5 h-3.5" />
      </button>
    </div>
  );

  if (typeof document === "undefined") return node;
  return createPortal(node, document.body);
}

