import { parseMessagePayload, pinMessage, unpinMessage } from '@chat-app/shared/chat';
import { extractErrorCode } from '@chat-app/shared/i18n';
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';
import { MessageList, type MessageListHandle } from '../components/MessageList';

import { ComposerActionsMenu } from '../components/ComposerActionsMenu';
import { ConversationHeader } from '../components/ConversationHeader';
import { EmojiPicker } from '../components/EmojiPicker';
import { EmptyState } from '../components/EmptyState';
import { ForwardDialog } from '../components/ForwardDialog';
import { GifPicker } from '../components/GifPicker';
import { GroupInfoPanel } from '../components/GroupInfoPanel';
import {
  AlertIcon,
  ArrowRightIcon,
  ChevronDownIcon,
  ChevronUpIcon,
  EyeIcon,
  EyeOffIcon,
  PencilIcon,
  PlusIcon,
  ReplyIcon,
  SearchIcon,
  SendIcon,
  SmileIcon,
  SpinnerIcon,
  XIcon,
} from '../components/icons';
const ImageAnnotator = lazy(() =>
  import('../components/ImageAnnotator').then((m) => ({ default: m.ImageAnnotator })),
);
import { InCallPanel } from '../components/InCallPanel';
import { IncomingCallPanel } from '../components/IncomingCallPanel';
import { CallPreviewPanel } from '../components/CallPreviewPanel';
import { MediaFilesDrawer } from '../components/MediaFilesDrawer';
import { MentionAutocomplete } from '../components/MentionAutocomplete';
import { MessageBubble, type QuotedRef } from '../components/MessageBubble';
import type { AggregatedReaction } from '../lib/useMessageReactions';
import { PinnedMessagesPanel } from '../components/PinnedMessagesPanel';
import { PollComposerDialog } from '../components/PollComposerDialog';
import { UserProfilePopover } from '../components/UserProfilePopover';
import { TypingIndicator } from '../components/TypingIndicator';
import { VoiceRecorder } from '../components/VoiceRecorder';
import type { DecryptedMessage } from '@chat-app/shared/chat';
import { useAuth } from '../context/AuthContext';
import { useCall } from '../context/CallContext';
import { useConversationsContext } from '../context/ConversationsContext';
import {
  collectConversationAttachments,
  createPollPayload,
  createWhiteboardPayload,
  createWatchTogetherPayload,
  createGamePayload,
} from '../lib/conversationFeatures';
const WhiteboardModal = lazy(() =>
  import('../components/WhiteboardModal').then((m) => ({ default: m.WhiteboardModal })),
);
const WatchTogetherModal = lazy(() =>
  import('../components/WatchTogetherModal').then((m) => ({ default: m.WatchTogetherModal })),
);
const GameModal = lazy(() =>
  import('../components/GameModal').then((m) => ({ default: m.GameModal })),
);
import { createWhiteboard, createWatchSession, parseYouTubeUrl, createGame, type GameType } from '@chat-app/shared/chat';
import { compressImages } from '../lib/imageCompress';
import { ensureInstallId } from '../lib/installId';
import { searchCachedMessages } from '../lib/messageCache';
import { supabase } from '../lib/supabase';
import { type GifResult } from '../lib/tenor';
import type { OutboxItem } from '../lib/messageOutbox';
import { useConversationMessages } from '../lib/useConversationMessages';
import { useMessageReactions } from '../lib/useMessageReactions';
import { useGroupReceipts } from '../lib/useGroupReceipts';
import { markDelivered, useMessageDeliveries } from '../lib/useMessageDeliveries';
import { markRead as markMessagesReadRemote, useMessageReads } from '../lib/useMessageReads';
import { usePeerPresence } from '../lib/usePeerPresence';
import { usePinnedMessages } from '../lib/usePinnedMessages';
import { useTypingChannel } from '../lib/useTypingChannel';
import { clearDraft, getDraftSync, setDraft } from '../lib/composerDraftStore';

// Discriminated union for rows inside the virtualized message list. Keeping
// pending bubbles and the "load older" tile inside the same Virtuoso
// instance means scroll-to-bottom / followOutput stay coherent across both
// (we don't need a sibling scroll container for pending items).
export type VirtuosoRow =
  | { kind: 'loader'; key: string }
  | { kind: 'message'; key: string; message: DecryptedMessage; idx: number }
  | { kind: 'pending'; key: string; item: OutboxItem };

// Stable empty-reactions sentinel. We pass this when a message has no
// reactions instead of `[]` literal — a fresh array per render would defeat
// `React.memo` on `MessageBubble` since the `reactions` prop reference would
// change on every parent render.
const EMPTY_REACTIONS: AggregatedReaction[] = [];

// Per-conversation scroll memory. Module-scoped so it survives the
// per-id remount of ConversationPage (see `ConversationRoute` in
// App.tsx). Session-only (lost on reload, like Discord). The
// `stickToBottom` flag is preserved alongside the topmost-visible row
// index so a chat the user left at the bottom keeps auto-following new
// messages when they return; a chat scrolled up returns to roughly the
// same row the user was reading.
//
// We track the topmost-visible row index rather than a pixel `scrollTop`
// because `react-virtuoso` virtualizes the list — the underlying scroll
// element's pixel offset depends on dynamically-measured row heights and
// is not stable across remounts. Using a row index restores the user's
// reading position even if some rows above re-render at different heights.
const scrollPositions = new Map<string, { topmostIndex: number; stickToBottom: boolean }>();

/** Pending composer attachment: the raw File plus the per-attachment
 *  view-once flag the user can toggle from the thumb hover button (P7.T4).
 *  Lives only in composer state — the flag is forwarded into
 *  `message_attachments.view_once` per row when the message is sent. */
interface PendingAttachment {
  file: File;
  viewOnce: boolean;
}

