import React, {
  Dispatch,
  MouseEventHandler,
  ReactNode,
  SetStateAction,
  useCallback,
  useEffect,
  useLayoutEffect,
  useMemo,
  useRef,
  useState,
} from 'react';
import {
  Direction,
  EventStatus,
  EventTimeline,
  EventTimelineSet,
  EventTimelineSetHandlerMap,
  MatrixClient,
  MatrixEvent,
  RelationType,
  Room,
  RoomEvent,
  Thread,
  ThreadEvent,
} from 'matrix-js-sdk';
import { HTMLReactParserOptions } from 'html-react-parser';
import { Editor } from 'slate';
import { ReactEditor } from 'slate-react';
import to from 'await-to-js';
import { useAtomValue, useSetAtom } from 'jotai';
import { Badge, Box, Chip, Icon, Icons, Line, Scroll, Spinner, Text, color, config } from 'folds';
import classNames from 'classnames';
import { Opts as LinkifyOpts } from 'linkifyjs';
import { isKeyHotkey } from 'is-hotkey';
import { eventWithShortcode, factoryEventSentBy } from '../../../utils/matrix';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useVirtualPaginator, ItemRange } from '../../../hooks/useVirtualPaginator';
import { useAlive } from '../../../hooks/useAlive';
import { editableActiveElement, scrollToBottom } from '../../../utils/dom';
import {
  DefaultPlaceholder,
  MessageBase,
  Reply,
  RedactedContent,
  MSticker,
  MessageUnsupportedContent,
  MessageNotDecryptedContent,
  ImageContent,
} from '../../../components/message';
import {
  factoryRenderLinkifyWithMention,
  getReactCustomHtmlParser,
  LINKIFY_OPTS,
  makeMentionCustomProps,
  renderMatrixMention,
} from '../../../plugins/react-custom-html-parser';
import {
  canEditEvent,
  decryptAllTimelineEvent,
  getEditedEvent,
  getEventReactions,
  getLatestEditableEvt,
  getMemberName,
  getReactionContent,
  reactionOrEditEvent,
} from '../../../utils/room';
import { useSetting } from '../../../state/hooks/settings';
import { MessageLayout, settingsAtom } from '../../../state/settings';
import { Message, Reactions, EncryptedContent } from '../message';
import { RenderMessageContent } from '../../../components/RenderMessageContent';
import { Image } from '../../../components/media';
import { ImageViewer } from '../../../components/image-viewer';
import * as css from './ThreadTimeline.css';
import {
  inSameDay,
  minuteDifference,
  timeDayMonthYear,
  today,
  yesterday,
} from '../../../utils/time';
import { createMentionElement, isEmptyEditor, moveCursor } from '../../../components/editor';
import { useKeyDown } from '../../../hooks/useKeyDown';
import { roomIdToReplyDraftAtomFamily } from '../../../state/room/roomInputDrafts';
import { usePowerLevelsContext } from '../../../hooks/usePowerLevels';
import { GetContentCallback, MessageEvent, StateEvent } from '../../../../types/matrix/room';
import {
  getIntersectionObserverEntry,
  useIntersectionObserver,
} from '../../../hooks/useIntersectionObserver';
import { useMentionClickHandler } from '../../../hooks/useMentionClickHandler';
import { useSpoilerClickHandler } from '../../../hooks/useSpoilerClickHandler';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { useIgnoredUsers } from '../../../hooks/useIgnoredUsers';
import { useImagePackRooms } from '../../../hooks/useImagePackRooms';
import { useIsDirectRoom } from '../../../hooks/useRoom';
import { useOpenUserRoomProfile } from '../../../state/hooks/userRoomProfile';
import { useSpaceOptionally } from '../../../hooks/useSpace';
import { useRoomCreators } from '../../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
import {
  useAccessiblePowerTagColors,
  useGetMemberPowerTag,
} from '../../../hooks/useMemberPowerTag';
import { useTheme } from '../../../hooks/useTheme';
import { useRoomCreatorsTag } from '../../../hooks/useRoomCreatorsTag';
import { usePowerLevelTags } from '../../../hooks/usePowerLevelTags';
import { roomToParentsAtom } from '../../../state/room/roomToParents';
import { EditHistoryModal } from '../message/EditHistoryModal';
import {
  getLinkedTimelines,
  getTimelineAndBaseIndex,
  getTimelineEvent,
  getTimelineRelativeIndex,
  getTimelinesEventsCount,
  timelineToEventsCount,
} from '../RoomTimeline';
import { getThreadDraftKey } from '../../../state/room/thread';
import { useThreadLinkedTimelines, useThreadPendingEvents } from './useThread';

