import { useConvex, useMutation, useQuery } from 'convex/react';
import {
	ArrowUp,
	ChartBar,
	Gif,
	ImageSquare,
	Paperclip,
	PlusCircle,
	Smiley,
	Sticker,
	X,
} from '@phosphor-icons/react';
import { useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react';
import { createPortal } from 'react-dom';
import { api } from '../../../../../convex/_generated/api';
import { usePlatform } from '../../platform';
import { useIsMobile } from '../../hooks/useIsMobile';
import { getTwemojiUrl } from '../../utils/twemoji';
import { EmojiPicker, type EmojiPickerValue } from './EmojiPicker';
import { MobileExpressionPickerSheet } from './MobileExpressionPickerSheet';
import { CreatePollModal } from './CreatePollModal';
import {
	MentionAutocomplete,
	type MentionAutocompleteHandle,
	type MentionItem,
} from './MentionAutocomplete';
import {
	PendingAttachmentRow,
	type PendingAttachment,
} from './PendingAttachmentRow';
import { Tooltip } from '@discord-clone/ui';
import { useKeybinds } from '../../contexts/KeybindContext';
import styles from './ChannelTextarea.module.css';

interface ReplyState {
	eventId: string;
	username: string;
}

interface ChannelTextareaProps {
	channelId: string;
	replyTo?: ReplyState | null;
	onCancelReply?: () => void;
	editingEventId?: string;
	editingContent?: string;
	onCancelEdit?: () => void;
}

/**
 * Composer: encrypts plaintext with the channel key and sends via
 * api.messages.send. Structure mirrors the original contenteditable-based
 * composer so the CSS module classes (.outer / .mainWrapper / .uploadColumn
 * / .contentArea / .buttonContainer / .sendColumn) resolve correctly.
 *
 * Omitted from the original: autocomplete, rich formatting, file upload,
 * emoji picker, GIF picker, stickers, saved media. The buttons render
 * visually but do nothing yet.
 */
export function ChannelTextarea({
	channelId,
	replyTo,
	onCancelReply,
	editingEventId,
	onCancelEdit,
}: ChannelTextareaProps) {
	const { crypto } = usePlatform();
	const isMobile = useIsMobile();
	const convex = useConvex();
	void convex;
	const editorRef = useRef<HTMLDivElement>(null);
	const emojiButtonRef = useRef<HTMLButtonElement>(null);
	const plusButtonRef = useRef<HTMLButtonElement>(null);
	const fileInputRef = useRef<HTMLInputElement>(null);
	// Wraps all four expression-trigger buttons (GIFs / Media /
	// Stickers / Emoji) so the document-level outside-click handler
	// can ignore clicks that land on any of them — the buttons
	// already toggle the picker themselves and we don't want the
	// outside-click to race with the toggle.
	const expressionButtonsRef = useRef<HTMLDivElement>(null);
	const pickerWrapperRef = useRef<HTMLDivElement>(null);
	const [isEmpty, setIsEmpty] = useState(true);
	// channelKey holds the LATEST key for this channel + its version.
	// We only ever send new messages under the latest version; for
	// decrypting existing messages Messages.tsx has its own version
	// lookup table.
	const [channelKey, setChannelKey] = useState<string | null>(null);
	const [channelKeyVersion, setChannelKeyVersion] = useState<number>(1);
	const [isUploading, setIsUploading] = useState(false);
	const [pendingAttachments, setPendingAttachments] = useState<PendingAttachment[]>([]);
	const [showPlusMenu, setShowPlusMenu] = useState(false);
	const [showCreatePoll, setShowCreatePoll] = useState(false);
	const [showEmojiPicker, setShowEmojiPicker] = useState(false);
	// `pickerInitialTab` is the source-of-truth for which tab the
	// picker should display — both as the initial tab on open and
	// for imperative tab swaps while it's open. The picker syncs
	// to changes via a useEffect, and reports its own internal tab
	// switches back through `onTabChange` so the composer toggle
	// behaviour stays in sync with what the user actually sees.
	const [pickerInitialTab, setPickerInitialTab] = useState<
		'emojis' | 'gifs' | 'media' | 'stickers'
	>('emojis');
	const [pickerPos, setPickerPos] = useState<{ bottom: number; right: number }>({
		bottom: 80,
		right: 20,
	});

	// Mention autocomplete state. `mentionQuery` is null when the
	// popup is closed; a string (possibly empty) when it's open and
	// tracking the characters the user typed after `@`.
	const [mentionQuery, setMentionQuery] = useState<string | null>(null);
	const mentionRef = useRef<MentionAutocompleteHandle>(null);

	const userId = typeof localStorage !== 'undefined' ? localStorage.getItem('userId') : null;
	const keybinds = useKeybinds();
	const username =
		typeof localStorage !== 'undefined' ? localStorage.getItem('username') : null;
	const privateKeyPem =
		typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('privateKey') : null;
	const signingKey =
		typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('signingKey') : null;

	const startTyping = useMutation(api.typing.startTyping);
	const stopTyping = useMutation(api.typing.stopTyping);
	const typingHeartbeatRef = useRef<number>(0);

	const channel = useQuery(api.channels.get, channelId ? { id: channelId as any } : 'skip');
	const allKeys = useQuery(
		api.channelKeys.getKeysForUser,
		userId ? { userId: userId as any } : 'skip',
	);
	const keyBundle = allKeys?.find((k) => k.channel_id === channelId) ?? null;

	const sendMessage = useMutation(api.messages.send);
	const generateUploadUrl = useMutation(api.files.generateUploadUrl);
	const validateUpload = useMutation(api.files.validateUpload);

	// Walk every bundle we have, decrypt the ones that carry a key
	// for the currently-open channel, and pick the highest-version
	// entry. That's the key new messages get sent under. The version
	// number rides along with each outgoing message so the receiver
	// knows which key to decrypt with.
	useEffect(() => {
		let cancelled = false;
		if (!allKeys || !privateKeyPem) {
			setChannelKey(null);
			setChannelKeyVersion(1);
			return;
		}
		(async () => {
			let bestVersion = -Infinity;
			let bestKey: string | null = null;
			for (const item of allKeys) {
				try {
					const bundleJson = await crypto.privateDecrypt(
						privateKeyPem,
						item.encrypted_key_bundle,
					);
					const parsed = JSON.parse(bundleJson) as Record<string, string>;
					const keyForThisChannel = parsed[channelId];
					if (!keyForThisChannel) continue;
					const version = item.key_version ?? 1;
					if (version > bestVersion) {
						bestVersion = version;
						bestKey = keyForThisChannel;
					}
				} catch (err) {
					console.error(
						`Failed to decrypt key bundle for ${item.channel_id}`,
						err,
					);
				}
			}
			if (cancelled) return;
			setChannelKey(bestKey);
			setChannelKeyVersion(bestVersion === -Infinity ? 1 : bestVersion);
		})();
		return () => {
			cancelled = true;
		};
	}, [allKeys, privateKeyPem, channelId]);

	// Reset the composer when channel changes
	useEffect(() => {
		if (editorRef.current) editorRef.current.textContent = '';
		setIsEmpty(true);
		setMentionQuery(null);
		typingHeartbeatRef.current = 0;
	}, [channelId]);

	// Listen for saved-media reposts dispatched from the Media tab in
	// EmojiPicker. The detail carries the same shape we'd embed in
	// the message body, so we just forward it through the existing
	// `sendAttachmentMessage` path.
	useEffect(() => {
		const onRepost = (e: Event) => {
			const detail = (e as CustomEvent<{
				url: string;
				filename: string;
				mimeType?: string;
				width?: number;
				height?: number;
				size?: number;
				encryptionKey: string;
				encryptionIv: string;
			}>).detail;
			if (!detail?.url || !detail?.encryptionKey) return;
			void sendAttachmentMessage({
				type: 'attachment',
				url: detail.url,
				filename: detail.filename,
				mimeType: detail.mimeType ?? 'application/octet-stream',
				size: detail.size ?? 0,
				key: detail.encryptionKey,
				iv: detail.encryptionIv,
				width: detail.width,
				height: detail.height,
			});
		};
		window.addEventListener('brycord:repost-saved-media', onRepost);
		return () =>
			window.removeEventListener('brycord:repost-saved-media', onRepost);
		// eslint-disable-next-line react-hooks/exhaustive-deps
	}, [channelKey, signingKey, userId, channelId, replyTo]);

	// Composer-level keybinds: open the emoji / gif picker and focus the
	// editor. Dispatched by KeybindProvider as window CustomEvents.
	useEffect(() => {
		const positionPicker = () => {
			const btn = emojiButtonRef.current;
			if (!btn) return;
			const rect = btn.getBoundingClientRect();
			setPickerPos({
				bottom: window.innerHeight - rect.top + 8,
				right: window.innerWidth - rect.right,
			});
		};
		const openEmoji = () => {
			positionPicker();
			setPickerInitialTab('emojis');
			setShowEmojiPicker((v) => !v);
		};
		const openGif = () => {
			positionPicker();
			setPickerInitialTab('gifs');
			setShowEmojiPicker(true);
		};
		const focusComposer = () => editorRef.current?.focus();
		window.addEventListener('brycord:keybind:popouts.openEmojiPicker', openEmoji);
		window.addEventListener('brycord:keybind:popouts.openGifPicker', openGif);
		window.addEventListener(
			'brycord:keybind:messaging.focusComposer',
			focusComposer,
		);
		return () => {
			window.removeEventListener(
				'brycord:keybind:popouts.openEmojiPicker',
				openEmoji,
			);
			window.removeEventListener(
				'brycord:keybind:popouts.openGifPicker',
				openGif,
			);
			window.removeEventListener(
				'brycord:keybind:messaging.focusComposer',
				focusComposer,
			);
		};
	}, []);

	const handleInput = () => {
		const editor = editorRef.current;
		const text = editor ? serializeEditor(editor) : '';
		setIsEmpty(text.trim().length === 0);

		// Typing heartbeat — refresh the server TTL at most once every 3s.
		if (userId && username && channelId && text.trim().length > 0) {
			const now = Date.now();
			if (now - typingHeartbeatRef.current > 3000) {
				typingHeartbeatRef.current = now;
				void startTyping({
					channelId: channelId as any,
					userId: userId as any,
					username,
				});
			}
		}

		// Detect `@query` at the cursor. We look at the text up to the
		// current caret position and match a trailing `@` token that
		// isn't glued to another word (must be start-of-text or after
		// whitespace). Matches `@`, `@f`, `@foo`, etc.
		const beforeCaret = getTextBeforeCaret(editor);
		const match = beforeCaret.match(/(?:^|\s)@([^\s@]*)$/);
		if (match) {
			setMentionQuery(match[1] ?? '');
		} else {
			setMentionQuery(null);
		}
	};

	// Walk the editor's DOM tree and produce the plain-text string we
	// actually send. <img data-emoji> → the unicode char; <img
	// data-custom-emoji> → `:shortcode:`; <br> → newline; text nodes
	// pass through (with zero-width spaces stripped).
	const serializeEditor = (editor: HTMLElement): string => {
		let out = '';
		const walk = (node: Node) => {
			if (node.nodeType === Node.TEXT_NODE) {
				out += (node.textContent ?? '').replace(/\u200B/g, '');
				return;
			}
			if (node.nodeType === Node.ELEMENT_NODE) {
				const el = node as HTMLElement;
				if (el.tagName === 'IMG') {
					if (el.dataset.customEmoji === 'true' && el.dataset.shortcode) {
						out += `:${el.dataset.shortcode}:`;
					} else if (el.dataset.emoji) {
						out += el.dataset.emoji;
					}
					return;
				}
				if (el.tagName === 'BR') {
					out += '\n';
					return;
				}
				for (const child of Array.from(el.childNodes)) walk(child);
			}
		};
		for (const child of Array.from(editor.childNodes)) walk(child);
		return out;
	};

	// Read the text from the start of the contenteditable up to the
	// current caret position. Used to drive the `@mention` matcher.
	const getTextBeforeCaret = (editor: HTMLElement | null): string => {
		if (!editor) return '';
		const selection = typeof window !== 'undefined' ? window.getSelection() : null;
		if (!selection || selection.rangeCount === 0) return editor.textContent ?? '';
		const range = selection.getRangeAt(0);
		if (!editor.contains(range.endContainer)) return editor.textContent ?? '';
		const pre = range.cloneRange();
		pre.selectNodeContents(editor);
		pre.setEnd(range.endContainer, range.endOffset);
		return pre.toString();
	};

	// Commit the mention: replace the trailing `@query` in the
	// contenteditable with `@DisplayName ` plain text. Plain-text only
	// for this pass — no mention pills.
	const handleMentionSelect = (item: MentionItem) => {
		const editor = editorRef.current;
		if (!editor) return;
		const label =
			item.kind === 'everyone' ? '@everyone ' : `@${item.displayName} `;
		const full = editor.textContent ?? '';
		// Find the last `@…` token — same heuristic as the matcher.
		const m = full.match(/(?:^|\s)@([^\s@]*)$/);
		if (!m) {
			editor.textContent = full + label;
		} else {
			const start = full.length - (m[0].startsWith('@') ? m[0].length : m[0].length - 1);
			const head = full.slice(0, start);
			editor.textContent = head + label;
		}
		// Restore focus and move caret to the end.
		editor.focus();
		const selection = window.getSelection();
		if (selection) {
			const range = document.createRange();
			range.selectNodeContents(editor);
			range.collapse(false);
			selection.removeAllRanges();
			selection.addRange(range);
		}
		setMentionQuery(null);
		setIsEmpty((editor.textContent ?? '').trim().length === 0);
	};

	const doSend = async () => {
		const text = editorRef.current ? serializeEditor(editorRef.current).trim() : '';
		const hasAttachments = pendingAttachments.length > 0;
		if (!hasAttachments && !text) return;
		if (!channelKey || !signingKey || !userId) return;

		// Snapshot staged attachments so the state can clear immediately
		// after send. The upload loop uses this local copy.
		const stagedFiles = pendingAttachments.map((a) => a.file);
		const stagedPreviews = pendingAttachments
			.map((a) => a.previewUrl)
			.filter((u): u is string => !!u);

		try {
			// 1. Text message first (if any). Matches the old client's
			//    send-then-attach order so the reply context lands on
			//    the text message.
			if (text) {
				const { content, iv, tag } = await crypto.encryptData(text, channelKey);
				const ciphertext = content + tag;
				const signature = await crypto.signMessage(signingKey, ciphertext);
				await sendMessage({
					channelId: channelId as any,
					senderId: userId as any,
					ciphertext,
					nonce: iv,
					signature,
					keyVersion: channelKeyVersion,
					replyTo: replyTo ? (replyTo.eventId as any) : undefined,
				});
			}

			// 2. One message per attachment. Clear the staging row up
			//    front so the cards disappear even if a later upload
			//    fails — failed uploads are logged to the console.
			if (hasAttachments) {
				setPendingAttachments([]);
				setIsUploading(true);
				try {
					for (const file of stagedFiles) {
						try {
							await uploadOneFile(file);
						} catch (err) {
							console.error('Failed to upload file:', err);
						}
					}
				} finally {
					setIsUploading(false);
					// Revoke the preview blob URLs now that the cards
					// are gone — keeping them leaks memory.
					for (const url of stagedPreviews) URL.revokeObjectURL(url);
				}
			}

			if (editorRef.current) editorRef.current.textContent = '';
			setIsEmpty(true);
			setMentionQuery(null);
			if (userId && channelId) {
				void stopTyping({
					channelId: channelId as any,
					userId: userId as any,
				});
				typingHeartbeatRef.current = 0;
			}
			onCancelReply?.();
		} catch (err) {
			console.error('Failed to send message:', err);
		}
	};

	// Send a plain text message (used by GIF picks — the URL travels
	// through the channel-key encryption like any other text and the
	// receiver's MessageContent + LinkEmbed renders the GIF inline).
	const sendPlainTextMessage = async (text: string) => {
		if (!channelKey || !signingKey || !userId) return;
		const payload = JSON.stringify({ text });
		const { content, iv, tag } = await crypto.encryptData(payload, channelKey);
		const ciphertext = content + tag;
		const signature = await crypto.signMessage(signingKey, ciphertext);
		await sendMessage({
			channelId: channelId as any,
			senderId: userId as any,
			ciphertext,
			nonce: iv,
			signature,
			keyVersion: channelKeyVersion,
			replyTo: replyTo ? (replyTo.eventId as any) : undefined,
		});
	};

	// Send a message whose content is a single attachment metadata JSON.
	// Matches the original Brycord on-the-wire format — the decrypt side
	// recognises `{ type: 'attachment', url, key, iv, ... }` as a plain
	// object and renders it via <EncryptedAttachment>.
	const sendAttachmentMessage = async (metadata: Record<string, unknown>) => {
		if (!channelKey || !signingKey || !userId) return;
		const payload = JSON.stringify(metadata);
		const { content, iv, tag } = await crypto.encryptData(payload, channelKey);
		const ciphertext = content + tag;
		const signature = await crypto.signMessage(signingKey, ciphertext);
		await sendMessage({
			channelId: channelId as any,
			senderId: userId as any,
			ciphertext,
			nonce: iv,
			signature,
			keyVersion: keyBundle?.key_version ?? 1,
			replyTo: replyTo ? (replyTo.eventId as any) : undefined,
		});
	};

	const getMediaDimensions = (
		file: File,
	): Promise<{ width: number; height: number } | null> => {
		return new Promise((resolve) => {
			if (file.type.startsWith('image/')) {
				const img = new Image();
				const objectUrl = URL.createObjectURL(file);
				img.onload = () => {
					URL.revokeObjectURL(objectUrl);
					resolve({ width: img.naturalWidth, height: img.naturalHeight });
				};
				img.onerror = () => {
					URL.revokeObjectURL(objectUrl);
					resolve(null);
				};
				img.src = objectUrl;
			} else if (file.type.startsWith('video/')) {
				const video = document.createElement('video');
				const objectUrl = URL.createObjectURL(file);
				video.onloadedmetadata = () => {
					URL.revokeObjectURL(objectUrl);
					resolve({ width: video.videoWidth, height: video.videoHeight });
				};
				video.onerror = () => {
					URL.revokeObjectURL(objectUrl);
					resolve(null);
				};
				video.src = objectUrl;
			} else {
				resolve(null);
			}
		});
	};

	const fromHexString = (hex: string): Uint8Array => {
		const matches = hex.match(/.{1,2}/g) ?? [];
		return new Uint8Array(matches.map((b) => parseInt(b, 16)));
	};

	const uploadOneFile = async (file: File) => {
		// 1. Encrypt the file with a fresh per-file AES key.
		const fileKey = await crypto.randomBytes(32);
		const buf = new Uint8Array(await file.arrayBuffer());
		const encrypted = await crypto.encryptData(buf, fileKey);
		const encryptedHex = encrypted.content + encrypted.tag;
		const encryptedBytes = fromHexString(encryptedHex);

		// 2. Upload the ciphertext as an opaque blob.
		const blob = new Blob([encryptedBytes], { type: 'application/octet-stream' });
		const uploadUrl = await generateUploadUrl();
		const res = await fetch(uploadUrl, {
			method: 'POST',
			headers: { 'Content-Type': blob.type },
			body: blob,
		});
		if (!res.ok) throw new Error(`Upload failed: ${res.status}`);
		const { storageId } = (await res.json()) as { storageId: string };

		// 3. Validate — server checks size cap + returns the permanent URL.
		const fileUrl = await validateUpload({ storageId: storageId as any });
		if (!fileUrl) throw new Error('Failed to resolve file URL');

		// 4. Collect media dimensions for preview sizing.
		const dims = await getMediaDimensions(file);

		// 5. Send the message with the attachment metadata.
		const metadata: Record<string, unknown> = {
			type: 'attachment',
			url: fileUrl,
			filename: file.name,
			mimeType: file.type || 'application/octet-stream',
			size: file.size,
			key: fileKey,
			iv: encrypted.iv,
			...(dims && { width: dims.width, height: dims.height }),
		};
		await sendAttachmentMessage(metadata);
	};

	// Stage files as pending attachments above the composer instead of
	// uploading immediately. The actual encrypt+upload runs in doSend()
	// when the user presses Send.
	const stageFiles = (files: File[]) => {
		if (files.length === 0) return;
		const staged: PendingAttachment[] = files.map((file) => {
			const kind = file.type.split('/')[0];
			const previewUrl =
				kind === 'image' || kind === 'video' ? URL.createObjectURL(file) : null;
			return {
				id: `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
				file,
				previewUrl,
			};
		});
		setPendingAttachments((prev) => [...prev, ...staged]);
	};

	const removePendingAttachment = (id: string) => {
		setPendingAttachments((prev) => {
			const victim = prev.find((a) => a.id === id);
			if (victim?.previewUrl) URL.revokeObjectURL(victim.previewUrl);
			return prev.filter((a) => a.id !== id);
		});
	};

	const handleFilesSelected = (e: React.ChangeEvent<HTMLInputElement>) => {
		const files = Array.from(e.target.files ?? []);
		e.target.value = '';
		stageFiles(files);
	};

	// Listen for drag-and-drop file events from ChannelView's FileDropZone.
	useEffect(() => {
		const handler = (e: Event) => {
			const detail = (e as CustomEvent<{ channelId: string; files: File[] }>).detail;
			if (!detail || detail.channelId !== channelId) return;
			stageFiles(detail.files);
		};
		window.addEventListener('brycord:drop-files', handler);
		return () => window.removeEventListener('brycord:drop-files', handler);
	}, [channelId]);

	// Revoke any preview URLs still around when the channel switches or
	// the component unmounts — the cards are channel-scoped and don't
	// survive navigation.
	useEffect(() => {
		return () => {
			for (const att of pendingAttachments) {
				if (att.previewUrl) URL.revokeObjectURL(att.previewUrl);
			}
		};
		// eslint-disable-next-line react-hooks/exhaustive-deps
	}, [channelId]);

	const handleKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
		// Route arrow keys + Enter + Escape to the mention popup when
		// it's open. This way the textarea's own keybindings stay out
		// of the way while the user is picking a mention.
		if (mentionQuery !== null && mentionRef.current) {
			if (e.key === 'ArrowDown') {
				if (mentionRef.current.moveSelection(1)) {
					e.preventDefault();
					return;
				}
			} else if (e.key === 'ArrowUp') {
				if (mentionRef.current.moveSelection(-1)) {
					e.preventDefault();
					return;
				}
			} else if (e.key === 'Enter' || e.key === 'Tab') {
				if (mentionRef.current.commit()) {
					e.preventDefault();
					return;
				}
			} else if (e.key === 'Escape') {
				setMentionQuery(null);
				e.preventDefault();
				return;
			}
		}

		if (e.key === 'Enter' && !e.shiftKey) {
			e.preventDefault();
			void doSend();
		}
	};

	// Click an expression button:
	//   - picker closed                 → open on this tab
	//   - picker open on the same tab   → close
	//   - picker open on a different tab → switch tabs (stay open)
	const openPickerOnTab = (tab: 'emojis' | 'gifs' | 'media' | 'stickers') => {
		if (showEmojiPicker) {
			if (pickerInitialTab === tab) {
				setShowEmojiPicker(false);
				return;
			}
			setPickerInitialTab(tab);
			return;
		}
		const btn = emojiButtonRef.current;
		if (btn) {
			const rect = btn.getBoundingClientRect();
			setPickerPos({
				bottom: window.innerHeight - rect.top + 8,
				right: window.innerWidth - rect.right,
			});
		}
		setPickerInitialTab(tab);
		setShowEmojiPicker(true);
	};

	const toggleEmojiPicker = () => openPickerOnTab('emojis');

	// Outside-click closes the desktop picker. Skips clicks on the
	// picker portal itself and on any of the expression trigger
	// buttons (those handle their own toggle). Only attached while
	// the picker is open and on desktop — mobile uses a sheet.
	useEffect(() => {
		if (!showEmojiPicker || isMobile) return;
		const onDocMouseDown = (e: MouseEvent) => {
			const target = e.target as Node | null;
			if (!target) return;
			if (pickerWrapperRef.current?.contains(target)) return;
			if (expressionButtonsRef.current?.contains(target)) return;
			setShowEmojiPicker(false);
		};
		// `mousedown` (not `click`) so we beat any selection logic
		// that the textarea below might run on click.
		document.addEventListener('mousedown', onDocMouseDown);
		return () => document.removeEventListener('mousedown', onDocMouseDown);
	}, [showEmojiPicker, isMobile]);

	const insertEmoji = (value: EmojiPickerValue) => {
		// GIF picks bypass the contentEditable image-insertion path
		// entirely — they're posted as a plain-text URL message so
		// the existing link-embed renderer can show them inline.
		if (value.kind === 'gif') {
			void sendPlainTextMessage(value.url);
			return;
		}

		const editor = editorRef.current;
		if (!editor) return;
		editor.focus();

		// Build the <img> node — unicode emoji render as a Twemoji SVG,
		// custom server emoji render as the uploaded image. Both are
		// marked contentEditable={false} so the caret skips over them.
		const img = document.createElement('img');
		if (value.kind === 'custom') {
			img.className = 'customEmoji';
			img.src = value.url;
			img.alt = `:${value.shortcode}:`;
			img.dataset.customEmoji = 'true';
			img.dataset.shortcode = value.shortcode;
		} else {
			img.className = 'twemoji-inline';
			img.src = getTwemojiUrl(value.surrogates);
			img.alt = value.surrogates;
			img.dataset.emoji = value.surrogates;
		}
		img.contentEditable = 'false';

		// Insert at the current selection when it lives inside the
		// editor, otherwise append at the end. A trailing zero-width
		// space gives the caret somewhere to sit past the image so the
		// user can keep typing.
		const selection = typeof window !== 'undefined' ? window.getSelection() : null;
		let range: Range | null = null;
		if (selection && selection.rangeCount > 0) {
			const r = selection.getRangeAt(0);
			if (editor.contains(r.startContainer) && editor.contains(r.endContainer)) {
				range = r;
			}
		}

		const zwsp = document.createTextNode('\u200B');
		if (range) {
			range.deleteContents();
			range.insertNode(img);
			img.after(zwsp);
		} else {
			editor.appendChild(img);
			editor.appendChild(zwsp);
		}

		// Move caret to after the zero-width space.
		if (selection) {
			const newRange = document.createRange();
			newRange.setStartAfter(zwsp);
			newRange.collapse(true);
			selection.removeAllRanges();
			selection.addRange(newRange);
		}

		setIsEmpty(serializeEditor(editor).trim().length === 0);
	};

	// For DMs we fetch the other participant so the placeholder reads
	// "Message @username" instead of the internal `dm-xxx-yyy` channel
	// name. For text/voice channels we still prefix with `#`.
	const isDM = channel?.type === 'dm';
	const dmRows = useQuery(
		api.dms.listDMs,
		isDM && userId ? { userId: userId as any } : 'skip',
	);
	const dmOtherUsername = useMemo(() => {
		if (!isDM || !dmRows) return null;
		const row = (dmRows as any[]).find((r) => r.channel_id === channelId);
		if (!row) return null;
		// Always use the real `username` for DM placeholder so it never
		// shows a server-side nickname. The display name still shows in
		// the header pill — it's only the composer hint that's strict.
		return row.other_username as string;
	}, [isDM, dmRows, channelId]);

	const channelName = channel?.name || 'channel';
	const placeholder = isDM
		? `Message @${dmOtherUsername || 'user'}`
		: `Message #${channelName}`;

	return (
		<div className={styles.outer}>
			{replyTo && onCancelReply && (
				<div className={styles.replyBar}>
					<span className={styles.replyText}>
						Replying to <strong>{replyTo.username}</strong>
					</span>
					<button
						type="button"
						className={styles.replyClose}
						onClick={onCancelReply}
						aria-label="Cancel reply"
					>
						<X size={20} weight="bold" />
					</button>
				</div>
			)}

			{editingEventId && onCancelEdit && (
				<div className={styles.editBar}>
					<span className={styles.editLabel}>Editing message</span>
					<button
						type="button"
						className={styles.editCancel}
						onClick={onCancelEdit}
					>
						cancel
					</button>
				</div>
			)}

			<PendingAttachmentRow
				attachments={pendingAttachments}
				onRemove={removePendingAttachment}
			/>

			<div className={styles.mainWrapper}>
				<div className={styles.uploadColumn}>
					<input
						ref={fileInputRef}
						type="file"
						multiple
						style={{ display: 'none' }}
						onChange={handleFilesSelected}
					/>
					<Tooltip content="Upload a File" placement="top">
						<button
							ref={plusButtonRef}
							type="button"
							className={styles.textareaButton}
							aria-label="Upload a file or create a poll"
							onClick={() => setShowPlusMenu((v) => !v)}
							disabled={isUploading || !channelKey}
						>
							<PlusCircle size={26} weight="fill" className={styles.textareaButtonIcon} />
						</button>
					</Tooltip>
				</div>

				<div className={styles.contentArea}>
					<div
						ref={editorRef}
						className={styles.textarea}
						contentEditable
						role="textbox"
						aria-multiline
						onInput={handleInput}
						onKeyDown={handleKeyDown}
						suppressContentEditableWarning
					/>
					{isEmpty && <div className={styles.placeholder}>{placeholder}</div>}
				</div>

				<div className={styles.buttonContainer} ref={expressionButtonsRef}>
					<Tooltip
						content="GIFs"
						shortcut={keybinds.getCombo('popouts.openGifPicker')}
						placement="top"
					>
						<button
							type="button"
							className={styles.textareaButton}
							onClick={() => openPickerOnTab('gifs')}
							aria-label="GIFs"
						>
							<Gif size={26} className={styles.textareaButtonIcon} />
						</button>
					</Tooltip>
					<Tooltip content="Saved Media" placement="top">
						<button
							type="button"
							className={styles.textareaButton}
							onClick={() => openPickerOnTab('media')}
							aria-label="Saved Media"
						>
							<ImageSquare size={26} className={styles.textareaButtonIcon} />
						</button>
					</Tooltip>
					<Tooltip content="Stickers" placement="top">
						<button
							type="button"
							className={styles.textareaButton}
							onClick={() => openPickerOnTab('stickers')}
							aria-label="Stickers"
						>
							<Sticker size={26} className={styles.textareaButtonIcon} />
						</button>
					</Tooltip>
					<Tooltip
						content="Emoji"
						shortcut={keybinds.getCombo('popouts.openEmojiPicker')}
						placement="top"
					>
						<button
							ref={emojiButtonRef}
							type="button"
							className={styles.textareaButton}
							onClick={toggleEmojiPicker}
							aria-label="Emoji"
						>
							<Smiley size={26} className={styles.textareaButtonIcon} />
						</button>
					</Tooltip>
				</div>

				<div className={styles.sendColumn}>
					<button
						type="button"
						className={styles.sendButton}
						onClick={doSend}
						disabled={(isEmpty && pendingAttachments.length === 0) || !channelKey || isUploading}
						aria-label="Send message"
					>
						<ArrowUp size={22} weight="bold" />
					</button>
				</div>
			</div>

			{mentionQuery !== null && (
				<MentionAutocomplete
					ref={mentionRef}
					channelId={channelId}
					query={mentionQuery}
					anchorEl={editorRef.current}
					onSelect={handleMentionSelect}
					onClose={() => setMentionQuery(null)}
				/>
			)}

			{isMobile ? (
				<MobileExpressionPickerSheet
					isOpen={showEmojiPicker}
					initialTab={pickerInitialTab}
					onClose={() => setShowEmojiPicker(false)}
					onSelect={(value) => {
						insertEmoji(value);
						setShowEmojiPicker(false);
					}}
				/>
			) : (
				showEmojiPicker &&
				createPortal(
					<div
						ref={pickerWrapperRef}
						style={{
							position: 'fixed',
							bottom: pickerPos.bottom,
							right: pickerPos.right,
							zIndex: 15000,
						}}
					>
						<EmojiPicker
							initialTab={pickerInitialTab}
							onTabChange={(tab) => setPickerInitialTab(tab)}
							onSelect={(value) => {
								insertEmoji(value);
								setShowEmojiPicker(false);
							}}
							onClose={() => setShowEmojiPicker(false)}
						/>
					</div>,
					document.body,
				)
			)}

			{showPlusMenu &&
				plusButtonRef.current &&
				createPortal(
					(() => {
						const rect = plusButtonRef.current.getBoundingClientRect();
						return (
							<>
								<div
									style={{
										position: 'fixed',
										inset: 0,
										zIndex: 14999,
									}}
									onClick={() => setShowPlusMenu(false)}
								/>
								<div
									className={styles.plusMenu}
									style={{
										position: 'fixed',
										bottom: window.innerHeight - rect.top + 8,
										left: rect.left,
										zIndex: 15000,
									}}
									onClick={(e) => e.stopPropagation()}
								>
									<button
										type="button"
										className={styles.plusMenuItem}
										onClick={() => {
											setShowPlusMenu(false);
											fileInputRef.current?.click();
										}}
									>
										<Paperclip size={20} />
										<span>Upload File</span>
									</button>
									<button
										type="button"
										className={styles.plusMenuItem}
										onClick={() => {
											setShowPlusMenu(false);
											setShowCreatePoll(true);
										}}
									>
										<ChartBar size={20} />
										<span>Create Poll</span>
									</button>
								</div>
							</>
						);
					})(),
					document.body,
				)}
			<CreatePollModal
				isOpen={showCreatePoll}
				onClose={() => setShowCreatePoll(false)}
				channelId={channelId}
			/>
		</div>
	);
}