export function ConversationPage() {
  const { t } = useTranslation(['app', 'errors']);
  const { id } = useParams<{ id: string }>();
  const { session } = useAuth();
  const { conversations, setActiveConversation, markRead, unread } = useConversationsContext();

  const conversation = useMemo(
    () => conversations.find((c) => c.id === id) ?? null,
    [conversations, id],
  );
  const peerId = conversation?.peer?.userId;
  const peerPresence = usePeerPresence(peerId);

  const { messages, loading, error, send, pending, retryPending, cancelPending } =
    useConversationMessages({
      conversationId: id,
      userId: session?.user.id,
      deviceId: ensureInstallId(),
    });
  const messageIds = useMemo(() => messages.map((m) => m.id), [messages]);
  const {
    byMessage: reactionsByMessage,
    toggle: toggleReaction,
    voteExclusive: votePoll,
    ready: reactionsReady,
  } = useMessageReactions(messageIds, session?.user.id);

  // Deferred-reveal gate for MessageList: keep the list hidden until messages
  // AND their reactions (the main post-paint height changer) are loaded, so the
  // chat opens already-stable instead of flickering through the load cascade.
  // A 300 ms max-timeout ensures a slow/empty reactions fetch never hangs it.
  const [revealTimedOut, setRevealTimedOut] = useState(false);
  useEffect(() => {
    setRevealTimedOut(false);
    if (!id || loading || messages.length === 0) return;
    const tmo = window.setTimeout(() => setRevealTimedOut(true), 300);
    return () => window.clearTimeout(tmo);
  }, [id, loading, messages.length]);
  const listReady = !loading && messages.length > 0 && (reactionsReady || revealTimedOut);

  const myId = session?.user.id;

  const ownMessageIds = useMemo(
    () => messages.filter((m) => m.senderId === myId).map((m) => m.id),
    [messages, myId],
  );
  const { peerReadSet } = useMessageReads(ownMessageIds, peerId);
  const { peerDeliveredSet } = useMessageDeliveries(ownMessageIds, peerId);

  const isGroup = conversation?.type === 'group';
  const { deliveredByMessage: groupDelivered, readByMessage: groupRead } = useGroupReceipts(
    ownMessageIds,
    myId,
    !!isGroup,
  );
  const groupRecipientCount = useMemo(() => {
    if (!isGroup || !conversation) return 0;
    return conversation.members.filter((m) => m.userId !== myId).length;
  }, [isGroup, conversation, myId]);

  const deliveredTrackedRef = useRef<Set<string>>(new Set());
  useEffect(() => {
    if (!myId || messages.length === 0) return;
    const toMark: string[] = [];
    for (const m of messages) {
      if (m.senderId === myId) continue;
      if (deliveredTrackedRef.current.has(m.id)) continue;
      deliveredTrackedRef.current.add(m.id);
      toMark.push(m.id);
    }
    if (toMark.length > 0) {
      void markDelivered(toMark).catch((err: unknown) => {
        console.warn('markDelivered failed', err);
      });
    }
  }, [messages, myId]);

  const lastSeenMessageId = useMemo(() => {
    for (let i = messages.length - 1; i >= 0; i--) {
      const m = messages[i];
      if (m && m.senderId === myId && peerReadSet.has(m.id)) return m.id;
    }
    return null;
  }, [messages, peerReadSet, myId]);

  const { typingUserIds, notifyTyping, notifyStopTyping } = useTypingChannel(id, myId);

  useEffect(() => {
    if (!id || messages.length === 0 || !myId) return;
    const incoming = messages.filter((m) => m.senderId !== myId).map((m) => m.id);
    if (incoming.length === 0) return;
    void markMessagesReadRemote(incoming).catch((err: unknown) => {
      console.error('markMessagesRead failed', err);
    });
  }, [id, messages, myId]);

  const [text, setText] = useState<string>(() => {
    if (!id) return '';
    return getDraftSync(id)?.text ?? '';
  });
  const [sending, setSending] = useState(false);
  const [sendError, setSendError] = useState<string | null>(null);
  const [stickToBottom, setStickToBottom] = useState(true);
  // Pending composer attachments — each carries its own view-once flag so
  // the user can mark individual images "burn after viewing" via the hover
  // toggle on the thumb (P7.T4). Non-image attachments keep viewOnce=false
  // but the field stays on the object so the shape is uniform.
  const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
  const [annotatingIndex, setAnnotatingIndex] = useState<number | null>(null);
  const [infoPanelOpen, setInfoPanelOpen] = useState(false);
  const [mediaDrawerOpen, setMediaDrawerOpen] = useState(false);
  const [pollDialogOpen, setPollDialogOpen] = useState(false);
  const [pollSending, setPollSending] = useState(false);
  const [openWhiteboardId, setOpenWhiteboardId] = useState<string | null>(null);
  const [creatingWhiteboard, setCreatingWhiteboard] = useState(false);
  const [openWatchSessionId, setOpenWatchSessionId] = useState<string | null>(null);
  const [watchDialogOpen, setWatchDialogOpen] = useState(false);
  const [watchUrl, setWatchUrl] = useState('');
  const [watchError, setWatchError] = useState<string | null>(null);
  const [watchCreating, setWatchCreating] = useState(false);
  const [openGameId, setOpenGameId] = useState<string | null>(null);
  const [gameDialogOpen, setGameDialogOpen] = useState(false);
  const [gameError, setGameError] = useState<string | null>(null);
  const [gameCreating, setGameCreating] = useState(false);
  const [pollError, setPollError] = useState<string | null>(null);
  const [replyTo, setReplyTo] = useState<DecryptedMessage | null>(null);
  const [forwardTarget, setForwardTarget] = useState<DecryptedMessage | null>(null);
  const [searchOpen, setSearchOpen] = useState(false);
  const [searchQuery, setSearchQuery] = useState('');
  const [searchIdx, setSearchIdx] = useState(0);
  const [searchSenderId, setSearchSenderId] = useState<string>('');
  const [searchAttachmentsOnly, setSearchAttachmentsOnly] = useState(false);
  const [searchDateFrom, setSearchDateFrom] = useState<string>('');
  const [searchDateTo, setSearchDateTo] = useState<string>('');
  const [highlightedId, setHighlightedId] = useState<string | null>(null);
  const [displayCount, setDisplayCount] = useState<number>(150);
  const [isDraggingFile, setIsDraggingFile] = useState(false);
  const [firstUnreadId, setFirstUnreadId] = useState<string | null>(null);
  const [firstUnreadJumpDismissed, setFirstUnreadJumpDismissed] = useState(false);
  const [newMessagesWhileAway, setNewMessagesWhileAway] = useState(0);
  const firstUnreadComputedRef = useRef<boolean>(false);
  const previousMessageIdsRef = useRef<Set<string>>(new Set());
  const [profilePopover, setProfilePopover] = useState<{
    userId: string;
    x: number;
    y: number;
  } | null>(null);
  const [mentionState, setMentionState] = useState<{ query: string; start: number } | null>(null);
  const [emojiOpen, setEmojiOpen] = useState(false);
  const [gifPickerOpen, setGifPickerOpen] = useState(false);
  const [actionsMenuOpen, setActionsMenuOpen] = useState(false);
  const actionsMenuAnchorRef = useRef<HTMLButtonElement>(null);
  const { pins, applyOptimisticPin, applyOptimisticUnpin, restorePins } = usePinnedMessages(id);
  const [pinnedPanelOpen, setPinnedPanelOpen] = useState(false);
  const pinnedIds = useMemo(() => new Set(pins.map((p) => p.messageId)), [pins]);

  // Optimistic pin/unpin: flip the local list synchronously so the pin badge
  // / panel updates on the same frame as the click. Realtime echo via
  // usePinnedMessages will refetch and reconcile (no-op since the optimistic
  // row matches the server). On error we restore the snapshot so the badge
  // doesn't lie about persisted state.
  const handleTogglePin = useCallback(
    async (messageId: string) => {
      if (!id || !myId) return;
      const wasPinned = pinnedIds.has(messageId);
      const snapshot = wasPinned
        ? applyOptimisticUnpin(messageId)
        : applyOptimisticPin(messageId, myId);
      try {
        if (wasPinned) {
          await unpinMessage(supabase, id, messageId);
        } else {
          await pinMessage(supabase, id, messageId, myId);
        }
      } catch (err) {
        restorePins(snapshot);
        console.warn('pin toggle failed', err);
      }
    },
    [id, myId, pinnedIds, applyOptimisticPin, applyOptimisticUnpin, restorePins],
  );

  const handleGifPick = useCallback(
    async (gif: GifResult) => {
      try {
        // Download the GIF bytes once and feed them into the existing
        // image-attachment pipeline so the result is end-to-end-encrypted
        // like any normal image.
        const res = await fetch(gif.url);
        const blob = await res.blob();
        const file = new File([blob], `tenor-${gif.id}.gif`, { type: 'image/gif' });
        await send('', [file], null);
      } catch (err) {
        console.warn('GIF send failed', err);
      }
    },
    [send],
  );
  const listRef = useRef<MessageListHandle>(null);
  const topmostIndexRef = useRef<number>(0);
  const fileInputRef = useRef<HTMLInputElement>(null);
  const composerRef = useRef<HTMLTextAreaElement>(null);

  useEffect(() => {
    if (!id) return;
    const draft = getDraftSync(id);
    const savedReplyToId = draft?.replyToId ?? null;
    if (!savedReplyToId) return;
    if (replyTo?.id === savedReplyToId) return;
    const match = messages.find((m) => m.id === savedReplyToId);
    if (match) setReplyTo(match);
  }, [id, messages, replyTo?.id]);

  useEffect(() => {
    if (!id) return;
    setDraft(id, { text, replyToId: replyTo?.id ?? null });
  }, [id, text, replyTo?.id]);

  useEffect(() => {
    if (firstUnreadComputedRef.current) return;
    if (!id || messages.length === 0) return;
    const count = unread[id] ?? 0;
    firstUnreadComputedRef.current = true;
    if (count === 0 || count > messages.length) {
      setFirstUnreadId(null);
      return;
    }
    const boundary = messages[messages.length - count];
    setFirstUnreadId(boundary ? boundary.id : null);
  }, [id, messages, unread]);

  useEffect(() => {
    const previous = previousMessageIdsRef.current;
    if (previous.size > 0 && !stickToBottom) {
      const addedIncoming = messages.filter(
        (message) => !previous.has(message.id) && message.senderId !== myId,
      ).length;
      if (addedIncoming > 0) {
        setNewMessagesWhileAway((count) => count + addedIncoming);
      }
    }
    previousMessageIdsRef.current = new Set(messages.map((message) => message.id));
  }, [messages, myId, stickToBottom]);

  // Replaces the previous IntersectionObserver-on-sentinel pattern: Virtuoso
  // calls `startReached` when the user scrolls near the first row of the
  // virtualized list. We bump `displayCount` the same way the old observer
  // did. Wrapped in useCallback so Virtuoso doesn't tear down its scroll
  // observer on every parent re-render.
  const handleStartReached = useCallback(() => {
    setDisplayCount((n) => {
      if (n >= messages.length) return n;
      return Math.min(messages.length, n * 2);
    });
  }, [messages.length]);

  const messageById = useMemo(() => {
    const m = new Map<string, DecryptedMessage>();
    for (const msg of messages) m.set(msg.id, msg);
    return m;
  }, [messages]);

  const attachmentIndex = useMemo(() => collectConversationAttachments(messages), [messages]);

  const senderNameFor = useCallback(
    (senderId: string): string => {
      if (senderId === myId) return t('app:chats.you', { defaultValue: 'Du' });
      const profile =
        conversation?.members.find((mm) => mm.userId === senderId)?.profile ??
        (senderId !== myId ? (conversation?.peer ?? null) : null);
      return profile?.displayName ?? '?';
    },
    [conversation, myId, t],
  );

  const buildQuoted = useCallback(
    (replyToId: string | null): QuotedRef | null => {
      if (!replyToId) return null;
      const target = messageById.get(replyToId);
      if (!target) {
        return {
          id: replyToId,
          senderName: '…',
          snippet: t('app:chats.quote_unavailable', { defaultValue: 'Nachricht nicht verfügbar' }),
          isAttachment: false,
          deleted: true,
        };
      }
      const parsed = parseMessagePayload(target.plaintext);
      const text =
        parsed.kind === 'text'
          ? parsed.text
          : parsed.kind === 'poll'
            ? 'Umfrage: ' + parsed.question
            : '';
      const hasAttachment = parsed.kind === 'text' && parsed.attachments.length > 0;
      return {
        id: target.id,
        senderName: senderNameFor(target.senderId),
        snippet: text.length > 120 ? text.slice(0, 120) + '…' : text,
        isAttachment: hasAttachment,
        deleted: !!target.deletedAt,
      };
    },
    [messageById, senderNameFor, t],
  );

  // Pre-compute quoted refs per message into a stable map. Calling
  // `buildQuoted(m.replyToId)` inline inside the `.map` returned a fresh
  // object on every parent render, defeating `React.memo` on MessageBubble.
  // With the map memoized on the same deps as `buildQuoted`, each bubble
  // gets a stable `quoted` reference until the underlying data actually
  // changes (new messages, sender renames, language switch).
  const quotedByMessage = useMemo(() => {
    const out = new Map<string, QuotedRef | null>();
    for (const m of messages) {
      out.set(m.id, buildQuoted(m.replyToId));
    }
    return out;
  }, [messages, buildQuoted]);

  // Build the discriminated-union row list Virtuoso renders. We use a
  // single virtualized list rather than separate "messages" and "pending"
  // sections so the unsent items stay at the bottom of the scroll viewport
  // (and Virtuoso's `followOutput` still triggers correctly when a new
  // outbox item is appended). Optional row 0 is the "load older" tile —
  // matches the old IntersectionObserver-sentinel pattern.
  const virtuosoRows = useMemo<VirtuosoRow[]>(() => {
    const out: VirtuosoRow[] = [];
    const hasLoader = displayCount < messages.length;
    if (hasLoader) {
      out.push({ kind: 'loader', key: '__loader__' });
    }
    const sliceStart = Math.max(0, messages.length - displayCount);
    for (let i = sliceStart; i < messages.length; i++) {
      const m = messages[i];
      if (!m) continue;
      out.push({ kind: 'message', key: m.id, message: m, idx: i });
    }
    for (const p of pending) {
      out.push({ kind: 'pending', key: 'pending-' + p.id, item: p });
    }
    return out;
  }, [messages, pending, displayCount]);

  // Snapshot of the saved position for this conversation, captured once on
  // mount. Used to derive the `initialTopMostItemIndex` we hand to the
  // Virtuoso instance below — Virtuoso applies that index synchronously
  // before its first paint, so re-entering a chat shows the saved row in
  // one frame rather than a "starts at top, jumps" flicker.
  //
  // Declared HERE (above `initialTopMostIndex`) rather than further down
  // because the useMemo that consumes it would otherwise hit a TDZ on
  // first render — `const` refs aren't hoisted.
  const savedPositionRef = useRef<{ topmostIndex: number; stickToBottom: boolean } | null>(
    null,
  );
  if (savedPositionRef.current === null && id) {
    savedPositionRef.current = scrollPositions.get(id) ?? null;
  }

  // Initial scroll position for the freshly-mounted Virtuoso instance.
  // Default = bottom (newest message). If we have a saved position from a
  // previous visit to this chat AND the user wasn't sticking to the
  // bottom, restore the saved row index (clamped to the current row
  // count in case the cache was trimmed).
  const initialAnchor = useMemo<{ type: 'bottom' } | { type: 'row'; index: number }>(() => {
    const saved = savedPositionRef.current;
    if (saved && !saved.stickToBottom) return { type: 'row', index: saved.topmostIndex };
    return { type: 'bottom' };
  }, []);

  const jumpToMessage = useCallback(
    (targetId: string) => {
      const msgIdx = messages.findIndex((m) => m.id === targetId);
      if (msgIdx < 0) return; // pinned message outside loaded cache — no-op
      // Make sure the target is actually inside the rendered slice; if the
      // user has only loaded the most-recent 150 rows but is jumping to an
      // older message, expand the slice so the row exists in the virtual
      // list before we ask Virtuoso to scroll to it.
      const needsAtLeast = messages.length - msgIdx;
      if (needsAtLeast > displayCount) {
        setDisplayCount(needsAtLeast);
      }
      // Convert message-array index into row index for the virtuoso rows
      // array (see `rows` further down). The slice starts at
      // `messages.length - displayCount`, and row 0 is the optional
      // "load older" header.
      const targetDisplayCount = Math.max(displayCount, needsAtLeast);
      const sliceStart = Math.max(0, messages.length - targetDisplayCount);
      const hasLoader = targetDisplayCount < messages.length;
      const rowIndex = (hasLoader ? 1 : 0) + (msgIdx - sliceStart);
      // requestAnimationFrame: when we just bumped `displayCount`, Virtuoso
      // needs a paint to register the new rows before scrollToIndex can
      // resolve the target row. Without this, the scroll either no-ops or
      // lands on a stale row.
      requestAnimationFrame(() => {
        listRef.current?.scrollToRow(rowIndex, 'center', 'smooth');
      });
      setHighlightedId(targetId);
      window.setTimeout(
        () => setHighlightedId((cur) => (cur === targetId ? null : cur)),
        1600,
      );
    },
    [messages, displayCount],
  );

  const handleReply = useCallback((m: DecryptedMessage) => {
    setReplyTo(m);
    composerRef.current?.focus();
  }, []);

  const handleForward = useCallback((m: DecryptedMessage) => {
    setForwardTarget(m);
  }, []);

  // Stable handler for MessageBubble's `onAvatarClick`. Previously this was
  // an inline arrow in the `.map`, which gave every row a fresh callback ref
  // and defeated `React.memo` on the bubble (every parent re-render — every
  // keystroke in the composer — re-rendered all 200 bubbles).
  const handleAvatarClick = useCallback((uid: string, ev: React.MouseEvent) => {
    ev.stopPropagation();
    setProfilePopover({
      userId: uid,
      x: ev.clientX,
      y: ev.clientY,
    });
  }, []);

  const searchActive = useMemo(
    () =>
      searchQuery.trim().length > 0 ||
      searchSenderId !== '' ||
      searchAttachmentsOnly ||
      searchDateFrom !== '' ||
      searchDateTo !== '',
    [searchQuery, searchSenderId, searchAttachmentsOnly, searchDateFrom, searchDateTo],
  );

  const [ftsExtras, setFtsExtras] = useState<DecryptedMessage[]>([]);
  useEffect(() => {
    if (!id) {
      setFtsExtras([]);
      return;
    }
    const q = searchQuery.trim();
    if (q.length < 2) {
      setFtsExtras([]);
      return;
    }
    let cancelled = false;
    void searchCachedMessages(id, q, 200).then((rows) => {
      if (cancelled) return;
      setFtsExtras(rows);
    });
    return () => {
      cancelled = true;
    };
  }, [id, searchQuery]);

  const searchMatches = useMemo(() => {
    if (!searchActive) return [] as DecryptedMessage[];
    const q = searchQuery.trim().toLowerCase();
    const fromTs = searchDateFrom ? new Date(searchDateFrom).getTime() : null;
    const toTs = searchDateTo ? new Date(searchDateTo).getTime() + 24 * 3600 * 1000 - 1 : null;
    const seen = new Set<string>();
    const pool: DecryptedMessage[] = [];
    for (const m of messages) {
      if (!seen.has(m.id)) {
        seen.add(m.id);
        pool.push(m);
      }
    }
    for (const m of ftsExtras) {
      if (!seen.has(m.id)) {
        seen.add(m.id);
        pool.push(m);
      }
    }
    pool.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
    return pool.filter((m) => {
      if (searchSenderId && m.senderId !== searchSenderId) return false;
      const created = new Date(m.createdAt).getTime();
      if (fromTs !== null && created < fromTs) return false;
      if (toTs !== null && created > toTs) return false;
      if (!m.plaintext) return false;
      const parsed = parseMessagePayload(m.plaintext);
      if (parsed.kind !== 'text') return false;
      if (searchAttachmentsOnly && parsed.attachments.length === 0) return false;
      if (q && !parsed.text.toLowerCase().includes(q)) return false;
      return true;
    });
  }, [
    messages,
    ftsExtras,
    searchActive,
    searchQuery,
    searchSenderId,
    searchAttachmentsOnly,
    searchDateFrom,
    searchDateTo,
  ]);

  useEffect(() => {
    if (searchMatches.length === 0) {
      setSearchIdx(0);
      return;
    }
    setSearchIdx((cur) => Math.min(cur, searchMatches.length - 1));
  }, [searchMatches.length]);

  useEffect(() => {
    if (!searchOpen || searchMatches.length === 0) return;
    const target = searchMatches[searchIdx];
    if (target) jumpToMessage(target.id);
  }, [searchOpen, searchMatches, searchIdx, jumpToMessage]);

  useEffect(() => {
    if (!id) return;
    setActiveConversation(id);
    return () => {
      setActiveConversation(null);
    };
  }, [id, setActiveConversation]);

  useEffect(() => {
    const onOpen = (e: Event) => {
      const detail = (e as CustomEvent<{ id?: string }>).detail;
      if (detail?.id) setOpenWhiteboardId(detail.id);
    };
    window.addEventListener('chatapp:open-whiteboard', onOpen);
    return () => window.removeEventListener('chatapp:open-whiteboard', onOpen);
  }, []);

  useEffect(() => {
    const onOpen = (e: Event) => {
      const detail = (e as CustomEvent<{ id?: string }>).detail;
      if (detail?.id) setOpenWatchSessionId(detail.id);
    };
    window.addEventListener('chatapp:open-watch-together', onOpen);
    return () => window.removeEventListener('chatapp:open-watch-together', onOpen);
  }, []);

  useEffect(() => {
    const onOpen = (e: Event) => {
      const detail = (e as CustomEvent<{ id?: string }>).detail;
      if (detail?.id) setOpenGameId(detail.id);
    };
    window.addEventListener('chatapp:open-game', onOpen);
    return () => window.removeEventListener('chatapp:open-game', onOpen);
  }, []);

  useEffect(() => {
    if (id && messages.length > 0) markRead(id);
  }, [id, messages.length, markRead]);

  // Scroll-to-bottom is handled by Virtuoso's `followOutput` prop, which
  // fires whenever the rendered row count grows and auto-scrolls down only
  // if the user was already at the bottom — exactly the Discord behavior
  // we want for both outgoing sends and incoming realtime messages.
  // The old useLayoutEffect that wrote `scrollTop = scrollHeight` is no
  // longer needed: Virtuoso owns scroll positioning now.

  // (savedPositionRef declared earlier — see TDZ note above the
  // initialTopMostIndex useMemo.)

  // Track whether the user is currently scrolled to the bottom. Virtuoso
  // calls this whenever the bottom-state changes; we feed it into
  // `stickToBottom` (used by the "jump to newest" pill and by the
  // "new messages while away" counter logic). Also clears the unread-
  // away counter when the user actually reaches the bottom.
  const handleAtBottomStateChange = useCallback(
    (atBottom: boolean) => {
      setStickToBottom(atBottom);
      if (atBottom) setNewMessagesWhileAway(0);
      if (id) {
        scrollPositions.set(id, {
          topmostIndex: topmostIndexRef.current,
          stickToBottom: atBottom,
        });
      }
    },
    [id],
  );

  // Persists the topmost-visible row index per conversation so re-entering
  // the chat lands roughly where the user left off (see scrollPositions
  // Map). Virtuoso fires `rangeChanged` whenever the visible range shifts;
  // we only care about the start of the range here.
  const handleRangeChanged = useCallback(
    (range: { startIndex: number; endIndex: number }) => {
      topmostIndexRef.current = range.startIndex;
      if (id) {
        const prev = scrollPositions.get(id);
        scrollPositions.set(id, {
          topmostIndex: range.startIndex,
          stickToBottom: prev?.stickToBottom ?? true,
        });
      }
    },
    [id],
  );

  const jumpToBottom = useCallback(() => {
    listRef.current?.scrollToBottom('smooth');
    setStickToBottom(true);
    setNewMessagesWhileAway(0);
  }, []);

  // Whenever an outgoing pending row appears, snap the viewport to the
  // bottom so the user sees their freshly-sent message land. This replaces
  // the old `setStickToBottom(true)` pattern that piggy-backed on a
  // `useLayoutEffect` writing scrollTop — Virtuoso owns scroll positioning
  // now, so we have to call it explicitly. Tracked via a ref so we only
  // scroll when the count actually grew (not on every render where it
  // happens to be > 0).
  // Initialize from the current pending count rather than 0 so we don't
  // fire scrollToIndex(LAST) on the very first render of a chat that
  // already has outbox-queued items. Only growth of `pending.length`
  // across renders should trigger the snap-to-bottom (i.e., the user
  // just submitted something new).
  const lastPendingCountRef = useRef(pending.length);
  useEffect(() => {
    if (pending.length > lastPendingCountRef.current) {
      listRef.current?.scrollToBottom('auto');
    }
    lastPendingCountRef.current = pending.length;
  }, [pending.length]);

  // Snap the viewport back to the bottom after a send. The composer
  // shrinks (cleared text, dismissed reply preview, dropped attachment
  // thumbs) which lets the Virtuoso area grow vertically — leaving the
  // just-sent bubble visibly above the new bottom for a frame.
  // `requestAnimationFrame` defers the scroll until React has committed
  // the composer-height change, so Virtuoso's ResizeObserver has
  // already seen the new viewport and `index: 'LAST', align: 'end'`
  // targets the correct bottom edge.
  const snapToBottom = useCallback(() => {
    window.requestAnimationFrame(() => {
      listRef.current?.scrollToBottom('auto');
    });
  }, []);

  async function handleSend(e?: React.FormEvent) {
    e?.preventDefault();
    if ((!text.trim() && attachments.length === 0) || sending) return;
    setSending(true);
    setSendError(null);
    try {
      await send(
        text,
        attachments.map((a) => a.file),
        replyTo?.id ?? null,
        { viewOnceFlags: attachments.map((a) => a.viewOnce) },
      );
      setText('');
      setAttachments([]);
      setReplyTo(null);
      if (fileInputRef.current) fileInputRef.current.value = '';
      setStickToBottom(true);
      notifyStopTyping();
      if (id) clearDraft(id);
      snapToBottom();
    } catch (err: unknown) {
      const code = extractErrorCode(err);
      setSendError(
        code
          ? t('errors:' + code, { defaultValue: t('errors:generic') })
          : err instanceof Error
            ? err.message
            : t('errors:generic'),
      );
    } finally {
      setSending(false);
    }
  }

  const handlePollSubmit = useCallback(
    async (question: string, options: string[]) => {
      setPollSending(true);
      setPollError(null);
      try {
        const payload = createPollPayload(question, options);
        await send(payload, [], replyTo?.id ?? null);
        setPollDialogOpen(false);
        setReplyTo(null);
        setStickToBottom(true);
        notifyStopTyping();
        snapToBottom();
      } catch (err: unknown) {
        setPollError(err instanceof Error ? err.message : 'Umfrage konnte nicht gesendet werden');
      } finally {
        setPollSending(false);
      }
    },
    [send, replyTo?.id, notifyStopTyping, snapToBottom],
  );

  const handleCreateWhiteboard = useCallback(async () => {
    if (!id || creatingWhiteboard) return;
    setCreatingWhiteboard(true);
    try {
      const board = await createWhiteboard(supabase, id);
      const payload = createWhiteboardPayload(board.id);
      await send(payload, [], replyTo?.id ?? null);
      setReplyTo(null);
      setStickToBottom(true);
      setOpenWhiteboardId(board.id);
      snapToBottom();
    } catch (err: unknown) {
      setSendError(err instanceof Error ? err.message : 'Whiteboard konnte nicht angelegt werden');
    } finally {
      setCreatingWhiteboard(false);
    }
  }, [id, creatingWhiteboard, send, replyTo?.id, snapToBottom]);

  const handleStartWatchTogether = useCallback(async () => {
    if (!id) return;
    const videoId = parseYouTubeUrl(watchUrl);
    if (!videoId) {
      setWatchError('Ungültige YouTube-URL.');
      return;
    }
    setWatchCreating(true);
    setWatchError(null);
    try {
      const ws = await createWatchSession(supabase, { conversationId: id, videoId });
      const payload = createWatchTogetherPayload(ws.id);
      await send(payload, [], replyTo?.id ?? null);
      setReplyTo(null);
      setStickToBottom(true);
      setWatchDialogOpen(false);
      setWatchUrl('');
      setOpenWatchSessionId(ws.id);
      snapToBottom();
    } catch (err: unknown) {
      setWatchError(err instanceof Error ? err.message : 'Konnte Watch-Together nicht starten');
    } finally {
      setWatchCreating(false);
    }
  }, [id, watchUrl, send, replyTo?.id, snapToBottom]);

  const handleStartGame = useCallback(async (gameType: GameType) => {
    if (!id) return;
    if (!conversation || conversation.members.length !== 2) {
      setGameError('Spiele aktuell nur in 1:1-Chats.');
      return;
    }
    const opponent = conversation.members.find((m) => m.userId !== myId);
    if (!opponent) {
      setGameError('Kein Gegner gefunden.');
      return;
    }
    setGameCreating(true);
    setGameError(null);
    try {
      const game = await createGame(supabase, {
        conversationId: id,
        gameType,
        opponentUserId: opponent.userId,
      });
      const payload = createGamePayload(game.id, gameType);
      await send(payload, [], replyTo?.id ?? null);
      setReplyTo(null);
      setStickToBottom(true);
      setGameDialogOpen(false);
      setOpenGameId(game.id);
      snapToBottom();
    } catch (err: unknown) {
      setGameError(err instanceof Error ? err.message : 'Konnte Spiel nicht starten');
    } finally {
      setGameCreating(false);
    }
  }, [id, conversation, myId, send, replyTo?.id, snapToBottom]);

  async function ingestFiles(files: File[]) {
    const compressed = await compressImages(files);
    const next: PendingAttachment[] = [];
    for (const f of compressed) {
      if (f.size > 10 * 1024 * 1024) {
        setSendError('Datei zu groß (max 10 MB)');
        continue;
      }
      // New attachments default to viewOnce=false; user opts in per-thumb
      // via the eye-toggle button on the preview (P7.T4).
      next.push({ file: f, viewOnce: false });
    }
    setAttachments((prev) => [...prev, ...next].slice(0, 4));
  }

  function handleFilesChosen(list: FileList | null) {
    if (!list) return;
    void ingestFiles(Array.from(list));
  }

  const { state: callState } = useCall();
  const callHereActive =
    (callState.kind === 'connected' ||
      callState.kind === 'connecting' ||
      callState.kind === 'outgoing') &&
    callState.conversationId === id;
  const incomingHere = callState.kind === 'incoming' && callState.conversationId === id;

  return (
    <div
      className="relative flex h-full flex-col"
      onDragEnter={(e) => {
        if (e.dataTransfer?.types.includes('Files')) {
          e.preventDefault();
          setIsDraggingFile(true);
        }
      }}
      onDragOver={(e) => {
        if (e.dataTransfer?.types.includes('Files')) {
          e.preventDefault();
          e.dataTransfer.dropEffect = 'copy';
        }
      }}
      onDragLeave={(e) => {
        if (e.currentTarget === e.target) setIsDraggingFile(false);
      }}
      onDrop={(e) => {
        if (!e.dataTransfer?.files?.length) return;
        e.preventDefault();
        setIsDraggingFile(false);
        void ingestFiles(Array.from(e.dataTransfer.files));
      }}
    >
      {!callHereActive && (
        <ConversationHeader
          conversation={conversation}
          peerPresence={peerPresence}
          onMediaClick={() => setMediaDrawerOpen((v) => !v)}
          {...(conversation?.type === 'dm' && conversation.peer
            ? {
                onProfileClick: (ev: React.MouseEvent) => {
                  ev.stopPropagation();
                  setProfilePopover({
                    userId: conversation.peer!.userId,
                    x: ev.clientX,
                    y: ev.clientY,
                  });
                },
              }
            : {})}
          onSearchClick={() => setSearchOpen((v) => !v)}
          {...(isGroup ? { onInfoClick: () => setInfoPanelOpen(true) } : {})}
          pinnedCount={pins.length}
          onOpenPinned={() => setPinnedPanelOpen(true)}
        />
      )}

      {!callHereActive && searchOpen && (
        <SearchBar
          query={searchQuery}
          onQueryChange={setSearchQuery}
          matches={searchMatches.length}
          activeIdx={searchIdx}
          senderId={searchSenderId}
          onSenderChange={setSearchSenderId}
          attachmentsOnly={searchAttachmentsOnly}
          onAttachmentsOnlyChange={setSearchAttachmentsOnly}
          dateFrom={searchDateFrom}
          onDateFromChange={setSearchDateFrom}
          dateTo={searchDateTo}
          onDateToChange={setSearchDateTo}
          members={conversation?.members ?? []}
          onPrev={() =>
            setSearchIdx((cur) =>
              searchMatches.length === 0
                ? 0
                : (cur - 1 + searchMatches.length) % searchMatches.length,
            )
          }
          onNext={() =>
            setSearchIdx((cur) =>
              searchMatches.length === 0 ? 0 : (cur + 1) % searchMatches.length,
            )
          }
          onClose={() => {
            setSearchOpen(false);
            setSearchQuery('');
            setSearchSenderId('');
            setSearchAttachmentsOnly(false);
            setSearchDateFrom('');
            setSearchDateTo('');
          }}
        />
      )}

      {/* Layout row: chat column on the left grows to fill remaining width;
          right-hand drawers (Media/Files, Group Info) render as inline
          siblings so opening one narrows the chat instead of floating on
          top of it (Discord parity). The chat-column wrapper holds the
          `relative` anchor for the drag-and-drop overlay further below. */}
      <div className="flex min-h-0 flex-1 flex-row">
        <div className="relative flex min-w-0 flex-1 flex-col">
          {/* Discord-style persistent voice-channel rail. Always visible in groups
              so anyone can pop in without an invite-ring; hidden in 1:1s unless
              someone is already waiting. Hides automatically once we're in. */}
          {conversation && !incomingHere && <CallPreviewPanel conversation={conversation} />}
          {incomingHere && conversation && <IncomingCallPanel conversation={conversation} />}
          {conversation && <InCallPanel conversation={conversation} />}

      <div className="discord-chat-surface flex min-h-0 flex-1 flex-col bg-surface-3">
        {loading ? (
          <div className="flex items-center gap-2 px-5 py-4 text-xs text-fg-muted">
            <SpinnerIcon className="h-3.5 w-3.5 text-accent" />
          </div>
        ) : error ? (
          <div className="px-5 py-4">
            <Banner>{error}</Banner>
          </div>
        ) : messages.length === 0 ? (
          <div className="px-5 py-4">
            <EmptyState
              icon={<SendIcon className="h-8 w-8" />}
              title={t('app:chats.conv_empty_title', { defaultValue: 'Sag Hallo 👋' })}
              description={t('app:chats.conv_empty_desc', {
                defaultValue: 'Hier ist noch nichts. Schreibe die erste Nachricht.',
              })}
            />
          </div>
        ) : (
          <MessageList
            ref={listRef}
            rows={virtuosoRows}
            // Deferred reveal: the list stays hidden until messages + reactions
            // + the unread divider are loaded, then anchors and reveals — so the
            // post-paint height cascade is never visible (no chat-switch flicker).
            ready={listReady}
            computeKey={(row) => row.key}
            initialAnchor={initialAnchor}
            // 250px at-bottom tolerance — a tall appended row (image, voice note,
            // grouped attachments) shouldn't push the user out of the at-bottom zone.
            atBottomThreshold={250}
            onReachTop={handleStartReached}
            onAtBottomChange={handleAtBottomStateChange}
            onTopRowChange={(topIndex) =>
              handleRangeChanged({ startIndex: topIndex, endIndex: topIndex })
            }
            renderRow={(_index, row) => {
              if (row.kind === 'loader') {
                return (
                  <div className="flex items-center justify-center py-2 text-xs text-fg-muted">
                    Lade ältere Nachrichten…
                  </div>
                );
              }
              if (row.kind === 'pending') {
                return (
                  <PendingBubble
                    item={row.item}
                    onRetry={() => retryPending(row.item.id)}
                    onCancel={() => cancelPending(row.item.id)}
                  />
                );
              }
              const m = row.message;
              const idx = row.idx;
              const prevRaw = messages[idx - 1];
              const nextRaw = messages[idx + 1];
              const prevIsCallEvent =
                !!prevRaw && parseMessagePayload(prevRaw.plaintext).kind === 'call_event';
              const nextIsCallEvent =
                !!nextRaw && parseMessagePayload(nextRaw.plaintext).kind === 'call_event';
              const grouped = !!prevRaw && prevRaw.senderId === m.senderId && !prevIsCallEvent;
              const isLastOfRun = !nextRaw || nextRaw.senderId !== m.senderId || nextIsCallEvent;
              const memberProfile =
                conversation?.members.find((mm) => mm.userId === m.senderId)?.profile ?? null;
              const senderProfile =
                memberProfile ?? (m.senderId !== myId ? (conversation?.peer ?? null) : null);
              return (
                <div className="px-5">
                  {firstUnreadId === m.id && (
                    <div
                      aria-label="Neue Nachrichten"
                      className="my-2 flex items-center gap-3 px-2"
                    >
                      <span className="h-px flex-1 bg-rose-500/60" />
                      <span className="rounded-full bg-rose-500/15 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-rose-500">
                        Neue Nachrichten
                      </span>
                      <span className="h-px flex-1 bg-rose-500/60" />
                    </div>
                  )}
                  <MessageBubble
                    message={m}
                    mine={m.senderId === myId}
                    groupedWithPrev={grouped}
                    isLastOfRun={isLastOfRun}
                    senderDisplayName={senderProfile?.displayName}
                    senderAvatarUrl={senderProfile?.avatarUrl}
                    conversationId={id ?? ''}
                    reactions={reactionsByMessage.get(m.id) ?? EMPTY_REACTIONS}
                    onToggleReaction={toggleReaction}
                    onVotePoll={votePoll}
                    showSeen={m.id === lastSeenMessageId}
                    {...(m.senderId === myId
                      ? {
                          deliveryState: computeDeliveryState({
                            messageId: m.id,
                            isGroup: !!isGroup,
                            recipientCount: groupRecipientCount,
                            peerReadSet,
                            peerDeliveredSet,
                            groupRead,
                            groupDelivered,
                          }),
                        }
                      : {})}
                    quoted={quotedByMessage.get(m.id) ?? null}
                    onJumpToMessage={jumpToMessage}
                    onReply={handleReply}
                    onForward={handleForward}
                    onAvatarClick={handleAvatarClick}
                    highlighted={highlightedId === m.id}
                    isPinned={pinnedIds.has(m.id)}
                    onTogglePin={handleTogglePin}
                  />
                </div>
              );
            }}
          />
        )}
      </div>

      {firstUnreadId && !firstUnreadJumpDismissed && (
        <button
          type="button"
          onClick={() => {
            jumpToMessage(firstUnreadId);
            setFirstUnreadJumpDismissed(true);
          }}
          className="absolute left-1/2 top-[76px] z-30 inline-flex -translate-x-1/2 cursor-pointer items-center gap-2 rounded-full border border-rose-500/30 bg-rose-500 px-3 py-1.5 text-xs font-semibold text-white shadow-lg transition hover:bg-rose-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-300/60"
        >
          <ChevronDownIcon className="h-3.5 w-3.5" />
          <span>Zu ungelesen</span>
        </button>
      )}

      {(!stickToBottom || newMessagesWhileAway > 0) && (
        <button
          type="button"
          onClick={jumpToBottom}
          className="absolute bottom-[92px] left-1/2 z-30 inline-flex -translate-x-1/2 cursor-pointer items-center gap-2 rounded-full border border-line bg-surface-2 px-3 py-1.5 text-xs font-semibold text-fg shadow-lg transition hover:bg-surface-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:bg-[#2b2d31] dark:hover:bg-[#383a40]"
        >
          <ChevronDownIcon className="h-3.5 w-3.5 text-accent" />
          <span>
            {newMessagesWhileAway > 0
              ? newMessagesWhileAway + ' neue Nachrichten'
              : 'Zum neuesten'}
          </span>
        </button>
      )}

      <TypingIndicator typingUserIds={typingUserIds} members={conversation?.members ?? []} />

      {isDraggingFile && (
        <div
          aria-hidden="true"
          className="pointer-events-none absolute inset-0 z-40 flex items-center justify-center rounded-lg border-2 border-dashed border-accent/60 bg-accent/10 backdrop-blur-sm"
        >
          <div className="rounded-xl border border-accent/40 bg-surface-3/90 px-4 py-3 text-sm font-semibold text-fg shadow-xl">
            Datei hier ablegen zum Anhängen
          </div>
        </div>
      )}

      <form onSubmit={handleSend} className="discord-chat-surface bg-surface-3 px-5 pb-5 pt-2">
        {sendError && (
          <div className="mb-2">
            <Banner>{sendError}</Banner>
          </div>
        )}

        {replyTo && (
          <div className="mb-2 flex items-stretch gap-2 rounded-lg border border-line bg-surface-2 p-2 text-sm dark:bg-[#2b2d31]">
            <span aria-hidden="true" className="w-1 shrink-0 rounded-full bg-accent" />
            <ReplyIcon className="mt-0.5 h-4 w-4 shrink-0 text-accent" />
            <div className="min-w-0 flex-1">
              <p className="truncate text-xs font-semibold text-fg">
                {t('app:chats.replying_to', {
                  name: senderNameFor(replyTo.senderId),
                  defaultValue: 'Antwort an ' + senderNameFor(replyTo.senderId),
                })}
              </p>
              <p className="truncate text-xs italic text-fg-muted">
                {(() => {
                  if (!replyTo.plaintext) return '…';
                  const p = parseMessagePayload(replyTo.plaintext);
                  if (p.kind === 'poll') return 'Umfrage: ' + p.question;
                  if (p.kind !== 'text') return '';
                  if (!p.text && p.attachments.length > 0) return '📎';
                  return p.text;
                })()}
              </p>
            </div>
            <button
              type="button"
              onClick={() => setReplyTo(null)}
              aria-label={t('app:friends.action_cancel', { defaultValue: 'Abbrechen' })}
              className="shrink-0 cursor-pointer rounded-md p-1 text-fg-muted transition hover:bg-surface-3 hover:text-fg dark:hover:bg-[#383a40]"
            >
              <XIcon className="h-4 w-4" />
            </button>
          </div>
        )}

        {attachments.length > 0 && (
          <div className="mb-2 flex flex-wrap gap-2">
            {attachments.map((a, idx) => (
              <AttachmentPreview
                key={idx}
                file={a.file}
                viewOnce={a.viewOnce}
                onRemove={() => setAttachments((prev) => prev.filter((_, i) => i !== idx))}
                {...(a.file.type.startsWith('image/')
                  ? {
                      onEdit: () => setAnnotatingIndex(idx),
                      onToggleViewOnce: () =>
                        setAttachments((prev) =>
                          prev.map((x, i) =>
                            i === idx ? { ...x, viewOnce: !x.viewOnce } : x,
                          ),
                        ),
                    }
                  : {})}
              />
            ))}
          </div>
        )}

        <div className="discord-composer relative flex items-end gap-1 rounded-xl border border-transparent bg-surface-2 p-1.5 shadow-sm focus-within:border-accent/60 focus-within:ring-2 focus-within:ring-accent/20">
          {isGroup && mentionState && conversation && (
            <MentionAutocomplete
              members={conversation.members}
              query={mentionState.query}
              excludeUserId={myId}
              onSelect={(username) => {
                const start = mentionState.start;
                const before = text.slice(0, start);
                const afterCaret = text.slice(start + 1 + mentionState.query.length);
                const inserted = '@' + username + ' ';
                const next = before + inserted + afterCaret;
                setText(next);
                setMentionState(null);
                const caret = (before + inserted).length;
                requestAnimationFrame(() => {
                  const el = composerRef.current;
                  if (!el) return;
                  el.focus();
                  el.setSelectionRange(caret, caret);
                });
              }}
              onClose={() => setMentionState(null)}
            />
          )}
          <input
            ref={fileInputRef}
            type="file"
            multiple
            className="hidden"
            onChange={(e) => handleFilesChosen(e.target.files)}
          />
          {/* [+] popover trigger — opens ComposerActionsMenu (file/poll/whiteboard/watch/game) */}
          <button
            ref={actionsMenuAnchorRef}
            type="button"
            onClick={() => setActionsMenuOpen((v) => !v)}
            aria-label={t('app:composer.more_actions', { defaultValue: 'Mehr Aktionen' })}
            title={t('app:composer.more_actions', { defaultValue: 'Mehr Aktionen' })}
            aria-expanded={actionsMenuOpen}
            className="inline-flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-3 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 dark:hover:bg-[#313338]"
          >
            <PlusIcon className="h-4 w-4" />
          </button>
          <ComposerActionsMenu
            anchorRef={actionsMenuAnchorRef}
            open={actionsMenuOpen}
            onClose={() => setActionsMenuOpen(false)}
            onAttachFile={() => fileInputRef.current?.click()}
            onCreatePoll={() => {
              setPollError(null);
              setPollDialogOpen(true);
            }}
            onCreateWhiteboard={() => void handleCreateWhiteboard()}
            onStartWatchTogether={() => setWatchDialogOpen(true)}
            onStartGame={() => setGameDialogOpen(true)}
            canStartGame={conversation?.members?.length === 2}
          />
          <div className="relative">
            <button
              type="button"
              data-emoji-trigger
              onClick={() => setEmojiOpen((v) => !v)}
              aria-label="Emoji einfügen"
              title="Emoji einfügen"
              aria-expanded={emojiOpen}
              className="inline-flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-3 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 dark:hover:bg-[#313338]"
            >
              <SmileIcon className="h-4 w-4" />
            </button>
            <EmojiPicker
              open={emojiOpen}
              onPick={(emoji) => {
                const el = composerRef.current;
                const caret = el?.selectionStart ?? text.length;
                const next = text.slice(0, caret) + emoji + text.slice(caret);
                setText(next);
                requestAnimationFrame(() => {
                  if (!el) return;
                  el.focus();
                  const pos = caret + emoji.length;
                  el.setSelectionRange(pos, pos);
                });
              }}
              onClose={() => setEmojiOpen(false)}
            />
          </div>
          <div className="relative">
            <button
              type="button"
              onClick={() => setGifPickerOpen((v) => !v)}
              className="flex h-9 w-9 cursor-pointer items-center justify-center rounded-md text-fg-muted hover:bg-surface-3 hover:text-fg"
              aria-label="GIF"
              title="GIF einfügen"
            >
              <span className="text-xs font-bold">GIF</span>
            </button>
            <GifPicker
              open={gifPickerOpen}
              onClose={() => setGifPickerOpen(false)}
              onPick={(gif) => void handleGifPick(gif)}
            />
          </div>
          <VoiceRecorder
            disabled={sending}
            onComplete={async (file) => {
              try {
                await send('', [file], replyTo?.id ?? null);
                setReplyTo(null);
              } catch (err: unknown) {
                const msg = err instanceof Error ? err.message : 'send failed';
                setSendError(msg);
              }
            }}
          />
          <textarea
            ref={composerRef}
            value={text}
            onChange={(e) => {
              const next = e.target.value;
              setText(next);
              if (next.length > 0) notifyTyping();
              const caret = e.target.selectionStart ?? next.length;
              const before = next.slice(0, caret);
              const atIdx = before.lastIndexOf('@');
              if (atIdx >= 0 && (atIdx === 0 || /\s/.test(before[atIdx - 1] ?? ''))) {
                const q = before.slice(atIdx + 1);
                if (!/\s/.test(q)) {
                  setMentionState({ query: q, start: atIdx });
                  return;
                }
              }
              setMentionState(null);
            }}
            onKeyDown={(e) => {
              if (e.key === 'Enter' && !e.shiftKey) {
                e.preventDefault();
                void handleSend();
              }
            }}
            onPaste={(e) => {
              const items = e.clipboardData?.items;
              if (!items) return;
              const pics: File[] = [];
              for (const it of Array.from(items)) {
                if (it.kind === 'file') {
                  const f = it.getAsFile();
                  if (f && f.type.startsWith('image/')) pics.push(f);
                }
              }
              if (pics.length > 0) {
                e.preventDefault();
                void ingestFiles(pics);
              }
            }}
            rows={1}
            placeholder="Nachricht schreiben…"
            className="max-h-40 min-h-[40px] flex-1 resize-none rounded-lg bg-transparent px-2 py-2.5 text-sm text-fg placeholder-fg-muted outline-none"
          />
          <button
            type="submit"
            disabled={sending || (text.trim().length === 0 && attachments.length === 0)}
            aria-busy={sending}
            className="inline-flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-lg bg-accent text-accent-fg transition hover:brightness-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 disabled:cursor-not-allowed disabled:opacity-45"
          >
            {sending ? <SpinnerIcon className="h-4 w-4" /> : <ArrowRightIcon className="h-4 w-4" />}
          </button>
        </div>
      </form>
        </div>

        {isGroup && conversation && (
          <GroupInfoPanel
            open={infoPanelOpen}
            onClose={() => setInfoPanelOpen(false)}
            conversation={conversation}
          />
        )}

        <MediaFilesDrawer
          open={mediaDrawerOpen}
          index={attachmentIndex}
          senderNameFor={senderNameFor}
          onJumpToMessage={(messageId) => {
            setMediaDrawerOpen(false);
            jumpToMessage(messageId);
          }}
          onClose={() => setMediaDrawerOpen(false)}
        />
      </div>

      <ForwardDialog
        open={forwardTarget !== null}
        message={forwardTarget}
        currentConversationId={id ?? null}
        onClose={() => setForwardTarget(null)}
      />

      <PollComposerDialog
        open={pollDialogOpen}
        sending={pollSending}
        error={pollError}
        onClose={() => {
          if (!pollSending) setPollDialogOpen(false);
        }}
        onSubmit={handlePollSubmit}
      />

      {profilePopover && (
        <UserProfilePopover
          userId={profilePopover.userId}
          profile={
            conversation?.members.find((m) => m.userId === profilePopover.userId)?.profile ??
            conversation?.peer ??
            null
          }
          x={profilePopover.x}
          y={profilePopover.y}
          onClose={() => setProfilePopover(null)}
        />
      )}

      <PinnedMessagesPanel
        open={pinnedPanelOpen}
        pins={pins}
        onClose={() => setPinnedPanelOpen(false)}
        onJump={(messageId) => {
          // Close the panel first so the underlying message-list viewport
          // is fully visible before the smooth-scroll runs (otherwise the
          // panel would briefly cover the highlighted target row).
          setPinnedPanelOpen(false);
          jumpToMessage(messageId);
        }}
        onUnpin={(messageId) => void handleTogglePin(messageId)}
      />

      {annotatingIndex !== null && attachments[annotatingIndex] && (
        <Suspense fallback={null}>
          <ImageAnnotator
            file={attachments[annotatingIndex]!.file}
            onCancel={() => setAnnotatingIndex(null)}
            onSave={(next) => {
              // Preserve the per-attachment viewOnce flag across annotation —
              // the user's burn-after-viewing intent shouldn't reset just
              // because they redrew the image.
              setAttachments((prev) =>
                prev.map((a, i) =>
                  i === annotatingIndex ? { file: next, viewOnce: a.viewOnce } : a,
                ),
              );
              setAnnotatingIndex(null);
            }}
          />
        </Suspense>
      )}

      {openWhiteboardId && (
        <Suspense fallback={null}>
          <WhiteboardModal
            whiteboardId={openWhiteboardId}
            onClose={() => setOpenWhiteboardId(null)}
          />
        </Suspense>
      )}

      {openWatchSessionId && (
        <Suspense fallback={null}>
          <WatchTogetherModal
            sessionId={openWatchSessionId}
            onClose={() => setOpenWatchSessionId(null)}
          />
        </Suspense>
      )}

      {openGameId && (
        <Suspense fallback={null}>
          <GameModal
            gameId={openGameId}
            onClose={() => setOpenGameId(null)}
          />
        </Suspense>
      )}

      {gameDialogOpen && (
        <div
          role="dialog"
          aria-modal="true"
          className="fixed inset-0 z-[70] flex items-center justify-center bg-ink-950/90 p-6"
          onClick={(e) => {
            if (e.target === e.currentTarget) setGameDialogOpen(false);
          }}
        >
          <div className="w-full max-w-sm rounded-2xl border border-line bg-surface-2 p-5 shadow-xl">
            <h2 className="mb-3 font-display text-lg font-semibold text-fg">
              {t('app:game.pick_title', { defaultValue: 'Spiel auswählen' })}
            </h2>
            {gameError && (
              <p className="mb-2 text-xs text-rose-400">{gameError}</p>
            )}
            <div className="grid grid-cols-2 gap-3">
              <button
                type="button"
                onClick={() => void handleStartGame('ttt')}
                disabled={gameCreating}
                className="flex flex-col items-center gap-2 rounded-xl border border-line bg-surface-3 p-4 text-fg transition hover:border-accent/40 hover:bg-surface disabled:cursor-not-allowed disabled:opacity-50"
              >
                <span className="text-3xl">×○</span>
                <span className="text-xs font-semibold">Tic-Tac-Toe</span>
              </button>
              <button
                type="button"
                onClick={() => void handleStartGame('c4')}
                disabled={gameCreating}
                className="flex flex-col items-center gap-2 rounded-xl border border-line bg-surface-3 p-4 text-fg transition hover:border-accent/40 hover:bg-surface disabled:cursor-not-allowed disabled:opacity-50"
              >
                <span className="text-3xl">🔴🟡</span>
                <span className="text-xs font-semibold">Vier-Gewinnt</span>
              </button>
            </div>
            <div className="mt-4 flex justify-end">
              <button
                type="button"
                onClick={() => { setGameDialogOpen(false); setGameError(null); }}
                className="cursor-pointer rounded-md border border-line bg-surface-3 px-3 py-1.5 text-xs font-medium text-fg-muted transition hover:bg-surface"
              >
                {t('app:game.cancel', { defaultValue: 'Abbrechen' })}
              </button>
            </div>
          </div>
        </div>
      )}

      {watchDialogOpen && (
        <div
          role="dialog"
          aria-modal="true"
          className="fixed inset-0 z-[70] flex items-center justify-center bg-ink-950/90 p-6"
          onClick={(e) => {
            if (e.target === e.currentTarget) setWatchDialogOpen(false);
          }}
        >
          <div className="w-full max-w-md rounded-2xl border border-line bg-surface-2 p-5 shadow-xl">
            <h2 className="mb-3 font-display text-lg font-semibold text-fg">
              {t('app:watch.dialog_title', { defaultValue: 'Watch Together starten' })}
            </h2>
            <input
              type="url"
              placeholder="https://youtu.be/..."
              value={watchUrl}
              onChange={(e) => { setWatchUrl(e.target.value); setWatchError(null); }}
              className="mb-2 w-full rounded-md border border-line bg-surface-3 px-3 py-2 text-sm text-fg placeholder-fg-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/40"
            />
            {watchError && (
              <p className="mb-2 text-xs text-rose-400">{watchError}</p>
            )}
            <div className="mt-3 flex items-center justify-end gap-2">
              <button
                type="button"
                onClick={() => { setWatchDialogOpen(false); setWatchUrl(''); setWatchError(null); }}
                className="cursor-pointer rounded-md border border-line bg-surface-3 px-3 py-1.5 text-xs font-medium text-fg-muted transition hover:bg-surface"
              >
                {t('app:watch.cancel', { defaultValue: 'Abbrechen' })}
              </button>
              <button
                type="button"
                onClick={() => void handleStartWatchTogether()}
                disabled={watchCreating || !watchUrl.trim()}
                className="cursor-pointer rounded-md bg-accent px-4 py-1.5 text-xs font-semibold text-accent-fg transition hover:bg-accent/90 disabled:cursor-not-allowed disabled:opacity-50"
              >
                {watchCreating
                  ? t('app:watch.starting', { defaultValue: 'Startet…' })
                  : t('app:watch.start', { defaultValue: 'Starten' })}
              </button>
            </div>
          </div>
        </div>
      )}
    </div>
  );
}

