import React, {
  KeyboardEventHandler,
  ReactNode,
  RefObject,
  forwardRef,
  useCallback,
  useEffect,
  useMemo,
  useRef,
  useState,
} from 'react';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import { isKeyHotkey } from 'is-hotkey';
import { EventType, IContent, MsgType, RelationType, Room } from 'matrix-js-sdk';
import { ReactEditor } from 'slate-react';
import { Transforms, Editor } from 'slate';
import {
  Box,
  Dialog,
  Icon,
  IconButton,
  Icons,
  Line,
  Overlay,
  OverlayBackdrop,
  OverlayCenter,
  PopOut,
  Scroll,
  Spinner,
  Text,
  color,
  config,
  toRem,
} from 'folds';

import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useClientConfig } from '../../hooks/useClientConfig';
import {
  CustomEditor,
  Toolbar,
  toMatrixCustomHTML,
  toPlainText,
  AUTOCOMPLETE_PREFIXES,
  AutocompletePrefix,
  AutocompleteQuery,
  getAutocompleteQuery,
  getPrevWorldRange,
  resetEditor,
  RoomMentionAutocomplete,
  UserMentionAutocomplete,
  EmoticonAutocomplete,
  createEmoticonElement,
  moveCursor,
  resetEditorHistory,
  customHtmlEqualsPlainText,
  trimCustomHtml,
  isEmptyEditor,
  getBeginCommand,
  trimCommand,
  getMentions,
} from '../../components/editor';
import { EmojiBoardTab } from '../../components/emoji-board/types';
import { UseStateProvider } from '../../components/UseStateProvider';
import {
  TUploadContent,
  encryptFile,
  getImageInfo,
  mxcUrlToHttp,
  tryDeleteMxcContent,
} from '../../utils/matrix';
import { compressImage, isCompressible } from '../../utils/imageCompression';
import { useTypingStatusUpdater } from '../../hooks/useTypingStatusUpdater';
import { useFilePicker } from '../../hooks/useFilePicker';
import { useFilePasteHandler } from '../../hooks/useFilePasteHandler';
import { useFileDropZone } from '../../hooks/useFileDrop';
import {
  TUploadItem,
  TUploadMetadata,
  roomIdToMsgDraftAtomFamily,
  roomIdToReplyDraftAtomFamily,
  roomIdToUploadItemsAtomFamily,
  roomUploadAtomFamily,
} from '../../state/room/roomInputDrafts';
import { UploadCardRenderer } from '../../components/upload-card';
import {
  UploadBoard,
  UploadBoardContent,
  UploadBoardHeader,
  UploadBoardImperativeHandlers,
} from '../../components/upload-board';
import {
  Upload,
  UploadStatus,
  UploadSuccess,
  createUploadFamilyObserverAtom,
} from '../../state/upload';
import { getImageUrlBlob, loadImageElement } from '../../utils/dom';
import { safeFile } from '../../utils/mimeTypes';
import { fulfilledPromiseSettledResult } from '../../utils/common';
import { useSetting } from '../../state/hooks/settings';
import { useAlive } from '../../hooks/useAlive';
import {
  ComposerToolbarButtonKey,
  normalizeComposerToolbarOrder,
  settingsAtom,
} from '../../state/settings';
import {
  getAudioMsgContent,
  getFileMsgContent,
  getImageMsgContent,
  getVideoMsgContent,
} from './msgContent';
import { getMemberName, getMentionContent, trimReplyFromBody } from '../../utils/room';
import { CommandAutocomplete } from './CommandAutocomplete';
import { Command, SHRUG, TABLEFLIP, UNFLIP, useCommands } from '../../hooks/useCommands';
import { mobileOrTablet } from '../../utils/user-agent';
import { useElementSizeObserver } from '../../hooks/useElementSizeObserver';
import { ReplyLayout, ThreadIndicator } from '../../components/message';
import { roomToParentsAtom } from '../../state/room/roomToParents';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useImagePackRooms } from '../../hooks/useImagePackRooms';
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
import colorMXID from '../../../util/colorMXID';
import { useIsDirectRoom } from '../../hooks/useRoom';
import { useAccessiblePowerTagColors, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag';
import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useTheme } from '../../hooks/useTheme';
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
import { useComposingCheck } from '../../hooks/useComposingCheck';
import { VoiceMessageRecorder } from '../../components/VoiceMessageRecorder';
import { PollCreator } from './PollCreator';
import { useRoomUnverifiedDeviceCount } from '../../hooks/useDeviceVerificationStatus';
import { ScheduleMessageModal } from './ScheduleMessageModal';
import { ScheduledMessagesTray } from './ScheduledMessagesTray';
import { DraftIndicator } from './DraftIndicator';
import { scheduledMessagesAtom } from '../../state/scheduledMessages';
import { createErrorToast, toastQueueAtom } from '../../state/toast';
import { getThreadDraftKey } from '../../state/room/thread';

const GifPicker = React.lazy(() =>
  import('../../components/GifPicker').then((m) => ({ default: m.GifPicker })),
);
const EmojiBoard = React.lazy(() =>
  import('../../components/emoji-board').then((m) => ({ default: m.EmojiBoard })),
);