// Virtual window size (how many items render around the viewport).
const PAGINATION_LIMIT = 50;
// Network page size for backward /relations pagination of the thread timeline.
const THREAD_PAGE_LIMIT = 30;

type Timeline = {
  linkedTimelines: EventTimeline[];
  range: ItemRange;
};

const getEmptyTimeline = (): Timeline => ({
  linkedTimelines: [],
  range: { start: 0, end: 0 },
});

const getInitialThreadTimeline = (thread: Thread, timelines?: EventTimeline[]): Timeline => {
  const linkedTimelines =
    timelines && timelines.length > 0 ? timelines : getLinkedTimelines(thread.liveTimeline);
  const evLength = getTimelinesEventsCount(linkedTimelines);
  return {
    linkedTimelines,
    range: {
      start: Math.max(evLength - PAGINATION_LIMIT, 0),
      end: evLength,
    },
  };
};

/**
 * Copy of RoomTimeline's `useTimelinePagination` pattern (not exported from RoomTimeline
 * as its ~35 hooks are hardwired to the room live timeline). Works transparently against
 * the thread timeline's /relations pagination.
 */
const useThreadTimelinePagination = (
  mx: MatrixClient,
  timeline: Timeline,
  setTimeline: Dispatch<SetStateAction<Timeline>>,
  limit: number,
) => {
  const timelineRef = useRef(timeline);
  timelineRef.current = timeline;
  const alive = useAlive();

  const handleTimelinePagination = useMemo(() => {
    let fetching = false;

    const recalibratePagination = (
      linkedTimelines: EventTimeline[],
      timelinesEventsCount: number[],
      backwards: boolean,
    ) => {
      const topTimeline = linkedTimelines[0];
      const timelineMatch = (mt: EventTimeline) => (t: EventTimeline) => t === mt;

      const newLTimelines = getLinkedTimelines(topTimeline);
      const topTmIndex = newLTimelines.findIndex(timelineMatch(topTimeline));
      const topAddedTm = topTmIndex === -1 ? [] : newLTimelines.slice(0, topTmIndex);

      const topTmAddedEvt =
        timelineToEventsCount(newLTimelines[topTmIndex]) - timelinesEventsCount[0];
      const offsetRange = getTimelinesEventsCount(topAddedTm) + (backwards ? topTmAddedEvt : 0);

      setTimeline((currentTimeline) => ({
        linkedTimelines: newLTimelines,
        range:
          offsetRange > 0
            ? {
                start: currentTimeline.range.start + offsetRange,
                end: currentTimeline.range.end + offsetRange,
              }
            : { ...currentTimeline.range },
      }));
    };

    return async (backwards: boolean) => {
      if (fetching) return;
      const { linkedTimelines: lTimelines } = timelineRef.current;
      const timelinesEventsCount = lTimelines.map(timelineToEventsCount);

      const timelineToPaginate = backwards ? lTimelines[0] : lTimelines[lTimelines.length - 1];
      if (!timelineToPaginate) return;

      const paginationToken = timelineToPaginate.getPaginationToken(
        backwards ? Direction.Backward : Direction.Forward,
      );
      if (
        !paginationToken &&
        getTimelinesEventsCount(lTimelines) !==
          getTimelinesEventsCount(getLinkedTimelines(timelineToPaginate))
      ) {
        recalibratePagination(lTimelines, timelinesEventsCount, backwards);
        return;
      }

      fetching = true;
      const [err] = await to(
        mx.paginateEventTimeline(timelineToPaginate, {
          backwards,
          limit,
        }),
      );
      if (err) {
        fetching = false;
        return;
      }
      const fetchedTimeline =
        timelineToPaginate.getNeighbouringTimeline(
          backwards ? Direction.Backward : Direction.Forward,
        ) ?? timelineToPaginate;
      // Decrypt all event ahead of render cycle
      const roomId = fetchedTimeline.getRoomId();
      const room = roomId ? mx.getRoom(roomId) : null;

      if (room?.hasEncryptionStateEvent()) {
        await to(decryptAllTimelineEvent(mx, fetchedTimeline));
      }

      fetching = false;
      if (alive()) {
        recalibratePagination(lTimelines, timelinesEventsCount, backwards);
      }
    };
  }, [mx, alive, setTimeline, limit]);
  return handleTimelinePagination;
};