interface SearchBarProps {
  query: string;
  onQueryChange: (q: string) => void;
  matches: number;
  activeIdx: number;
  senderId: string;
  onSenderChange: (id: string) => void;
  attachmentsOnly: boolean;
  onAttachmentsOnlyChange: (v: boolean) => void;
  dateFrom: string;
  onDateFromChange: (v: string) => void;
  dateTo: string;
  onDateToChange: (v: string) => void;
  members: { userId: string; profile: { displayName?: string | null } | null }[];
  onPrev: () => void;
  onNext: () => void;
  onClose: () => void;
}

function SearchBar({
  query,
  onQueryChange,
  matches,
  activeIdx,
  senderId,
  onSenderChange,
  attachmentsOnly,
  onAttachmentsOnlyChange,
  dateFrom,
  onDateFromChange,
  dateTo,
  onDateToChange,
  members,
  onPrev,
  onNext,
  onClose,
}: SearchBarProps) {
  const { t } = useTranslation(['app']);
  return (
    <div className="discord-chat-panel flex flex-col gap-2 border-b border-line bg-surface-2 px-4 py-2">
      <div className="flex items-center gap-2">
        <SearchIcon className="h-4 w-4 shrink-0 text-fg-muted" />
        <input
          type="search"
          autoFocus
          value={query}
          onChange={(e) => onQueryChange(e.target.value)}
          onKeyDown={(e) => {
            if (e.key === 'Escape') onClose();
            if (e.key === 'Enter') {
              e.preventDefault();
              if (e.shiftKey) onPrev();
              else onNext();
            }
          }}
          placeholder={t('app:chats.search_in_conv', { defaultValue: 'In Unterhaltung suchen…' })}
          className="min-w-0 flex-1 bg-transparent text-sm text-fg placeholder-fg-muted outline-none"
        />
        <span className="shrink-0 text-xs tabular-nums text-fg-muted">
          {matches === 0
            ? query.trim().length > 0
              ? t('app:chats.search_none', { defaultValue: 'Keine Treffer' })
              : ''
            : activeIdx + 1 + ' / ' + matches}
        </span>
        <button
          type="button"
          onClick={onPrev}
          disabled={matches === 0}
          aria-label="Previous"
          className="cursor-pointer rounded-md p-1 text-fg-muted transition hover:bg-surface-3 hover:text-fg disabled:cursor-not-allowed disabled:opacity-40 dark:hover:bg-[#383a40]"
        >
          <ChevronUpIcon className="h-4 w-4" />
        </button>
        <button
          type="button"
          onClick={onNext}
          disabled={matches === 0}
          aria-label="Next"
          className="cursor-pointer rounded-md p-1 text-fg-muted transition hover:bg-surface-3 hover:text-fg disabled:cursor-not-allowed disabled:opacity-40 dark:hover:bg-[#383a40]"
        >
          <ChevronDownIcon className="h-4 w-4" />
        </button>
        <button
          type="button"
          onClick={onClose}
          aria-label="Close"
          className="cursor-pointer rounded-md p-1 text-fg-muted transition hover:bg-surface-3 hover:text-fg dark:hover:bg-[#383a40]"
        >
          <XIcon className="h-4 w-4" />
        </button>
      </div>
      <div className="flex flex-wrap items-center gap-2 text-xs text-fg-muted">
        <select
          value={senderId}
          onChange={(e) => onSenderChange(e.target.value)}
          className="cursor-pointer rounded-md border border-line bg-surface-3 px-2 py-1 text-fg outline-none focus:border-accent dark:bg-[#383a40]"
        >
          <option value="">Alle Sender</option>
          {members.map((m) => (
            <option key={m.userId} value={m.userId}>
              {m.profile?.displayName ?? m.userId.slice(0, 6)}
            </option>
          ))}
        </select>
        <label className="flex cursor-pointer items-center gap-1 rounded-md border border-line bg-surface-3 px-2 py-1 text-fg dark:bg-[#383a40]">
          <input
            type="checkbox"
            checked={attachmentsOnly}
            onChange={(e) => onAttachmentsOnlyChange(e.target.checked)}
            className="accent-accent"
          />
          <span>Nur Anhänge</span>
        </label>
        <label className="flex items-center gap-1">
          <span>Von</span>
          <input
            type="date"
            value={dateFrom}
            onChange={(e) => onDateFromChange(e.target.value)}
            className="rounded-md border border-line bg-surface-3 px-2 py-1 text-fg outline-none focus:border-accent dark:bg-[#383a40]"
          />
        </label>
        <label className="flex items-center gap-1">
          <span>Bis</span>
          <input
            type="date"
            value={dateTo}
            onChange={(e) => onDateToChange(e.target.value)}
            className="rounded-md border border-line bg-surface-3 px-2 py-1 text-fg outline-none focus:border-accent dark:bg-[#383a40]"
          />
        </label>
      </div>
    </div>
  );
}