interface RoomInputProps {
  editor: Editor;
  fileDropContainerRef: RefObject<HTMLElement>;
  roomId: string;
  room: Room;
  threadRootId?: string;
  // Identifies this composer to global key handlers (e.g. the up-arrow "edit last
  // message" handler). Threads pass a distinct name so the main timeline's handler
  // doesn't fire for the thread composer, and vice-versa.
  editableName?: string;
}
export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
  (
    { editor, fileDropContainerRef, roomId, room, threadRootId, editableName = 'RoomInput' },
    ref,
  ) => {
    const mx = useMatrixClient();
    const useAuthentication = useMediaAuthentication();
    const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
    const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
    const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
    const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
    const [warnOnUnverifiedDevices] = useSetting(settingsAtom, 'warnOnUnverifiedDevices');
    const crypto = mx.getCrypto();
    const roomUnverifiedDeviceCount = useRoomUnverifiedDeviceCount(crypto, room);
    const isEncrypted = room.hasEncryptionStateEvent();
    const showUnverifiedWarning =
      warnOnUnverifiedDevices &&
      isEncrypted &&
      roomUnverifiedDeviceCount !== undefined &&
      roomUnverifiedDeviceCount > 0;
    const direct = useIsDirectRoom();
    const commands = useCommands(mx, room);
    const emojiBtnRef = useRef<HTMLButtonElement>(null);
    const roomToParents = useAtomValue(roomToParentsAtom);
    const powerLevels = usePowerLevelsContext();
    const creators = useRoomCreators(room);

    const [charCount, setCharCount] = useState(0);
    useEffect(() => {
      setCharCount(0);
    }, [roomId]);
    const [pollOpen, setPollOpen] = useState(false);
    const [scheduleOpen, setScheduleOpen] = useState(false);
    const [scheduleContent, setScheduleContent] = useState<IContent | null>(null);
    const setScheduledMessages = useSetAtom(scheduledMessagesAtom);
    const setToast = useSetAtom(toastQueueAtom);

    const alive = useAlive();
    // Scope drafts/replies/uploads by thread so a thread composer stays fully
    // isolated from the main room composer (and from other threads).
    const draftKey = threadRootId ? getThreadDraftKey(roomId, threadRootId) : roomId;
    const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(draftKey));
    const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(draftKey));
    const replyUserID = replyDraft?.userId;

    const powerLevelTags = usePowerLevelTags(room, powerLevels);
    const creatorsTag = useRoomCreatorsTag();
    const getMemberPowerTag = useGetMemberPowerTag(room, creators, powerLevels);
    const theme = useTheme();
    const accessibleTagColors = useAccessiblePowerTagColors(
      theme.kind,
      creatorsTag,
      powerLevelTags,
    );

    const replyPowerTag = replyUserID ? getMemberPowerTag(replyUserID) : undefined;
    const replyPowerColor = replyPowerTag?.color
      ? accessibleTagColors.get(replyPowerTag.color)
      : undefined;
    const replyUsernameColor =
      legacyUsernameColor || direct ? colorMXID(replyUserID ?? '') : replyPowerColor;

    const [uploadBoard, setUploadBoard] = useState(true);
    const [selectedFiles, setSelectedFiles] = useAtom(roomIdToUploadItemsAtomFamily(draftKey));
    const uploadFamilyObserverAtom = createUploadFamilyObserverAtom(
      roomUploadAtomFamily,
      selectedFiles.map((f) => f.file),
    );
    const uploadBoardHandlers = useRef<UploadBoardImperativeHandlers | undefined>(undefined);

    const imagePackRooms: Room[] = useImagePackRooms(roomId, roomToParents);

    const [toolbar, setToolbar] = useSetting(settingsAtom, 'editorToolbar');
    const [composerToolbarButtons] = useSetting(settingsAtom, 'composerToolbarButtons');
    const isMobile = mobileOrTablet();
    // On phones the composer's secondary action buttons (attach, GIF, poll,
    // location, voice, formatting, schedule) collapse behind a "+" toggle so the
    // input stays one compact row instead of wrapping into a tall stack. Emoji +
    // Send remain inline. Desktop keeps everything inline (isMobile === false).
    const [mobileToolsOpen, setMobileToolsOpen] = useState(false);
    const touchTarget = isMobile ? { minWidth: '44px', minHeight: '44px' } : undefined;
    const showFormat = composerToolbarButtons?.showFormat ?? true;
    const showEmoji = composerToolbarButtons?.showEmoji ?? true;
    const showSticker = composerToolbarButtons?.showSticker ?? true;
    const showGif = composerToolbarButtons?.showGif ?? true;
    const showLocation = composerToolbarButtons?.showLocation ?? true;
    const showPoll = composerToolbarButtons?.showPoll ?? true;
    const showVoice = composerToolbarButtons?.showVoice ?? true;
    // Schedule-send is hidden in thread mode (v1 reduction).
    const showSchedule = (composerToolbarButtons?.showSchedule ?? true) && !threadRootId;
    const composerButtonOrder = useMemo(
      () => normalizeComposerToolbarOrder(composerToolbarButtons?.order),
      [composerToolbarButtons?.order],
    );
    const [locating, setLocating] = React.useState(false);
    const [locationError, setLocationError] = React.useState<string | null>(null);
    const handleShareLocation = useCallback(() => {
      if (!navigator.geolocation) {
        setLocationError('Geolocation not supported.');
        setTimeout(() => setLocationError(null), 4000);
        return;
      }
      setLocating(true);
      navigator.geolocation.getCurrentPosition(
        (pos) => {
          setLocating(false);
          const { latitude, longitude } = pos.coords;
          const geoUri = `geo:${latitude.toFixed(6)},${longitude.toFixed(6)}`;
          const ts = Date.now();
          // MSC3488 extensible location: send the geo_uri (legacy) alongside the
          // m.location/m.asset/m.ts blocks so Element and other clients render a
          // proper "shared location" pin instead of falling back to plain text.
          mx.sendMessage(roomId, threadRootId ?? null, {
            msgtype: 'm.location',
            body: `Shared a location: ${geoUri}`,
            geo_uri: geoUri,
            'org.matrix.msc3488.location': { uri: geoUri },
            'org.matrix.msc3488.asset': { type: 'm.self' },
            'org.matrix.msc3488.ts': ts,
            'm.ts': ts,
          } as any);
        },
        (err) => {
          setLocating(false);
          const msg =
            err.code === 1
              ? 'Location access denied.'
              : err.code === 3
                ? 'Location timed out.'
                : 'Failed to get location.';
          setLocationError(msg);
          setTimeout(() => setLocationError(null), 4000);
        },
        { timeout: 10000 },
      );
    }, [mx, roomId, threadRootId]);

    const handleVoiceSend = useCallback(
      async (blob: Blob, mimeType: string, durationMs: number, waveform: number[]) => {
        const baseContent: IContent = {
          msgtype: MsgType.Audio,
          body: 'Voice message',
          filename: 'voice-message.ogg',
          'org.matrix.msc3245.voice': {},
          'org.matrix.msc1767.audio': { duration: durationMs, waveform },
          info: { mimetype: mimeType, size: blob.size, duration: durationMs },
        };

        if (room.hasEncryptionStateEvent()) {
          const { encInfo, file: encBlob } = await encryptFile(blob);
          const uploadResult = await mx.uploadContent(encBlob);
          mx.sendMessage(roomId, threadRootId ?? null, {
            ...baseContent,
            file: { ...encInfo, url: uploadResult.content_uri },
          } as any);
        } else {
          const uploadResult = await mx.uploadContent(blob, {
            name: 'voice-message.ogg',
            type: mimeType,
          });
          mx.sendMessage(roomId, threadRootId ?? null, {
            ...baseContent,
            url: uploadResult.content_uri,
          } as any);
        }
      },
      [mx, room, roomId, threadRootId],
    );

    const [autocompleteQuery, setAutocompleteQuery] =
      useState<AutocompleteQuery<AutocompletePrefix>>();

    const sendTypingStatus = useTypingStatusUpdater(mx, roomId);

    const handleFiles = useCallback(
      async (files: File[]) => {
        setUploadBoard(true);
        const safeFiles = files.map(safeFile);
        const fileItems: TUploadItem[] = [];

        if (room.hasEncryptionStateEvent()) {
          const encryptFiles = fulfilledPromiseSettledResult(
            await Promise.allSettled(safeFiles.map((f) => encryptFile(f))),
          );
          encryptFiles.forEach((ef) =>
            fileItems.push({
              ...ef,
              metadata: {
                markedAsSpoiler: false,
              },
            }),
          );
        } else {
          safeFiles.forEach((f) =>
            fileItems.push({
              file: f,
              originalFile: f,
              encInfo: undefined,
              metadata: {
                markedAsSpoiler: false,
              },
            }),
          );
        }
        setSelectedFiles({
          type: 'PUT',
          item: fileItems,
        });
      },
      [setSelectedFiles, room],
    );
    const pickFile = useFilePicker(handleFiles, true);
    const handlePaste = useFilePasteHandler(handleFiles);
    const dropZoneVisible = useFileDropZone(fileDropContainerRef, handleFiles);
    const { gifApiKey } = useClientConfig();
    const gifBtnRef = useRef<HTMLButtonElement>(null);
    const [hideStickerBtn, setHideStickerBtn] = useState(document.body.clientWidth < 500);
    const [gifError, setGifError] = React.useState<string | null>(null);
    const [gifUploading, setGifUploading] = React.useState(false);

    const isComposing = useComposingCheck();

    useElementSizeObserver(
      useCallback(() => fileDropContainerRef.current, [fileDropContainerRef]),
      useCallback((width) => setHideStickerBtn(width < 500), []),
    );

    const didRestoreDraft = React.useRef(false);
    useEffect(() => {
      if (didRestoreDraft.current) return;
      didRestoreDraft.current = true;
      if (msgDraft.length > 0) {
        Transforms.insertFragment(editor, msgDraft);
      } else {
        // Jotai draft is empty (page reload) — try localStorage fallback
        try {
          const stored = localStorage.getItem(`draft-msg-${draftKey}`);
          if (stored) {
            const nodes = JSON.parse(stored);
            if (Array.isArray(nodes) && nodes.length > 0) {
              Transforms.insertFragment(editor, nodes);
              // Mirror the restored draft into the atom so the draft indicator
              // (reads roomIdToMsgDraftAtomFamily) reflects a persisted draft
              // after a page reload — not only on same-session room re-entry.
              setMsgDraft(nodes);
            }
          }
        } catch {
          // Ignore malformed stored draft
        }
      }
    }, [editor, msgDraft, draftKey, setMsgDraft]);

    useEffect(
      () => () => {
        if (!isEmptyEditor(editor)) {
          const parsedDraft = JSON.parse(JSON.stringify(editor.children));
          setMsgDraft(parsedDraft);
          localStorage.setItem(`draft-msg-${draftKey}`, JSON.stringify(parsedDraft));
        } else {
          setMsgDraft([]);
          localStorage.removeItem(`draft-msg-${draftKey}`);
        }
        resetEditor(editor);
        resetEditorHistory(editor);
      },
      [draftKey, editor, setMsgDraft],
    );

    const handleFileMetadata = useCallback(
      (fileItem: TUploadItem, metadata: TUploadMetadata) => {
        setSelectedFiles({
          type: 'REPLACE',
          item: fileItem,
          replacement: { ...fileItem, metadata },
        });
      },
      [setSelectedFiles],
    );

    const handleRemoveUpload = useCallback(
      (upload: TUploadContent | TUploadContent[]) => {
        const uploads = Array.isArray(upload) ? upload : [upload];
        setSelectedFiles({
          type: 'DELETE',
          item: selectedFiles.filter((f) => uploads.find((u) => u === f.file)),
        });
        uploads.forEach((u) => roomUploadAtomFamily.remove(u));
      },
      [setSelectedFiles, selectedFiles],
    );

    const handleCancelUpload = useCallback(
      (uploads: Upload[]) => {
        uploads.forEach((upload) => {
          if (upload.status === UploadStatus.Loading) {
            mx.cancelUpload(upload.promise);
          }
        });
        handleRemoveUpload(uploads.map((upload) => upload.file));
      },
      [mx, handleRemoveUpload],
    );

    const handleSendUpload = useCallback(
      async (uploads: UploadSuccess[]) => {
        const contentsPromises = uploads.map(async (upload) => {
          const fileItem = selectedFiles.find((f) => f.file === upload.file);
          if (!fileItem) throw new Error('Broken upload');

          // Resolve the MXC URL to use — may be overridden if compression is enabled
          let mxc = upload.mxc;

          if (fileItem.metadata.compressImage && isCompressible(fileItem.originalFile)) {
            // Use the cached compression result if available, otherwise compute it now
            let compressionResult = fileItem.metadata.compressionResult;
            if (compressionResult === undefined) {
              compressionResult = await compressImage(fileItem.originalFile);
            }

            if (compressionResult) {
              const originalFile = fileItem.originalFile as File;
              // compressImage re-encodes as JPEG; swap the extension so the file
              // name and MIME type agree (avoids e.g. a JPEG named "photo.png").
              const compressedType = compressionResult.type;
              const compressedName = `${originalFile.name.replace(/\.[^./\\]+$/, '')}.jpg`;
              const compressedFile = new File([compressionResult.blob], compressedName, {
                type: compressedType,
              });
              const uploadRes = await mx.uploadContent(compressedFile, {
                name: compressedName,
                type: compressedType,
              });
              const compressedMxc = (uploadRes as { content_uri: string }).content_uri;
              if (compressedMxc) {
                // Delete the pre-uploaded original so only one copy lives on the server.
                tryDeleteMxcContent(mx, upload.mxc);
                mxc = compressedMxc;
                // Build a synthetic fileItem that refers to the compressed file so
                // getImageMsgContent picks up the correct dimensions and type.
                const compressedItem = {
                  ...fileItem,
                  file: compressedFile,
                  originalFile: compressedFile,
                };
                return getImageMsgContent(mx, compressedItem, mxc);
              }
            }
          }

          if (fileItem.file.type.startsWith('image')) {
            return getImageMsgContent(mx, fileItem, mxc);
          }
          if (fileItem.file.type.startsWith('video')) {
            return getVideoMsgContent(mx, fileItem, mxc);
          }
          if (fileItem.file.type.startsWith('audio')) {
            return getAudioMsgContent(fileItem, mxc);
          }
          return getFileMsgContent(fileItem, mxc);
        });
        handleCancelUpload(uploads);
        const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises));
        contents.forEach((content) => mx.sendMessage(roomId, threadRootId ?? null, content as any));
      },
      [mx, roomId, threadRootId, selectedFiles, handleCancelUpload],
    );

    const submit = useCallback(() => {
      uploadBoardHandlers.current?.handleSend();

      // Slash commands work in threads too: content-transform commands (/me,
      // /notice, /shrug, /tableflip, /unflip) flow into the normal send below,
      // which routes to the thread via `threadRootId`; the rest (/invite, /kick,
      // …) are room-level actions. This also matches the command autocomplete,
      // which is already shown in the thread composer.
      const commandName = getBeginCommand(editor);
      let plainText = toPlainText(editor.children, isMarkdown).trim();
      let customHtml = trimCustomHtml(
        toMatrixCustomHTML(editor.children, {
          allowTextFormatting: true,
          allowBlockMarkdown: isMarkdown,
          allowInlineMarkdown: isMarkdown,
          allowMath: true,
        }),
      );
      let msgType = MsgType.Text;

      if (commandName) {
        plainText = trimCommand(commandName, plainText);
        customHtml = trimCommand(commandName, customHtml);
      }
      if (commandName === Command.Me) {
        msgType = MsgType.Emote;
      } else if (commandName === Command.Notice) {
        msgType = MsgType.Notice;
      } else if (commandName === Command.Shrug) {
        plainText = `${SHRUG} ${plainText}`;
        customHtml = `${SHRUG} ${customHtml}`;
      } else if (commandName === Command.TableFlip) {
        plainText = `${TABLEFLIP} ${plainText}`;
        customHtml = `${TABLEFLIP} ${customHtml}`;
      } else if (commandName === Command.UnFlip) {
        plainText = `${UNFLIP} ${plainText}`;
        customHtml = `${UNFLIP} ${customHtml}`;
      } else if (commandName) {
        const commandContent = commands[commandName as Command];
        if (commandContent) {
          // Fire-and-forget by design (the editor resets immediately for UX), but
          // surface a rejection instead of failing silently. NOTE: /kick and /ban
          // route through rateLimitedActions (utils/matrix.ts), whose to() helper
          // swallows non-429 errors, so those two commands can still resolve even
          // when the underlying kick/ban failed — this catch only covers errors
          // that actually reject out of exe().
          commandContent.exe(plainText).catch((err) => {
            console.error(`Failed to run /${commandName} command:`, err);
            setToast(
              createErrorToast(
                `The /${commandName} command failed. Please try again.`,
                Icons.Warning,
                'Command failed',
              ),
            );
          });
        }
        resetEditor(editor);
        resetEditorHistory(editor);
        setCharCount(0);
        sendTypingStatus(false);
        return;
      }

      if (plainText === '') return;

      const body = plainText;
      const formattedBody = customHtml;
      const mentionData = getMentions(mx, roomId, editor);

      const content: IContent = {
        msgtype: msgType,
        body,
      };

      if (replyDraft && replyDraft.userId !== mx.getUserId()) {
        mentionData.users.add(replyDraft.userId);
      }

      const mMentions = getMentionContent(Array.from(mentionData.users), mentionData.room);
      content['m.mentions'] = mMentions;

      if (replyDraft || !customHtmlEqualsPlainText(formattedBody, body)) {
        content.format = 'org.matrix.custom.html';
        content.formatted_body = formattedBody;
      }
      if (replyDraft) {
        content['m.relates_to'] = {
          'm.in_reply_to': {
            event_id: replyDraft.eventId,
          },
        };
        if (replyDraft.relation?.rel_type === RelationType.Thread) {
          content['m.relates_to'].event_id = replyDraft.relation.event_id;
          content['m.relates_to'].rel_type = RelationType.Thread;
          content['m.relates_to'].is_falling_back = false;
        }
      }
      mx.sendMessage(roomId, threadRootId ?? null, content as any);
      resetEditor(editor);
      resetEditorHistory(editor);
      setCharCount(0);
      setMsgDraft([]);
      localStorage.removeItem(`draft-msg-${draftKey}`);
      setReplyDraft(undefined);
      sendTypingStatus(false);
    }, [
      mx,
      roomId,
      threadRootId,
      draftKey,
      editor,
      replyDraft,
      sendTypingStatus,
      setReplyDraft,
      setMsgDraft,
      isMarkdown,
      commands,
      setToast,
    ]);

    /**
     * Build a text message content object from the current editor state.
     * Returns null if the editor is empty or the input is a command.
     */
    const buildCurrentTextContent = useCallback((): IContent | null => {
      const commandName = getBeginCommand(editor);
      // Don't schedule commands
      if (commandName) return null;

      const plainText = toPlainText(editor.children, isMarkdown).trim();
      const customHtml = trimCustomHtml(
        toMatrixCustomHTML(editor.children, {
          allowTextFormatting: true,
          allowBlockMarkdown: isMarkdown,
          allowInlineMarkdown: isMarkdown,
          allowMath: true,
        }),
      );
      if (plainText === '') return null;

      const body = plainText;
      const formattedBody = customHtml;
      const mentionData = getMentions(mx, roomId, editor);

      const content: IContent = {
        msgtype: MsgType.Text,
        body,
      };

      if (replyDraft && replyDraft.userId !== mx.getUserId()) {
        mentionData.users.add(replyDraft.userId);
      }
      content['m.mentions'] = getMentionContent(Array.from(mentionData.users), mentionData.room);

      if (replyDraft || !customHtmlEqualsPlainText(formattedBody, body)) {
        content.format = 'org.matrix.custom.html';
        content.formatted_body = formattedBody;
      }
      if (replyDraft) {
        content['m.relates_to'] = {
          'm.in_reply_to': { event_id: replyDraft.eventId },
        };
        if (replyDraft.relation?.rel_type === RelationType.Thread) {
          content['m.relates_to'].event_id = replyDraft.relation.event_id;
          content['m.relates_to'].rel_type = RelationType.Thread;
          content['m.relates_to'].is_falling_back = false;
        }
      }
      return content;
    }, [editor, isMarkdown, mx, roomId, replyDraft]);

    const handleScheduleClick = useCallback(() => {
      // Pre-fill from editor if there's content; open blank if editor is empty.
      const content = buildCurrentTextContent();
      setScheduleContent(content);
      setScheduleOpen(true);
    }, [buildCurrentTextContent]);

    const handleScheduled = useCallback(
      (delayId: string, sendAt: number, content: IContent) => {
        setScheduledMessages((prev) => {
          const next = new Map(prev);
          const current = next.get(roomId) ?? [];
          next.set(roomId, [...current, { delayId, roomId, content, sendAt }]);
          return next;
        });
        resetEditor(editor);
        resetEditorHistory(editor);
        setMsgDraft([]);
        localStorage.removeItem(`draft-msg-${draftKey}`);
        setReplyDraft(undefined);
        sendTypingStatus(false);
      },
      [
        setScheduledMessages,
        roomId,
        draftKey,
        editor,
        setReplyDraft,
        setMsgDraft,
        sendTypingStatus,
      ],
    );

    const handleKeyDown: KeyboardEventHandler = useCallback(
      (evt) => {
        if (
          (isKeyHotkey('mod+enter', evt) || (!enterForNewline && isKeyHotkey('enter', evt))) &&
          !isComposing(evt)
        ) {
          evt.preventDefault();
          submit();
        }
        if (isKeyHotkey('escape', evt)) {
          // Only consume Escape (and stop it bubbling to the thread panel / room
          // window handlers) when the composer actually has something to dismiss.
          // If we did nothing, let Escape propagate so those handlers can run.
          if (autocompleteQuery) {
            evt.preventDefault();
            evt.stopPropagation();
            setAutocompleteQuery(undefined);
            return;
          }
          if (replyDraft) {
            evt.preventDefault();
            evt.stopPropagation();
            setReplyDraft(undefined);
          }
        }
      },
      [submit, replyDraft, setReplyDraft, enterForNewline, autocompleteQuery, isComposing],
    );

    const handleKeyUp: KeyboardEventHandler = useCallback(
      (evt) => {
        if (isKeyHotkey('escape', evt)) {
          evt.preventDefault();
          return;
        }

        if (!hideActivity) {
          sendTypingStatus(!isEmptyEditor(editor));
        }

        const prevWordRange = getPrevWorldRange(editor);
        const query = prevWordRange
          ? getAutocompleteQuery<AutocompletePrefix>(editor, prevWordRange, AUTOCOMPLETE_PREFIXES)
          : undefined;
        setAutocompleteQuery(query);
      },
      [editor, sendTypingStatus, hideActivity],
    );

    const handleCloseAutocomplete = useCallback(() => {
      setAutocompleteQuery(undefined);
      ReactEditor.focus(editor);
    }, [editor]);

    const handleEmoticonSelect = useCallback(
      (key: string, shortcode: string) => {
        editor.insertNode(createEmoticonElement(key, shortcode));
        moveCursor(editor);
      },
      [editor],
    );

    const handleGifSelect = useCallback(
      async (gifUrl: string, w: number, h: number) => {
        setGifUploading(true);
        try {
          // Only fetch from trusted Giphy CDN domains (match any *.giphy.com subdomain)
          const { hostname } = new URL(gifUrl);
          if (!hostname.endsWith('.giphy.com') && hostname !== 'giphy.com') {
            setGifError('GIF source not trusted.');
            setTimeout(() => setGifError(null), 4000);
            return;
          }

          const res = await fetch(gifUrl);
          if (!res.ok) {
            setGifError('Failed to download GIF from Giphy.');
            setTimeout(() => setGifError(null), 4000);
            return;
          }
          const contentType = res.headers.get('content-type') ?? '';
          if (!contentType.startsWith('image/')) {
            setGifError('Unexpected GIF format. Please try another.');
            setTimeout(() => setGifError(null), 4000);
            return;
          }

          const blob = await res.blob();
          if (blob.size > 20 * 1024 * 1024) {
            setGifError('GIF is too large (max 20 MB).');
            setTimeout(() => setGifError(null), 4000);
            return;
          }

          const uploadRes = await mx.uploadContent(
            new File([blob], 'image.gif', { type: 'image/gif' }),
            { type: 'image/gif', name: 'image.gif', includeFilename: false },
          );
          const mxcUrl = (uploadRes as { content_uri: string }).content_uri;
          if (!mxcUrl) return;
          mx.sendMessage(roomId, threadRootId ?? null, {
            msgtype: MsgType.Image,
            body: 'image.gif',
            url: mxcUrl,
            info: { mimetype: 'image/gif', w, h, size: blob.size },
          });
        } catch (e) {
          console.error('GIF send failed:', e instanceof Error ? e.message : 'unknown error');
          if (!alive()) return;
          setGifError('Failed to send GIF. Please try again.');
          setTimeout(() => setGifError(null), 4000);
        } finally {
          if (alive()) setGifUploading(false);
        }
      },
      [mx, roomId, threadRootId, alive],
    );

    const handleStickerSelect = useCallback(
      async (mxc: string, shortcode: string, label: string) => {
        const stickerUrl = mxcUrlToHttp(mx, mxc, useAuthentication);
        if (!stickerUrl) return;

        const info = await getImageInfo(
          await loadImageElement(stickerUrl),
          await getImageUrlBlob(stickerUrl),
        );

        mx.sendEvent(roomId, threadRootId ?? null, EventType.Sticker, {
          body: label,
          url: mxc,
          info,
        });
      },
      [mx, roomId, threadRootId, useAuthentication],
    );

    if (room.getType() === 'm.server_notice') {
      return (
        <Box
          ref={ref as React.Ref<HTMLDivElement>}
          direction="Column"
          alignItems="Center"
          justifyContent="Center"
          style={{ padding: config.space.S300 }}
        >
          <Text size="T300" priority="300">
            This room contains system messages from your homeserver. Replies are not permitted.
          </Text>
        </Box>
      );
    }

    // Mobile "+" overflow: the `after` builder stashes the collapsed secondary
    // buttons here and the `bottom` slot renders them when the toggle is open.
    // React evaluates JSX props in source order (before → after → bottom), so
    // `after` assigns this before `bottom` reads it within the same render.
    let composerOverflow: ReactNode = null;

    return (
      <div ref={ref}>
        {selectedFiles.length > 0 && (
          <UploadBoard
            header={
              <UploadBoardHeader
                open={uploadBoard}
                onToggle={() => setUploadBoard(!uploadBoard)}
                uploadFamilyObserverAtom={uploadFamilyObserverAtom}
                onSend={handleSendUpload}
                imperativeHandlerRef={uploadBoardHandlers}
                onCancel={handleCancelUpload}
              />
            }
          >
            {uploadBoard && (
              <Scroll size="300" hideTrack visibility="Hover">
                <UploadBoardContent>
                  {Array.from(selectedFiles)
                    .reverse()
                    .map((fileItem, index) => (
                      <UploadCardRenderer
                        key={index}
                        isEncrypted={!!fileItem.encInfo}
                        fileItem={fileItem}
                        setMetadata={handleFileMetadata}
                        onRemove={handleRemoveUpload}
                      />
                    ))}
                </UploadBoardContent>
              </Scroll>
            )}
          </UploadBoard>
        )}
        <Overlay
          open={dropZoneVisible}
          backdrop={<OverlayBackdrop />}
          style={{ pointerEvents: 'none' }}
        >
          <OverlayCenter>
            <Dialog variant="Primary">
              <Box
                direction="Column"
                justifyContent="Center"
                alignItems="Center"
                gap="500"
                style={{ padding: toRem(60) }}
              >
                <Icon size="600" src={Icons.File} />
                <Text size="H4" align="Center">
                  {`Drop Files in "${room?.name || 'Room'}"`}
                </Text>
                <Text align="Center">Drag and drop files here or click for selection dialog</Text>
              </Box>
            </Dialog>
          </OverlayCenter>
        </Overlay>
        {autocompleteQuery?.prefix === AutocompletePrefix.RoomMention && (
          <RoomMentionAutocomplete
            roomId={roomId}
            editor={editor}
            query={autocompleteQuery}
            requestClose={handleCloseAutocomplete}
          />
        )}
        {autocompleteQuery?.prefix === AutocompletePrefix.UserMention && (
          <UserMentionAutocomplete
            room={room}
            editor={editor}
            query={autocompleteQuery}
            requestClose={handleCloseAutocomplete}
          />
        )}
        {autocompleteQuery?.prefix === AutocompletePrefix.Emoticon && (
          <EmoticonAutocomplete
            imagePackRooms={imagePackRooms}
            editor={editor}
            query={autocompleteQuery}
            requestClose={handleCloseAutocomplete}
          />
        )}
        {autocompleteQuery?.prefix === AutocompletePrefix.Command && (
          <CommandAutocomplete
            room={room}
            editor={editor}
            query={autocompleteQuery}
            requestClose={handleCloseAutocomplete}
          />
        )}
        {showUnverifiedWarning && (
          <Box
            alignItems="Center"
            gap="200"
            style={{
              margin: `0 ${config.space.S300} ${config.space.S100}`,
              padding: `${config.space.S100} ${config.space.S200}`,
              borderRadius: config.radii.R300,
              background: color.Warning.Container,
              border: `${config.borderWidth.B300} solid ${color.Warning.ContainerLine}`,
            }}
          >
            <Icon
              size="100"
              src={Icons.Shield}
              style={{ color: color.Warning.OnContainer, flexShrink: 0 }}
            />
            <Text size="T200" style={{ color: color.Warning.OnContainer }}>
              {roomUnverifiedDeviceCount}{' '}
              {roomUnverifiedDeviceCount === 1 ? 'unverified device' : 'unverified devices'} in this
              room
            </Text>
          </Box>
        )}
        <ScheduledMessagesTray roomId={roomId} />
        <CustomEditor
          editableName={editableName}
          editor={editor}
          placeholder="Send a message..."
          onKeyDown={handleKeyDown}
          onKeyUp={handleKeyUp}
          onPaste={handlePaste}
          onChange={(value) => setCharCount(toPlainText(value, isMarkdown).trim().length)}
          top={
            replyDraft && (
              <div>
                <Box
                  alignItems="Center"
                  gap="300"
                  style={{ padding: `${config.space.S200} ${config.space.S300} 0` }}
                >
                  <IconButton
                    onClick={() => setReplyDraft(undefined)}
                    aria-label="Dismiss reply"
                    variant="SurfaceVariant"
                    size="300"
                    radii="300"
                  >
                    <Icon src={Icons.Cross} size="50" />
                  </IconButton>
                  <Box direction="Row" gap="200" alignItems="Center">
                    {replyDraft.relation?.rel_type === RelationType.Thread && <ThreadIndicator />}
                    <ReplyLayout
                      userColor={replyUsernameColor}
                      username={
                        <Text size="T300" truncate>
                          <b>{getMemberName(room, replyDraft.userId)}</b>
                        </Text>
                      }
                    >
                      <Text size="T300" truncate>
                        {trimReplyFromBody(replyDraft.body)}
                      </Text>
                    </ReplyLayout>
                  </Box>
                </Box>
              </div>
            )
          }
          before={
            isMobile ? (
              <IconButton
                onClick={() => setMobileToolsOpen((open) => !open)}
                aria-label="More actions"
                aria-expanded={mobileToolsOpen}
                aria-controls="composer-more-actions"
                variant="SurfaceVariant"
                size="300"
                radii="300"
                style={touchTarget}
              >
                <Icon src={mobileToolsOpen ? Icons.Cross : Icons.Plus} />
              </IconButton>
            ) : (
              <IconButton
                onClick={() => pickFile('*')}
                aria-label="Attach file"
                variant="SurfaceVariant"
                size="300"
                radii="300"
                style={touchTarget}
              >
                <Icon src={Icons.PlusCircle} />
              </IconButton>
            )
          }
          after={(() => {
            const formatButton = showFormat ? (
              <IconButton
                key="showFormat"
                variant="SurfaceVariant"
                size="300"
                radii="300"
                style={touchTarget}
                aria-label={toolbar ? 'Hide formatting toolbar' : 'Show formatting toolbar'}
                aria-pressed={toolbar}
                onClick={() => setToolbar(!toolbar)}
              >
                <Icon src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} />
              </IconButton>
            ) : null;

            // Emoji and Sticker share a single EmojiBoard PopOut anchored to the
            // emoji button, so they are rendered together as one unit. Their
            // relative order still follows the saved order.
            const emojiStickerBlock =
              showEmoji || showSticker ? (
                <UseStateProvider key="showEmojiSticker" initial={undefined}>
                  {(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => {
                    const stickerBtn =
                      showSticker && !hideStickerBtn ? (
                        <IconButton
                          key="showSticker"
                          aria-pressed={emojiBoardTab === EmojiBoardTab.Sticker}
                          aria-label="Insert sticker"
                          onClick={() => setEmojiBoardTab(EmojiBoardTab.Sticker)}
                          variant="SurfaceVariant"
                          size="300"
                          radii="300"
                          style={touchTarget}
                        >
                          <Icon
                            src={Icons.Sticker}
                            filled={emojiBoardTab === EmojiBoardTab.Sticker}
                          />
                        </IconButton>
                      ) : null;
                    const emojiBtn = showEmoji ? (
                      <IconButton
                        key="showEmoji"
                        ref={emojiBtnRef}
                        aria-label="Insert emoji"
                        aria-pressed={
                          hideStickerBtn ? !!emojiBoardTab : emojiBoardTab === EmojiBoardTab.Emoji
                        }
                        onClick={() => setEmojiBoardTab(EmojiBoardTab.Emoji)}
                        variant="SurfaceVariant"
                        size="300"
                        radii="300"
                        style={touchTarget}
                      >
                        <Icon
                          src={Icons.Smile}
                          filled={
                            hideStickerBtn ? !!emojiBoardTab : emojiBoardTab === EmojiBoardTab.Emoji
                          }
                        />
                      </IconButton>
                    ) : null;
                    const emojiFirst =
                      composerButtonOrder.indexOf('showEmoji') <
                      composerButtonOrder.indexOf('showSticker');
                    return (
                      <PopOut
                        offset={16}
                        alignOffset={-44}
                        position="Top"
                        align="End"
                        anchor={
                          emojiBoardTab === undefined
                            ? undefined
                            : (emojiBtnRef.current?.getBoundingClientRect() ?? undefined)
                        }
                        content={
                          <React.Suspense fallback={null}>
                            <EmojiBoard
                              tab={emojiBoardTab}
                              onTabChange={setEmojiBoardTab}
                              imagePackRooms={imagePackRooms}
                              returnFocusOnDeactivate={false}
                              onEmojiSelect={handleEmoticonSelect}
                              onCustomEmojiSelect={handleEmoticonSelect}
                              onStickerSelect={handleStickerSelect}
                              requestClose={() => {
                                setEmojiBoardTab((t) => {
                                  if (t) {
                                    if (!mobileOrTablet()) ReactEditor.focus(editor);
                                    return undefined;
                                  }
                                  return t;
                                });
                              }}
                            />
                          </React.Suspense>
                        }
                      >
                        {emojiFirst ? [emojiBtn, stickerBtn] : [stickerBtn, emojiBtn]}
                      </PopOut>
                    );
                  }}
                </UseStateProvider>
              ) : null;

            const gifButton =
              !!gifApiKey && showGif ? (
                <UseStateProvider key="showGif" initial={false}>
                  {(gifOpen: boolean, setGifOpen) => (
                    <PopOut
                      offset={16}
                      alignOffset={-44}
                      position="Top"
                      align="End"
                      anchor={
                        gifOpen
                          ? (gifBtnRef.current?.getBoundingClientRect() ?? undefined)
                          : undefined
                      }
                      content={
                        <React.Suspense fallback={null}>
                          <GifPicker
                            apiKey={gifApiKey}
                            onSelect={handleGifSelect}
                            requestClose={() => setGifOpen(false)}
                          />
                        </React.Suspense>
                      }
                    >
                      <IconButton
                        ref={gifBtnRef}
                        aria-label="Insert GIF"
                        aria-pressed={gifOpen}
                        onClick={() => !gifUploading && setGifOpen(!gifOpen)}
                        variant="SurfaceVariant"
                        size="300"
                        radii="300"
                        disabled={gifUploading}
                        style={touchTarget}
                      >
                        {gifUploading ? (
                          <Spinner variant="Secondary" size="100" />
                        ) : (
                          <Text
                            size="T200"
                            style={{
                              fontWeight: 800,
                              fontSize: '11px',
                              letterSpacing: '0.04em',
                              lineHeight: 1,
                            }}
                          >
                            GIF
                          </Text>
                        )}
                      </IconButton>
                    </PopOut>
                  )}
                </UseStateProvider>
              ) : null;

            const locationButton = showLocation ? (
              <IconButton
                key="showLocation"
                onClick={handleShareLocation}
                disabled={locating}
                aria-label="Share location"
                variant="SurfaceVariant"
                size="300"
                radii="300"
                title="Share location"
                style={touchTarget}
              >
                {locating ? (
                  <Spinner variant="Secondary" size="100" />
                ) : (
                  <Icon src={Icons.SpaceGlobe} size="100" />
                )}
              </IconButton>
            ) : null;

            const pollButton = showPoll ? (
              <IconButton
                key="showPoll"
                onClick={() => setPollOpen(true)}
                aria-label="Create poll"
                variant="SurfaceVariant"
                size="300"
                radii="300"
                title="Create poll"
                style={touchTarget}
              >
                <Icon src={Icons.OrderList} size="100" />
              </IconButton>
            ) : null;

            const voiceButton = showVoice ? (
              <VoiceMessageRecorder
                key="showVoice"
                onSend={handleVoiceSend}
                onError={(err) => {
                  setLocationError(err);
                  setTimeout(() => setLocationError(null), 4000);
                }}
              />
            ) : null;

            const scheduleButton = showSchedule ? (
              <IconButton
                key="showSchedule"
                onClick={handleScheduleClick}
                variant="SurfaceVariant"
                size="300"
                radii="300"
                style={touchTarget}
                aria-label="Schedule message"
                title="Schedule message"
              >
                <Icon src={Icons.Clock} size="100" />
              </IconButton>
            ) : null;

            const orderedButtons: ReactNode[] = [];
            let emojiStickerRendered = false;
            composerButtonOrder.forEach((key: ComposerToolbarButtonKey) => {
              switch (key) {
                case 'showFormat':
                  if (formatButton) orderedButtons.push(formatButton);
                  break;
                case 'showEmoji':
                case 'showSticker':
                  // Rendered once as a combined unit at whichever of the two
                  // keys comes first in the order.
                  if (!emojiStickerRendered) {
                    emojiStickerRendered = true;
                    if (emojiStickerBlock) orderedButtons.push(emojiStickerBlock);
                  }
                  break;
                case 'showGif':
                  if (gifButton) orderedButtons.push(gifButton);
                  break;
                case 'showLocation':
                  if (locationButton) orderedButtons.push(locationButton);
                  break;
                case 'showPoll':
                  if (pollButton) orderedButtons.push(pollButton);
                  break;
                case 'showVoice':
                  if (voiceButton) orderedButtons.push(voiceButton);
                  break;
                case 'showSchedule':
                  if (scheduleButton) orderedButtons.push(scheduleButton);
                  break;
                default:
                  break;
              }
            });

            // Mobile: keep only emoji/sticker inline beside Send; the rest move
            // into the "+" overflow row (rendered via `bottom`), led by the attach
            // button that `before` gives up on mobile. Desktop renders all inline.
            const emojiInline = orderedButtons.filter(
              (node) => React.isValidElement(node) && node.key === 'showEmojiSticker',
            );
            const overflowButtons = orderedButtons.filter(
              (node) => !(React.isValidElement(node) && node.key === 'showEmojiSticker'),
            );
            if (isMobile) {
              composerOverflow = (
                <>
                  <IconButton
                    key="showAttach"
                    onClick={() => pickFile('*')}
                    aria-label="Attach file"
                    variant="SurfaceVariant"
                    size="300"
                    radii="300"
                    style={touchTarget}
                  >
                    <Icon src={Icons.PlusCircle} />
                  </IconButton>
                  {overflowButtons}
                </>
              );
            }

            return (
              <>
                {isMobile ? emojiInline : orderedButtons}
                {gifError && (
                  <Text
                    size="T200"
                    style={{
                      color: color.Critical.Main,
                      padding: '2px 6px',
                      alignSelf: 'center',
                      whiteSpace: 'nowrap',
                    }}
                  >
                    {gifError}
                  </Text>
                )}
                {locationError && (
                  <Text
                    size="T200"
                    style={{
                      color: color.Critical.Main,
                      padding: '2px 6px',
                      alignSelf: 'center',
                      whiteSpace: 'nowrap',
                    }}
                  >
                    {locationError}
                  </Text>
                )}
                <DraftIndicator roomId={draftKey} />
                {charCount > 0 && (
                  <Text
                    size="T200"
                    priority="300"
                    style={{
                      padding: `0 ${config.space.S100}`,
                      alignSelf: 'center',
                      userSelect: 'none',
                      minWidth: '2rem',
                      textAlign: 'right',
                    }}
                  >
                    {charCount}
                  </Text>
                )}
                <IconButton
                  onClick={submit}
                  variant="SurfaceVariant"
                  size="300"
                  radii="300"
                  style={touchTarget}
                  aria-label="Send message"
                >
                  <Icon src={Icons.Send} />
                </IconButton>
              </>
            );
          })()}
          bottom={
            <>
              {isMobile && mobileToolsOpen && composerOverflow && (
                <div>
                  <Line variant="SurfaceVariant" size="300" />
                  <Box
                    id="composer-more-actions"
                    role="group"
                    aria-label="More actions"
                    alignItems="Center"
                    gap="100"
                    wrap="Wrap"
                    style={{ padding: config.space.S200 }}
                  >
                    {composerOverflow}
                  </Box>
                </div>
              )}
              {toolbar && (
                <div>
                  <Line variant="SurfaceVariant" size="300" />
                  <Toolbar />
                </div>
              )}
            </>
          }
        />
        {pollOpen && <PollCreator room={room} roomId={roomId} onClose={() => setPollOpen(false)} />}
        {scheduleOpen && (
          <ScheduleMessageModal
            roomId={roomId}
            initialBody={
              typeof scheduleContent?.body === 'string' ? scheduleContent.body : undefined
            }
            onScheduled={handleScheduled}
            onClose={() => {
              setScheduleOpen(false);
              setScheduleContent(null);
            }}
          />
        )}
      </div>
    );
  },
);