export type ThreadTimelineProps = {
  room: Room;
  thread: Thread;
  editor: Editor;
};

export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
  const mx = useMatrixClient();
  const alive = useAlive();
  const useAuthentication = useMediaAuthentication();

  const [messageLayout] = useSetting(settingsAtom, 'messageLayout');
  const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing');
  const [perMessageProfiles] = useSetting(settingsAtom, 'perMessageProfiles');
  const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
  const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
  const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools');
  const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
  const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
  const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
  const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
  const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview;
  const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
  const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');

  const direct = useIsDirectRoom();
  const ignoredUsersList = useIgnoredUsers();
  const ignoredUsersSet = useMemo(() => new Set(ignoredUsersList), [ignoredUsersList]);

  const setReplyDraft = useSetAtom(
    roomIdToReplyDraftAtomFamily(getThreadDraftKey(room.roomId, thread.id)),
  );

  const powerLevels = usePowerLevelsContext();
  const creators = useRoomCreators(room);
  const creatorsTag = useRoomCreatorsTag();
  const powerLevelTags = usePowerLevelTags(room, powerLevels);
  const getMemberPowerTag = useGetMemberPowerTag(room, creators, powerLevels);

  const theme = useTheme();
  const accessiblePowerTagColors = useAccessiblePowerTagColors(
    theme.kind,
    creatorsTag,
    powerLevelTags,
  );

  const permissions = useRoomPermissions(creators, powerLevels);
  const canRedact = permissions.action('redact', mx.getSafeUserId());
  const canDeleteOwn = permissions.event(MessageEvent.RoomRedaction, mx.getSafeUserId());
  const canSendReaction = permissions.event(MessageEvent.Reaction, mx.getSafeUserId());
  const canPinEvent = permissions.stateEvent(StateEvent.RoomPinnedEvents, mx.getSafeUserId());

  const mentionClickHandler = useMentionClickHandler(room.roomId);
  const spoilerClickHandler = useSpoilerClickHandler();
  const openUserRoomProfile = useOpenUserRoomProfile();
  const space = useSpaceOptionally();
  const roomToParents = useAtomValue(roomToParentsAtom);
  const imagePackRooms: Room[] = useImagePackRooms(room.roomId, roomToParents);

  const [editId, setEditId] = useState<string>();
  const [editHistoryEvent, setEditHistoryEvent] = useState<MatrixEvent | undefined>();

  const linkifyOpts = useMemo<LinkifyOpts>(
    () => ({
      ...LINKIFY_OPTS,
      render: factoryRenderLinkifyWithMention((href) =>
        renderMatrixMention(mx, room.roomId, href, makeMentionCustomProps(mentionClickHandler)),
      ),
    }),
    [mx, room, mentionClickHandler],
  );
  const htmlReactParserOptions = useMemo<HTMLReactParserOptions>(
    () =>
      getReactCustomHtmlParser(mx, room.roomId, {
        linkifyOpts,
        useAuthentication,
        handleSpoilerClick: spoilerClickHandler,
        handleMentionClick: mentionClickHandler,
      }),
    [mx, room, linkifyOpts, spoilerClickHandler, mentionClickHandler, useAuthentication],
  );

  const { timelines, ready } = useThreadLinkedTimelines(mx, thread);
  const pendingEvents = useThreadPendingEvents(room, thread.id, thread);

  const [timeline, setTimeline] = useState<Timeline>(() =>
    ready ? getInitialThreadTimeline(thread, timelines) : getEmptyTimeline(),
  );
  const eventsLength = getTimelinesEventsCount(timeline.linkedTimelines);

  const canPaginateBack =
    typeof timeline.linkedTimelines[0]?.getPaginationToken(Direction.Backward) === 'string';
  const rangeAtStart = timeline.range.start === 0;

  const scrollRef = useRef<HTMLDivElement>(null);
  const atBottomAnchorRef = useRef<HTMLElement>(null);
  const [atBottom, setAtBottom] = useState(true);
  const atBottomRef = useRef(atBottom);
  atBottomRef.current = atBottom;
  const scrollToBottomRef = useRef({ count: 0, smooth: true });

  const handleTimelinePagination = useThreadTimelinePagination(
    mx,
    timeline,
    setTimeline,
    THREAD_PAGE_LIMIT,
  );

  const getScrollElement = useCallback(() => scrollRef.current, []);

  const { getItems, scrollToItem, observeBackAnchor } = useVirtualPaginator({
    count: eventsLength,
    limit: PAGINATION_LIMIT,
    range: timeline.range,
    onRangeChange: useCallback((r) => setTimeline((cs) => ({ ...cs, range: r })), []),
    getScrollElement,
    getItemElement: useCallback(
      (index: number) =>
        (scrollRef.current?.querySelector(`[data-message-item="${index}"]`) as HTMLElement) ??
        undefined,
      [],
    ),
    onEnd: handleTimelinePagination,
  });

  // Seed local timeline once the thread has fetched its initial events.
  const seededRef = useRef(false);
  useEffect(() => {
    if (!ready || seededRef.current) return;
    seededRef.current = true;
    setTimeline(getInitialThreadTimeline(thread, timelines));
    scrollToBottomRef.current.count += 1;
    scrollToBottomRef.current.smooth = false;
    if (room.hasEncryptionStateEvent()) {
      to(decryptAllTimelineEvent(mx, thread.liveTimeline)).then(() => {
        if (alive()) setTimeline((ct) => ({ ...ct }));
      });
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps -- seed once when ready flips
  }, [ready, thread]);

  // Re-render / stick-to-bottom on live thread activity.
  useEffect(() => {
    const handleTimeline: EventTimelineSetHandlerMap[RoomEvent.Timeline] = (
      mEvent,
      eventRoom,
      toStartOfTimeline,
      removed,
      data,
    ) => {
      if (!data?.liveEvent) return;
      if (atBottomRef.current) {
        scrollToBottomRef.current.count += 1;
        scrollToBottomRef.current.smooth = true;
        setTimeline((ct) => ({
          ...ct,
          range: {
            start: ct.range.start + 1,
            end: ct.range.end + 1,
          },
        }));
        return;
      }
      setTimeline((ct) => ({ ...ct }));
    };
    const handleUpdate = () => setTimeline((ct) => ({ ...ct }));
    // A gappy sync / updateThreadMetadata resets the thread's live timeline —
    // the stored linkedTimelines would then point at a detached timeline, so
    // reseed the window from the fresh liveTimeline.
    const handleReset = () => {
      setTimeline(getInitialThreadTimeline(thread, getLinkedTimelines(thread.liveTimeline)));
      scrollToBottomRef.current.count += 1;
      scrollToBottomRef.current.smooth = false;
    };

    thread.on(RoomEvent.Timeline, handleTimeline);
    thread.on(ThreadEvent.Update, handleUpdate);
    thread.on(RoomEvent.TimelineReset, handleReset);
    return () => {
      thread.removeListener(RoomEvent.Timeline, handleTimeline);
      thread.removeListener(ThreadEvent.Update, handleUpdate);
      thread.removeListener(RoomEvent.TimelineReset, handleReset);
    };
  }, [thread]);

  // atBottom detection
  useIntersectionObserver(
    useCallback((entries) => {
      const target = atBottomAnchorRef.current;
      if (!target) return;
      const entry = getIntersectionObserverEntry(target, entries);
      if (entry) setAtBottom(entry.isIntersecting);
    }, []),
    useCallback(
      () => ({
        root: getScrollElement(),
        rootMargin: '100px',
      }),
      [getScrollElement],
    ),
    useCallback(() => atBottomAnchorRef.current, []),
  );

  // Initial scroll to bottom on mount.
  useLayoutEffect(() => {
    const scrollEl = scrollRef.current;
    if (scrollEl) scrollToBottom(scrollEl);
  }, []);

  // Scroll to bottom when requested.
  const scrollToBottomCount = scrollToBottomRef.current.count;
  useLayoutEffect(() => {
    if (scrollToBottomCount > 0) {
      const scrollEl = scrollRef.current;
      if (scrollEl)
        scrollToBottom(scrollEl, scrollToBottomRef.current.smooth ? 'smooth' : 'instant');
    }
  }, [scrollToBottomCount]);

  const handleJumpToBottom = useCallback(() => {
    // Re-anchor the virtual window at the thread tail first. While scrolled up,
    // live replies deliberately don't extend the window, so without this the chip
    // would scroll to the bottom of the STALE window (a mid/old event) instead of
    // the newest reply. Mirrors the main timeline's handleJumpToLatest.
    setTimeline(getInitialThreadTimeline(thread, getLinkedTimelines(thread.liveTimeline)));
    scrollToBottomRef.current.count += 1;
    scrollToBottomRef.current.smooth = true;
    // Flip atBottom so the layout effect re-runs (count re-read) and live
    // events resume sticking to the bottom.
    setAtBottom(true);
  }, [thread]);

  // Scroll in-place editor into view.
  useEffect(() => {
    if (editId) {
      const editMsgElement =
        (scrollRef.current?.querySelector(`[data-message-id="${editId}"]`) as HTMLElement) ??
        undefined;
      editMsgElement?.scrollIntoView({ block: 'center', behavior: 'smooth' });
    }
  }, [editId]);

  const handleUserClick: MouseEventHandler<HTMLButtonElement> = useCallback(
    (evt) => {
      evt.preventDefault();
      evt.stopPropagation();
      const userId = evt.currentTarget.getAttribute('data-user-id');
      if (!userId) return;
      openUserRoomProfile(
        room.roomId,
        space?.roomId,
        userId,
        evt.currentTarget.getBoundingClientRect(),
      );
    },
    [room, space, openUserRoomProfile],
  );

  const handleUsernameClick: MouseEventHandler<HTMLButtonElement> = useCallback(
    (evt) => {
      evt.preventDefault();
      const userId = evt.currentTarget.getAttribute('data-user-id');
      if (!userId) return;
      const name = getMemberName(room, userId);
      editor.insertNode(
        createMentionElement(
          userId,
          name.startsWith('@') ? name : `@${name}`,
          userId === mx.getUserId(),
        ),
      );
      ReactEditor.focus(editor);
      moveCursor(editor);
    },
    [mx, room, editor],
  );

  const handleReplyClick: MouseEventHandler<HTMLButtonElement> = useCallback(
    (evt) => {
      const replyId = evt.currentTarget.getAttribute('data-event-id');
      if (!replyId) return;
      const replyEvt = thread.findEventById(replyId) ?? room.findEventById(replyId);
      if (!replyEvt) return;
      const editedReply = getEditedEvent(replyId, replyEvt, thread.getUnfilteredTimelineSet());
      const content = editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent();
      const { body, formatted_body: formattedBody } = content;
      const senderId = replyEvt.getSender();
      if (senderId && typeof body === 'string') {
        setReplyDraft({
          userId: senderId,
          eventId: replyId,
          body,
          formattedBody,
          relation: { rel_type: RelationType.Thread, event_id: thread.id },
        });
        setTimeout(() => ReactEditor.focus(editor), 100);
      }
    },
    [room, thread, setReplyDraft, editor],
  );

  const handleReactionToggle = useCallback(
    (targetEventId: string, key: string, shortcode?: string) => {
      const timelineSet = thread.getUnfilteredTimelineSet();
      const relations = getEventReactions(timelineSet, targetEventId);
      const allReactions = relations?.getSortedAnnotationsByKey() ?? [];
      const [, reactionsSet] = allReactions.find(([k]) => k === key) ?? [];
      const reactions = reactionsSet ? Array.from(reactionsSet) : [];
      const myReaction = reactions.find(factoryEventSentBy(mx.getUserId()!));

      if (myReaction && !!myReaction.isRelation()) {
        mx.redactEvent(room.roomId, myReaction.getId()!);
        return;
      }
      const rShortcode =
        shortcode ||
        (reactions.find(eventWithShortcode)?.getContent().shortcode as string | undefined);
      mx.sendEvent(
        room.roomId,
        thread.id,
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        MessageEvent.Reaction as any,
        getReactionContent(targetEventId, key, rShortcode),
      );
    },
    [mx, room, thread],
  );

  const handleEdit = useCallback(
    (editEvtId?: string) => {
      if (editEvtId) {
        setEditId(editEvtId);
        return;
      }
      setEditId(undefined);
      ReactEditor.focus(editor);
    },
    [editor],
  );

  // Up-arrow in the (empty) thread composer edits your last editable reply in
  // this thread — parity with the main timeline. Gated on the thread composer's
  // distinct editableName so it and the main handler never cross-fire.
  useKeyDown(
    window,
    useCallback(
      (evt) => {
        if (
          isKeyHotkey('arrowup', evt) &&
          editableActiveElement() &&
          document.activeElement?.getAttribute('data-editable-name') === 'ThreadInput' &&
          isEmptyEditor(editor)
        ) {
          const editableEvt = getLatestEditableEvt(thread.liveTimeline, (mEvt) =>
            canEditEvent(mx, mEvt),
          );
          const editableEvtId = editableEvt?.getId();
          if (!editableEvtId) return;
          setEditId(editableEvtId);
          evt.preventDefault();
        }
      },
      [mx, thread, editor],
    ),
  );

  const handleOpenReply: MouseEventHandler = useCallback(
    (evt) => {
      const targetId = evt.currentTarget.getAttribute('data-event-id');
      if (!targetId) return;
      // best-effort: scroll to referenced event if it is inside the loaded thread window
      let absIndex = -1;
      let acc = 0;
      timeline.linkedTimelines.some((tl) => {
        const idx = tl.getEvents().findIndex((e) => e.getId() === targetId);
        if (idx !== -1) {
          absIndex = acc + idx;
          return true;
        }
        acc += tl.getEvents().length;
        return false;
      });
      if (absIndex >= 0) {
        scrollToItem(absIndex, {
          behavior: 'smooth',
          align: 'center',
          stopInView: true,
        });
      }
    },
    [timeline.linkedTimelines, scrollToItem],
  );

  const renderMessageContent = useCallback(
    (mEvent: MatrixEvent, mEventId: string, timelineSet: EventTimelineSet): ReactNode => {
      // Evaluated lazily so EncryptedContent can re-run it (re-reading getType())
      // after MatrixEventEvent.Decrypted fires — decryption re-emits NEITHER
      // RoomEvent.Timeline nor ThreadEvent.Update, so without this wrapper a
      // live-arriving encrypted reply would show "Unable to decrypt" forever.
      const renderByType = (): ReactNode => {
        if (mEvent.isRedacted()) {
          return <RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />;
        }
        const type = mEvent.getType();
        if (type === MessageEvent.Sticker) {
          return (
            <MSticker
              content={mEvent.getContent()}
              renderImageContent={(props) => (
                <ImageContent
                  {...props}
                  autoPlay={mediaAutoLoad}
                  renderImage={(p) => <Image {...p} loading="lazy" />}
                  renderViewer={(p) => <ImageViewer {...p} />}
                />
              )}
            />
          );
        }
        if (type === MessageEvent.RoomMessageEncrypted) {
          return (
            <Text>
              <MessageNotDecryptedContent />
            </Text>
          );
        }
        if (type !== MessageEvent.RoomMessage) {
          return (
            <Text>
              <MessageUnsupportedContent />
            </Text>
          );
        }
        const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet);
        const getContent = (() =>
          editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent()) as GetContentCallback;
        const senderId = mEvent.getSender() ?? '';
        const senderDisplayName = getMemberName(room, senderId);
        return (
          <RenderMessageContent
            displayName={senderDisplayName}
            msgType={mEvent.getContent().msgtype ?? ''}
            ts={mEvent.getTs()}
            edited={!!editedEvent}
            onEditHistoryClick={editedEvent ? () => setEditHistoryEvent(mEvent) : undefined}
            getContent={getContent}
            mediaAutoLoad={mediaAutoLoad}
            urlPreview={showUrlPreview}
            htmlReactParserOptions={htmlReactParserOptions}
            linkifyOpts={linkifyOpts}
            outlineAttachment={messageLayout === MessageLayout.Bubble}
            eventId={mEventId}
          />
        );
      };

      if (mEvent.getType() === MessageEvent.RoomMessageEncrypted) {
        return <EncryptedContent mEvent={mEvent}>{renderByType}</EncryptedContent>;
      }
      return renderByType();
    },
    [room, mediaAutoLoad, showUrlPreview, htmlReactParserOptions, linkifyOpts, messageLayout],
  );

  const renderMessage = useCallback(
    (
      mEvent: MatrixEvent,
      opts: { item?: number; collapse: boolean; highlight: boolean; editable: boolean },
    ): ReactNode => {
      const mEventId = mEvent.getId();
      if (!mEventId) return null;
      const timelineSet = thread.getUnfilteredTimelineSet();
      const reactionRelations = getEventReactions(timelineSet, mEventId);
      const reactions = reactionRelations?.getSortedAnnotationsByKey();
      const hasReactions = !!reactions && reactions.length > 0;
      const { replyEventId, threadRootId } = mEvent;

      return (
        <Message
          key={mEventId}
          data-message-item={opts.item}
          data-message-id={mEventId}
          room={room}
          mEvent={mEvent}
          messageSpacing={messageSpacing}
          messageLayout={messageLayout}
          collapse={opts.collapse}
          highlight={opts.highlight}
          edit={opts.editable && editId === mEventId}
          canDelete={canRedact || (canDeleteOwn && mEvent.getSender() === mx.getUserId())}
          canSendReaction={canSendReaction}
          canPinEvent={canPinEvent}
          imagePackRooms={imagePackRooms}
          relations={hasReactions ? reactionRelations : undefined}
          onUserClick={handleUserClick}
          onUsernameClick={handleUsernameClick}
          onReplyClick={handleReplyClick}
          onReactionToggle={handleReactionToggle}
          onEditId={opts.editable ? handleEdit : undefined}
          reply={
            replyEventId && (
              <Reply
                room={room}
                timelineSet={timelineSet}
                replyEventId={replyEventId}
                threadRootId={threadRootId}
                onClick={handleOpenReply}
                getMemberPowerTag={getMemberPowerTag}
                accessibleTagColors={accessiblePowerTagColors}
                legacyUsernameColor={legacyUsernameColor || direct}
              />
            )
          }
          reactions={
            reactionRelations && (
              <Reactions
                style={{ marginTop: config.space.S200 }}
                room={room}
                relations={reactionRelations}
                mEventId={mEventId}
                canSendReaction={canSendReaction}
                onReactionToggle={handleReactionToggle}
              />
            )
          }
          hideReadReceipts={hideActivity}
          showDeveloperTools={showDeveloperTools}
          memberPowerTag={getMemberPowerTag(mEvent.getSender() ?? '')}
          accessibleTagColors={accessiblePowerTagColors}
          legacyUsernameColor={legacyUsernameColor || direct}
          hour24Clock={hour24Clock}
          dateFormatString={dateFormatString}
          lotusTerminal={!!lotusTerminal}
        >
          {renderMessageContent(mEvent, mEventId, timelineSet)}
        </Message>
      );
    },
    [
      thread,
      room,
      messageSpacing,
      messageLayout,
      editId,
      canRedact,
      canDeleteOwn,
      canSendReaction,
      canPinEvent,
      imagePackRooms,
      handleUserClick,
      handleUsernameClick,
      handleReplyClick,
      handleReactionToggle,
      handleEdit,
      handleOpenReply,
      getMemberPowerTag,
      accessiblePowerTagColors,
      legacyUsernameColor,
      direct,
      hideActivity,
      showDeveloperTools,
      hour24Clock,
      dateFormatString,
      lotusTerminal,
      mx,
      renderMessageContent,
    ],
  );

  let prevEvent: MatrixEvent | undefined;
  let isPrevRendered = false;
  let dayDivider = false;
  const eventRenderer = (item: number) => {
    const [eventTimeline, baseIndex] = getTimelineAndBaseIndex(timeline.linkedTimelines, item);
    if (!eventTimeline) return null;
    const mEvent = getTimelineEvent(eventTimeline, getTimelineRelativeIndex(item, baseIndex));
    const mEventId = mEvent?.getId();
    if (!mEvent || !mEventId) return null;

    // Skip annotations, edits, and any state/membership events (they can't be threaded).
    if (reactionOrEditEvent(mEvent) || typeof mEvent.getStateKey() === 'string') {
      prevEvent = mEvent;
      return null;
    }
    const eventSender = mEvent.getSender();
    if (eventSender && ignoredUsersSet.has(eventSender)) {
      prevEvent = mEvent;
      return null;
    }

    const isRoot = mEventId === thread.id;

    if (!dayDivider) {
      dayDivider = prevEvent ? !inSameDay(prevEvent.getTs(), mEvent.getTs()) : false;
    }

    const collapsed =
      !isRoot &&
      !perMessageProfiles &&
      isPrevRendered &&
      !dayDivider &&
      prevEvent !== undefined &&
      prevEvent.getId() !== thread.id &&
      prevEvent.getSender() === eventSender &&
      prevEvent.getType() === mEvent.getType() &&
      minuteDifference(prevEvent.getTs(), mEvent.getTs()) < 5;

    const eventJSX = renderMessage(mEvent, {
      item,
      collapse: collapsed,
      highlight: false,
      editable: true,
    });

    const dayDividerJSX =
      dayDivider && eventJSX && !isRoot ? (
        <MessageBase space={messageSpacing}>
          <Box gap="100" justifyContent="Center" alignItems="Center">
            <Line style={{ flexGrow: 1 }} variant="Surface" size="300" />
            <Badge as="span" size="500" variant="Secondary" fill="None" radii="300">
              <Text size="L400">
                {(() => {
                  if (today(mEvent.getTs())) return 'Today';
                  if (yesterday(mEvent.getTs())) return 'Yesterday';
                  return timeDayMonthYear(mEvent.getTs());
                })()}
              </Text>
            </Badge>
            <Line style={{ flexGrow: 1 }} variant="Surface" size="300" />
          </Box>
        </MessageBase>
      ) : null;

    prevEvent = mEvent;
    isPrevRendered = !!eventJSX;
    if (dayDividerJSX) dayDivider = false;

    // Root gets an emphasized container + a "N replies" divider under it.
    if (isRoot && eventJSX) {
      const replyCount = thread.length;
      return (
        <React.Fragment key={mEventId}>
          <div className={css.RootMessage}>{eventJSX}</div>
          {replyCount > 0 && (
            <Box
              className={css.RepliesDivider}
              gap="100"
              justifyContent="Center"
              alignItems="Center"
            >
              <Line style={{ flexGrow: 1 }} variant="Surface" size="300" />
              <Text size="L400" priority="300">
                {replyCount === 1 ? '1 reply' : `${replyCount} replies`}
              </Text>
              <Line style={{ flexGrow: 1 }} variant="Surface" size="300" />
            </Box>
          )}
        </React.Fragment>
      );
    }

    if (eventJSX && dayDividerJSX) {
      return (
        <React.Fragment key={mEventId}>
          {dayDividerJSX}
          {eventJSX}
        </React.Fragment>
      );
    }

    return eventJSX;
  };

  const items = getItems();
  const showEmptyReplies = ready && thread.length === 0;

  const renderPendingEvent = (mEvent: MatrixEvent) => {
    const failed =
      mEvent.status === EventStatus.NOT_SENT || mEvent.status === EventStatus.CANCELLED;
    return (
      <div
        key={mEvent.getId() ?? mEvent.getTxnId()}
        className={classNames(failed ? css.PendingFailed : css.PendingMessage)}
      >
        {renderMessage(mEvent, { collapse: false, highlight: false, editable: false })}
        {failed && (
          <Box style={{ padding: `0 ${config.space.S400}` }}>
            <Text size="T200" style={{ color: color.Critical.Main }}>
              Failed to send
            </Text>
          </Box>
        )}
      </div>
    );
  };

  if (!ready) {
    return (
      <Box
        className={css.ThreadCentered}
        grow="Yes"
        direction="Column"
        justifyContent="Center"
        alignItems="Center"
      >
        <Spinner variant="Secondary" size="600" />
      </Box>
    );
  }

  return (
    <Box className={css.ThreadTimeline} grow="Yes">
      <Scroll ref={scrollRef} visibility="Hover">
        <Box
          className={css.ThreadTimelineContent}
          direction="Column"
          justifyContent="End"
          role="log"
          aria-label="Thread timeline"
          aria-live="polite"
        >
          {(canPaginateBack || !rangeAtStart) && (
            <>
              <MessageBase>
                <DefaultPlaceholder />
              </MessageBase>
              <MessageBase ref={observeBackAnchor}>
                <DefaultPlaceholder />
              </MessageBase>
            </>
          )}

          {items.map(eventRenderer)}

          {showEmptyReplies && (
            <Box className={css.NoReplies} justifyContent="Center">
              <Text size="T300" priority="300">
                No replies yet — say something
              </Text>
            </Box>
          )}

          {pendingEvents.map(renderPendingEvent)}

          <span ref={atBottomAnchorRef} />
        </Box>
      </Scroll>
      {!atBottom && (
        <Box className={css.ThreadTimelineFloat} justifyContent="Center" alignItems="Center">
          <Chip
            variant="SurfaceVariant"
            radii="Pill"
            outlined
            before={<Icon size="50" src={Icons.ArrowBottom} />}
            onClick={handleJumpToBottom}
          >
            <Text size="L400">Jump to Latest</Text>
          </Chip>
        </Box>
      )}
      {editHistoryEvent && (
        <EditHistoryModal
          room={room}
          mEvent={editHistoryEvent}
          onClose={() => setEditHistoryEvent(undefined)}
        />
      )}
    </Box>
  );
}