function computeDeliveryState(args: {
  messageId: string;
  isGroup: boolean;
  recipientCount: number;
  peerReadSet: Set<string>;
  peerDeliveredSet: Set<string>;
  groupRead: Map<string, Set<string>>;
  groupDelivered: Map<string, Set<string>>;
}): 'sent' | 'delivered' | 'read' {
  if (!args.isGroup) {
    if (args.peerReadSet.has(args.messageId)) return 'read';
    if (args.peerDeliveredSet.has(args.messageId)) return 'delivered';
    return 'sent';
  }
  if (args.recipientCount === 0) return 'sent';
  const reads = args.groupRead.get(args.messageId);
  if (reads && reads.size >= args.recipientCount) return 'read';
  const delivered = args.groupDelivered.get(args.messageId);
  if (delivered && delivered.size >= args.recipientCount) return 'delivered';
  return 'sent';
}

function PendingBubble({
  item,
  onRetry,
  onCancel,
}: {
  item: OutboxItem;
  onRetry: () => void;
  onCancel: () => void;
}) {
  const failed = item.attempts >= 8;
  return (
    <div className="flex justify-end py-0.5">
      <div
        className={
          'max-w-[72%] rounded-[18px] px-3.5 py-2 text-sm ' +
          (failed
            ? 'border border-rose-500/40 bg-rose-500/10 text-rose-700 dark:text-rose-100'
            : 'border border-dashed border-accent/50 bg-accent/10 text-fg opacity-80')
        }
      >
        <p className="whitespace-pre-wrap break-words">{item.text}</p>
        <div className="mt-1 flex items-center justify-end gap-2 text-[10px] uppercase tracking-wider text-fg-muted">
          {failed ? (
            <>
              <span>{item.lastError ?? 'Senden fehlgeschlagen'}</span>
              <button
                type="button"
                onClick={onRetry}
                className="cursor-pointer rounded px-1.5 py-0.5 font-semibold text-accent hover:underline"
              >
                Erneut
              </button>
              <button
                type="button"
                onClick={onCancel}
                className="cursor-pointer rounded px-1.5 py-0.5 font-semibold text-rose-600 hover:underline dark:text-rose-300"
              >
                Verwerfen
              </button>
            </>
          ) : (
            <>
              <SpinnerIcon className="h-3 w-3 animate-spin" />
              <span>{item.attempts === 0 ? 'Senden…' : 'Wiederhole (' + item.attempts + ')'}</span>
            </>
          )}
        </div>
      </div>
    </div>
  );
}

function Banner({ children }: { children: React.ReactNode }) {
  return (
    <div
      role="alert"
      className="flex items-start gap-3 rounded-lg border border-rose-500/30 bg-rose-500/10 p-3 text-sm text-rose-700 dark:text-rose-100"
    >
      <AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-500 dark:text-rose-400" />
      <p className="min-w-0 flex-1 break-words">{children}</p>
    </div>
  );
}

function AttachmentPreview({
  file,
  viewOnce,
  onRemove,
  onEdit,
  onToggleViewOnce,
}: {
  file: File;
  viewOnce: boolean;
  onRemove: () => void;
  onEdit?: () => void;
  onToggleViewOnce?: () => void;
}) {
  const { t } = useTranslation(['app']);
  const isImage = file.type.startsWith('image/');
  const [url, setUrl] = useState<string | null>(null);
  useEffect(() => {
    if (!isImage) return;
    const u = URL.createObjectURL(file);
    setUrl(u);
    return () => URL.revokeObjectURL(u);
  }, [file, isImage]);
  return (
    <div className="group relative overflow-hidden rounded-lg border border-line bg-surface-2 dark:bg-[#2b2d31]">
      {isImage && url ? (
        <img src={url} alt={file.name} className="block h-20 w-20 object-cover" />
      ) : (
        <div className="flex h-20 w-32 flex-col justify-center gap-0.5 px-2 text-[10px]">
          <span className="truncate font-semibold text-fg" title={file.name}>
            {file.name || 'Datei'}
          </span>
          <span className="text-fg-muted">{file.type || 'unbekannt'}</span>
          <span className="text-fg-muted">{(file.size / 1024).toFixed(0)} KB</span>
        </div>
      )}
      {isImage && onEdit && (
        <button
          type="button"
          onClick={onEdit}
          aria-label="Bearbeiten"
          title="Bearbeiten"
          className="absolute bottom-1 left-1 flex h-5 w-5 cursor-pointer items-center justify-center rounded-full bg-black/70 text-white opacity-0 transition group-hover:opacity-100 hover:bg-accent/80"
        >
          <PencilIcon className="h-3 w-3" />
        </button>
      )}
      {isImage && onToggleViewOnce && (
        <button
          type="button"
          onClick={onToggleViewOnce}
          aria-label={
            viewOnce
              ? t('app:composer.view_once_off', { defaultValue: 'Einmal-Ansicht deaktivieren' })
              : t('app:composer.view_once_on', { defaultValue: 'Einmal-Ansicht aktivieren' })
          }
          title={
            viewOnce
              ? t('app:composer.view_once_on_hint', {
                  defaultValue: 'Empfänger sieht das Bild nur einmal',
                })
              : t('app:composer.view_once_off_hint', { defaultValue: 'Einmal-Ansicht ein/aus' })
          }
          className={
            'absolute bottom-1 right-1 flex h-5 w-5 cursor-pointer items-center justify-center rounded-full transition ' +
            (viewOnce
              ? 'bg-accent text-accent-fg opacity-100'
              : 'bg-black/70 text-white opacity-0 hover:bg-accent/80 group-hover:opacity-100')
          }
        >
          {viewOnce ? <EyeIcon className="h-3 w-3" /> : <EyeOffIcon className="h-3 w-3" />}
        </button>
      )}
      {/* When viewOnce is on, overlay a persistent "1×" badge so the user
          has visual confirmation independent of the small toggle button. */}
      {isImage && viewOnce && (
        <div
          aria-hidden="true"
          className="pointer-events-none absolute bottom-1 right-7 rounded-md bg-accent/90 px-1 py-0.5 text-[9px] font-bold text-accent-fg"
        >
          1×
        </div>
      )}
      <button
        type="button"
        onClick={onRemove}
        aria-label="Entfernen"
        className="absolute right-1 top-1 flex h-5 w-5 cursor-pointer items-center justify-center rounded-full bg-black/70 text-white transition hover:bg-rose-500/80"
      >
        <XIcon className="h-3 w-3" />
      </button>
    </div>
  );
}

