import { useMutation, usePaginatedQuery, useQuery } from 'convex/react';
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { api } from '../../../../../convex/_generated/api';
import { usePlatform } from '../../platform';
import { MessageGroup } from './MessageGroup';
import { PollCard } from './PollCard';
import { ChannelWelcomeSection } from './ChannelWelcomeSection';
import type { Id } from '../../../../../convex/_generated/dataModel';
import styles from './Messages.module.css';

interface MessagesProps {
	channelId: string;
	onReply?: (eventId: string, username: string) => void;
}

import type { AttachmentMetadata } from './EncryptedAttachment';

export interface ReactionUser {
	userId: string;
	username: string;
	displayName: string | null;
}

export interface DecryptedMessage {
	id: string;
	channelId: string;
	senderId: string;
	authorName: string;
	authorAvatarUrl: string | null;
	authorRoleColor: string | null;
	content: string;
	timestamp: number;
	editedTimestamp: number | null;
	replyToId: string | null;
	replyToAuthorName: string | null;
	replyToContent: string | null;
	attachments: AttachmentMetadata[];
	reactions: Array<{
		emoji: string;
		count: number;
		me: boolean;
		users: ReactionUser[];
	}>;
	pinned: boolean;
}

// Ciphertext format on disk is `content + tag` as hex, where the tag is
// the last 32 hex chars (16 bytes of GCM auth tag). Must be split before
// calling crypto.decryptData, which expects them as separate args.
const TAG_LENGTH = 32;

// Small LRU-ish cache for decrypted messages so re-renders don't redecrypt.
const decryptionCache = new Map<string, string>();
const MAX_CACHE = 2000;

function cacheSet(id: string, content: string) {
	if (decryptionCache.size >= MAX_CACHE) {
		const firstKey = decryptionCache.keys().next().value;
		if (firstKey !== undefined) decryptionCache.delete(firstKey);
	}
	decryptionCache.set(id, content);
}

// ── Day divider helpers ─────────────────────────────────────────────
//
// Inserts a "Tuesday, April 7, 2026"-style separator between message
// groups whenever the calendar day changes. Compares year/month/day
// rather than the timestamp delta — two messages 23 hours apart can
// still cross midnight.

function isSameDay(a: number, b: number): boolean {
	const da = new Date(a);
	const db = new Date(b);
	return (
		da.getFullYear() === db.getFullYear() &&
		da.getMonth() === db.getMonth() &&
		da.getDate() === db.getDate()
	);
}

const DAY_DIVIDER_FORMATTER = new Intl.DateTimeFormat(undefined, {
	weekday: 'long',
	year: 'numeric',
	month: 'long',
	day: 'numeric',
});

function formatDayDivider(ts: number): string {
	return DAY_DIVIDER_FORMATTER.format(new Date(ts));
}

function DayDivider({ timestamp }: { timestamp: number }) {
	return (
		<div className={styles.dayDivider} role="separator">
			{formatDayDivider(timestamp)}
		</div>
	);
}

/**
 * Red "NEW" line inserted between the last message the viewer has
 * already read and the first unseen one. Anchored to a snapshot of
 * `lastReadTimestamp` taken when the channel opened, so live new
 * messages arriving during the session don't push the divider
 * further down.
 */
function NewMessagesDivider() {
	return (
		<div className={styles.newDivider} role="separator">
			<span className={styles.newDividerBadge}>New</span>
		</div>
	);
}

export function Messages({ channelId, onReply }: MessagesProps) {
	const { crypto } = usePlatform();
	const scrollerRef = useRef<HTMLDivElement>(null);

	// Pending jump target — set by the `brycord:scroll-to-message`
	// listener when the message isn't rendered yet. A second effect
	// further down reacts to it: if the target is now in the DOM,
	// scroll to it; otherwise call `loadMore` to fetch another page
	// of history and try again on the next render.
	const pendingJumpRef = useRef<string | null>(null);

	// channelKeysByVersion holds every key we have for this channel,
	// keyed by the server-stored keyVersion. A DM that's been rotated
	// will have multiple entries — old messages decrypt with the
	// version they were written under, new messages use the latest.
	const [channelKeysByVersion, setChannelKeysByVersion] = useState<
		Map<number, string>
	>(new Map());

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

	const allKeys = useQuery(
		api.channelKeys.getKeysForUser,
		userId ? { userId: userId as any } : 'skip',
	);

	// ── Read-state + "NEW" divider plumbing ─────────────────────────
	//
	// `readState` is the server's live record of the latest message
	// timestamp this user has acknowledged for the current channel.
	// `readSnapshot` captures the value at the time the channel was
	// opened so the divider stays anchored in place even after we
	// flush a later `markRead` mutation during the session. Stored
	// as state (not a ref) so the first render after the query
	// resolves picks it up deterministically.
	const readState = useQuery(
		api.readState.getReadState,
		userId && channelId
			? { userId: userId as any, channelId: channelId as any }
			: 'skip',
	);
	const markRead = useMutation(api.readState.markRead);
	const [readSnapshot, setReadSnapshot] = useState<{
		channelId: string;
		lastRead: number;
	} | null>(null);
	// Tracks the freshest timestamp we've observed in this channel so
	// the mark-read flush has something to send. Pure ref — updates
	// should never trigger a re-render.
	const latestSeenTimestampRef = useRef<number>(0);
	// Debounce timer for batched mark-read flushes.
	const markReadTimerRef = useRef<number | null>(null);

	// Reset the snapshot whenever the user changes channels.
	useLayoutEffect(() => {
		setReadSnapshot(null);
		latestSeenTimestampRef.current = 0;
		if (markReadTimerRef.current !== null) {
			window.clearTimeout(markReadTimerRef.current);
			markReadTimerRef.current = null;
		}
	}, [channelId]);

	// First time a non-null `readState` arrives for the current
	// channel, lock it in as the divider anchor. `null` (no stored
	// read state yet — brand-new channel) is treated as "0" so the
	// divider appears the moment anyone posts.
	useEffect(() => {
		if (!channelId) return;
		if (readSnapshot?.channelId === channelId) return;
		if (readState === undefined) return;
		setReadSnapshot({
			channelId,
			lastRead: readState?.lastReadTimestamp ?? 0,
		});
	}, [readState, channelId, readSnapshot]);

	/** Fire-and-forget mark-read flush. Gated by:
	 *   - an authenticated user
	 *   - a channel loaded
	 *   - the window being visible (otherwise we keep the snapshot
	 *     and the NEW line so the user sees it when they return)
	 *   - the user being pinned to the bottom of the scroller
	 *   - the server's stored timestamp being strictly older than
	 *     the freshest message we've observed */
	const flushMarkRead = useCallback(() => {
		if (!userId || !channelId) return;
		if (
			typeof document !== 'undefined' &&
			document.visibilityState === 'hidden'
		) {
			return;
		}
		if (!pinnedRef.current) return;
		const ts = latestSeenTimestampRef.current;
		if (!ts) return;
		const serverTs = readState?.lastReadTimestamp ?? 0;
		if (ts <= serverTs) return;
		void markRead({
			userId: userId as any,
			channelId: channelId as any,
			lastReadTimestamp: ts,
		});
		// eslint-disable-next-line react-hooks/exhaustive-deps
	}, [userId, channelId, readState, markRead]);

	const scheduleMarkRead = useCallback(() => {
		if (markReadTimerRef.current !== null) {
			window.clearTimeout(markReadTimerRef.current);
		}
		// 600ms debounce — batches bursts of new messages into a
		// single mutation without feeling laggy to users who watch
		// the sidebar unread dot.
		markReadTimerRef.current = window.setTimeout(() => {
			markReadTimerRef.current = null;
			flushMarkRead();
		}, 600);
	}, [flushMarkRead]);

	// Walk every bundle we have, decrypt the ones tagged for this
	// channel, and build a {version → keyHex} map. A single bundle's
	// plaintext is a JSON object mapping channelId → keyHex (legacy
	// bundles can carry multiple channel ids), so we still merge by
	// channelId, then bucket by the row's `key_version`.
	useEffect(() => {
		let cancelled = false;
		if (!allKeys || !privateKeyPem) {
			setChannelKeysByVersion(new Map());
			return;
		}
		(async () => {
			const next = new Map<number, string>();
			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) {
						next.set(item.key_version ?? 1, keyForThisChannel);
					}
				} catch (err) {
					console.error(
						`Failed to decrypt key bundle for ${item.channel_id}`,
						err,
					);
				}
			}
			if (cancelled) return;
			setChannelKeysByVersion(next);
		})();
		return () => {
			cancelled = true;
		};
	}, [allKeys, privateKeyPem, channelId]);

	// Fallback "default" key — use the highest version we have. Needed
	// for legacy messages that were written before `keyVersion` was
	// tracked on messages (they come back without the field).
	const channelKey = useMemo(() => {
		if (channelKeysByVersion.size === 0) return null;
		let maxVersion = -Infinity;
		let latest: string | null = null;
		for (const [ver, key] of channelKeysByVersion) {
			if (ver > maxVersion) {
				maxVersion = ver;
				latest = key;
			}
		}
		return latest;
	}, [channelKeysByVersion]);

	const keyBundle = allKeys?.find((k) => k.channel_id === channelId) ?? null;

	const {
		results: pagedMessages,
		status,
		loadMore,
	} = usePaginatedQuery(
		api.messages.list,
		channelId ? { channelId: channelId as any, userId: (userId as any) ?? undefined } : 'skip',
		{ initialNumItems: 50 },
	);

	const [decryptedMap, setDecryptedMap] = useState<Map<string, string>>(new Map());

	// Reply previews — separate map keyed by the *child* message id so
	// the per-row render can grab the parent's plaintext without
	// re-decrypting on every paint. Filled by the effect below.
	const [replyPreviewMap, setReplyPreviewMap] = useState<Map<string, string>>(
		new Map(),
	);

	// Helper: try to scroll to whatever's in `pendingJumpRef`. If the
	// target is in the DOM we scroll + flash, clear the pending ref,
	// and we're done. Otherwise we kick off another paginate-up so
	// older history loads, then re-poll on the next render via the
	// effect below.
	const tryFulfillJump = useCallback(() => {
		const target = pendingJumpRef.current;
		if (!target) return;
		const scroller = scrollerRef.current;
		if (!scroller) return;
		const el = scroller.querySelector<HTMLElement>(
			`[data-message-id="${CSS.escape(target)}"]`,
		);
		if (el) {
			pendingJumpRef.current = null;
			el.scrollIntoView({ behavior: 'smooth', block: 'center' });
			el.classList.add('messageJumpHighlight');
			window.setTimeout(() => {
				el.classList.remove('messageJumpHighlight');
			}, 1600);
			return;
		}
		// Not in the DOM — load more older pages until either the
		// message appears or the listing is exhausted.
		if (status === 'CanLoadMore') {
			loadMore(50);
		} else if (status === 'Exhausted') {
			pendingJumpRef.current = null;
		}
	}, [status, loadMore]);

	// Listen for `brycord:scroll-to-message` window events fired by
	// reply previews, pin entries, and search results.
	useEffect(() => {
		const onScrollTo = (e: Event) => {
			const detail = (e as CustomEvent<{
				channelId?: string;
				messageId?: string;
			}>).detail;
			if (!detail?.messageId) return;
			if (detail.channelId && detail.channelId !== channelId) return;
			pendingJumpRef.current = detail.messageId;
			tryFulfillJump();
		};
		window.addEventListener('brycord:scroll-to-message', onScrollTo);
		return () =>
			window.removeEventListener('brycord:scroll-to-message', onScrollTo);
	}, [channelId, tryFulfillJump]);

	// Re-attempt the jump whenever a new page of messages lands. The
	// loader call above triggers a re-render with more rows, this
	// effect runs, and we either find the target or queue the next
	// paginate-up.
	useEffect(() => {
		if (pendingJumpRef.current) tryFulfillJump();
	}, [pagedMessages, tryFulfillJump]);

	useEffect(() => {
		if (channelKeysByVersion.size === 0 || !pagedMessages) return;
		let cancelled = false;
		(async () => {
			const next = new Map(decryptedMap);
			let changed = false;
			for (const msg of pagedMessages as any[]) {
				const id = msg.id as string;
				if (next.has(id)) continue;
				const cached = decryptionCache.get(id);
				if (cached) {
					next.set(id, cached);
					changed = true;
					continue;
				}
				if (!msg.ciphertext || msg.ciphertext.length < TAG_LENGTH) {
					next.set(id, '[Invalid Encrypted Message]');
					changed = true;
					continue;
				}
				// Pick the key matching this message's version. Messages
				// that predate key rotation default to version 1. If the
				// exact version is missing (shouldn't happen normally),
				// fall back to the latest key and try that so we never
				// lock the user out of their own history.
				const msgVersion: number = msg.keyVersion ?? 1;
				const keyForVersion =
					channelKeysByVersion.get(msgVersion) ?? channelKey;
				if (!keyForVersion) {
					next.set(id, '[Unable to decrypt]');
					changed = true;
					continue;
				}
				const tag = msg.ciphertext.slice(-TAG_LENGTH);
				const contentHex = msg.ciphertext.slice(0, -TAG_LENGTH);
				try {
					const plaintext = await crypto.decryptData(
						contentHex,
						keyForVersion,
						msg.nonce,
						tag,
					);
					if (cancelled) return;
					cacheSet(id, plaintext);
					next.set(id, plaintext);
					changed = true;
				} catch {
					next.set(id, '[Unable to decrypt]');
					changed = true;
				}
			}
			if (changed && !cancelled) setDecryptedMap(next);
		})();
		return () => {
			cancelled = true;
		};
	}, [pagedMessages, channelKeysByVersion, channelKey]);

	// Reply preview decryption — same pattern as the main loop but
	// keyed by child message id and using the parent's `replyToContent`
	// + `replyToNonce` + `replyToKeyVersion` fields. Cached so a
	// re-render of pagedMessages doesn't redecrypt every preview.
	useEffect(() => {
		if (channelKeysByVersion.size === 0 || !pagedMessages) return;
		let cancelled = false;
		(async () => {
			const next = new Map(replyPreviewMap);
			let changed = false;
			for (const msg of pagedMessages as any[]) {
				const id = msg.id as string;
				if (next.has(id)) continue;
				if (
					!msg.replyToContent ||
					!msg.replyToNonce ||
					msg.replyToContent.length < TAG_LENGTH
				) {
					continue;
				}
				const cacheKey = `reply:${id}`;
				const cached = decryptionCache.get(cacheKey);
				if (cached) {
					next.set(id, cached);
					changed = true;
					continue;
				}
				const replyVersion: number = msg.replyToKeyVersion ?? 1;
				const keyForVersion =
					channelKeysByVersion.get(replyVersion) ?? channelKey;
				if (!keyForVersion) continue;
				const tag = msg.replyToContent.slice(-TAG_LENGTH);
				const contentHex = msg.replyToContent.slice(0, -TAG_LENGTH);
				try {
					const plaintext = await crypto.decryptData(
						contentHex,
						keyForVersion,
						msg.replyToNonce,
						tag,
					);
					if (cancelled) return;
					// Strip JSON wrappers so the preview shows the
					// human-readable text, not raw {"text":"…"} dumps.
					let preview = plaintext;
					try {
						const parsed = JSON.parse(plaintext);
						if (parsed && typeof parsed === 'object') {
							if (Array.isArray(parsed)) {
								preview = parsed
									.filter((p: any) => p?.type === 'attachment')
									.map((p: any) => p?.filename || 'attachment')
									.join(', ');
							} else if (parsed.type === 'attachment') {
								preview = parsed.filename || parsed.mimeType || 'Attachment';
							} else if (parsed.text !== undefined) {
								preview = String(parsed.text);
							}
						}
					} catch {
						/* plain text */
					}
					cacheSet(cacheKey, preview);
					next.set(id, preview);
					changed = true;
				} catch {
					/* leave unset — UI shows the missing-context fallback */
				}
			}
			if (changed && !cancelled) setReplyPreviewMap(next);
		})();
		return () => {
			cancelled = true;
		};
		// eslint-disable-next-line react-hooks/exhaustive-deps
	}, [pagedMessages, channelKeysByVersion, channelKey]);

	const decrypted: DecryptedMessage[] = useMemo(() => {
		if (!pagedMessages) return [];
		return (pagedMessages as any[])
			.slice()
			.reverse()
			.map((msg) => {
				const id = msg.id as string;
				const content = decryptedMap.get(id) ?? '';
				const raw = (() => {
					try {
						return JSON.parse(content);
					} catch {
						return null;
					}
				})();

				let text = content;
				const attachments: AttachmentMetadata[] = [];
				// Two legacy formats:
				//   1. Single object: { type: 'attachment', url, key, iv, ... }
				//   2. Array of attachment objects
				//   3. { text: '...' } wrapper from newer sends
				if (raw) {
					if (Array.isArray(raw)) {
						for (const item of raw) {
							if (item?.type === 'attachment' && item.url && item.key && item.iv) {
								attachments.push(item as AttachmentMetadata);
							}
						}
						text = '';
					} else if (raw.type === 'attachment' && raw.url && raw.key && raw.iv) {
						attachments.push(raw as AttachmentMetadata);
						text = '';
					} else if (raw.text !== undefined) {
						text = String(raw.text);
					}
				}

				// Reply preview comes from `replyPreviewMap`, populated
				// asynchronously by the dedicated decryption effect above.
				const replyPreview = replyPreviewMap.get(id) ?? null;

				// `msg.reactions` is either an array of {emoji,count,me,users}
				// (new server shape) or null. Older cached rows can still
				// surface a Record without `users` — fall back to an
				// empty users list in that case so the client never
				// crashes on forward-compat.
				const reactionsArray: DecryptedMessage['reactions'] = Array.isArray(
					msg.reactions,
				)
					? msg.reactions.map((r: any) => ({
							emoji: String(r.emoji ?? ''),
							count: Number(r.count ?? 0),
							me: Boolean(r.me ?? false),
							users: Array.isArray(r.users)
								? r.users.map((u: any) => ({
										userId: String(u.userId ?? ''),
										username: String(u.username ?? 'Unknown'),
										displayName: u.displayName ?? null,
									}))
								: [],
						}))
					: msg.reactions && typeof msg.reactions === 'object'
						? Object.entries(msg.reactions).map(([emoji, info]) => ({
								emoji,
								count: (info as any).count ?? 0,
								me: (info as any).me ?? false,
								users: [],
							}))
						: [];

				return {
					id,
					channelId,
					senderId: msg.sender_id as string,
					authorName: msg.displayName || msg.username || 'User',
					authorAvatarUrl: msg.avatarUrl ?? null,
					authorRoleColor: msg.senderRoleColor ?? null,
					content: text,
					timestamp: msg.created_at ? new Date(msg.created_at).getTime() : Date.now(),
					editedTimestamp: msg.editedAt ?? null,
					replyToId: msg.replyToId ?? null,
					replyToAuthorName: msg.replyToDisplayName || msg.replyToUsername || null,
					replyToContent: replyPreview,
					attachments,
					reactions: reactionsArray,
					pinned: msg.pinned ?? false,
				} as DecryptedMessage;
			});
	}, [pagedMessages, decryptedMap, replyPreviewMap, channelId]);

	// Pinned-to-bottom tracking. Matches the new UI's approach: a single
	// boolean in a ref, updated on every onScroll event via isNearBottom.
	// MutationObserver + ResizeObserver below drive all auto-scroll
	// behaviour based on this one bit.
	const pinnedRef = useRef(true);

	// Anchor for scroll-preservation on pagination. Instead of tracking
	// an absolute scrollTop we remember the distance between the top of
	// the oldest visible message and the bottom of the scroller (i.e.
	// `scrollHeight - scrollTop`). As long as this anchor is set, every
	// observed mutation re-pins scrollTop so that the same "bottom
	// offset" is preserved — the reader stays glued to whichever
	// message they were looking at when they scrolled into the loader
	// region, regardless of how many growth events the decryption
	// pipeline produces.
	//
	// The anchor is cleared when:
	//   - The query reports no more pages (`status !== 'CanLoadMore'`)
	//   - OR the next onScroll fires AFTER the user actually moves
	//     the scroller (tracked via a "recently restored" flag)
	const scrollAnchorRef = useRef<{ bottomOffset: number } | null>(null);
	const restoreActiveRef = useRef(false);

	// On channel switch, re-pin and scroll to bottom.
	useLayoutEffect(() => {
		pinnedRef.current = true;
		scrollAnchorRef.current = null;
		restoreActiveRef.current = false;
		const el = scrollerRef.current;
		if (el) el.scrollTop = el.scrollHeight;
	}, [channelId]);

	// Observe DOM mutations + resizes inside the scroller. If the user
	// is pinned to bottom, snap to bottom on every content change. If
	// the user triggered a paginate-up, preserve their position by
	// adjusting scrollTop against the height delta.
	useEffect(() => {
		const el = scrollerRef.current;
		if (!el) return;

		const onContentChange = () => {
			// Pagination anchor wins over pinned — the user is clearly
			// scrolled up and reading older context, so we must NOT
			// slam them to the bottom.
			const anchor = scrollAnchorRef.current;
			if (anchor) {
				// Restore the same distance-from-bottom on every
				// mutation until the anchor is cleared. This handles
				// the case where decryption triggers multiple rounds
				// of height growth after a single loadMore call.
				restoreActiveRef.current = true;
				el.scrollTop = Math.max(0, el.scrollHeight - anchor.bottomOffset);
				// Flip the flag off on the next tick so a real user
				// scroll after this restore can still update pinned
				// tracking without being mistaken for our own restore.
				requestAnimationFrame(() => {
					restoreActiveRef.current = false;
				});
				return;
			}
			if (pinnedRef.current) {
				el.scrollTop = el.scrollHeight;
			}
		};

		const mutationObs = new MutationObserver(onContentChange);
		mutationObs.observe(el, { childList: true, subtree: true });

		// ResizeObserver catches async content like attachments loading
		// after the DOM is already in place (images decoding, blob URLs
		// resolving, emoji images flushing). Also observes the scroller
		// itself so window resizes (e.g. dragging the bottom edge up)
		// keep the bottom-pinned position locked instead of clipping
		// the latest messages.
		const content = el.firstElementChild;
		let resizeObs: ResizeObserver | undefined;
		if (content) {
			resizeObs = new ResizeObserver(onContentChange);
			resizeObs.observe(content);
			resizeObs.observe(el);
		}

		// Final safety net for non-ResizeObserver-friendly resizes
		// (split-pane drags that don't trigger an element resize) —
		// listen for window resize and re-run the pin check too.
		const onWindowResize = () => onContentChange();
		window.addEventListener('resize', onWindowResize);

		// Image / video / audio attachments fire this once their
		// decoded bytes are painted. Re-runs the same pin-to-bottom
		// path so the scroll anchor catches the late layout shift
		// even when the placeholder + final image have identical
		// box dimensions (the ResizeObserver wouldn't fire then).
		const onAttachmentLoaded = () => onContentChange();
		window.addEventListener(
			'brycord:attachment-loaded',
			onAttachmentLoaded,
		);

		return () => {
			mutationObs.disconnect();
			resizeObs?.disconnect();
			window.removeEventListener('resize', onWindowResize);
			window.removeEventListener(
				'brycord:attachment-loaded',
				onAttachmentLoaded,
			);
		};
	}, [channelId]);

	const handleScroll = useCallback(() => {
		const el = scrollerRef.current;
		if (!el) return;

		// Ignore scroll events triggered by our own anchor-restore in
		// the observer. Without this guard, the restore would look
		// like a user scroll and nuke the anchor before the next
		// mutation arrives.
		if (restoreActiveRef.current) return;

		const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
		pinnedRef.current = distanceFromBottom < 150;

		// If the user has visibly moved away from the loader region
		// (far enough down that another paginate-up can't trigger),
		// clear the anchor. This lets the next near-top scroll start
		// a fresh pagination cycle instead of chaining onto stale
		// coords from a previous one.
		if (scrollAnchorRef.current && el.scrollTop > 200) {
			scrollAnchorRef.current = null;
		}

		// Load older messages when the user nears the top. Capture
		// the current distance-from-bottom into the anchor ref BEFORE
		// firing loadMore so the content-change observer can pin the
		// viewport to the same message the reader was looking at.
		if (el.scrollTop < 80 && status === 'CanLoadMore' && !scrollAnchorRef.current) {
			scrollAnchorRef.current = {
				bottomOffset: el.scrollHeight - el.scrollTop,
			};
			loadMore(50);
		}
	}, [status, loadMore]);

	const groups = useMemo(() => {
		const result: DecryptedMessage[][] = [];
		for (const msg of decrypted) {
			const last = result[result.length - 1];
			if (last && last[last.length - 1].senderId === msg.senderId) {
				const gap = msg.timestamp - last[last.length - 1].timestamp;
				if (gap < 7 * 60 * 1000 && !msg.replyToId) {
					last.push(msg);
					continue;
				}
			}
			result.push([msg]);
		}
		return result;
	}, [decrypted]);

	// Channel info — used by the welcome header at the top of the
	// scroller so we can render either a DM intro or a "Welcome to
	// #name" block. Cheap reactive query, same as ChannelView.
	const channelDoc = useQuery(
		api.channels.get,
		channelId ? { id: channelId as Id<'channels'> } : 'skip',
	);

	// Polls are independent Convex documents — fetch every poll in
	// this channel and interleave them with the message groups by
	// creation timestamp. Poll counts are small per channel so
	// collecting them in one query is fine.
	const pollsInChannel =
		useQuery(
			api.polls.listByChannel,
			channelId ? { channelId: channelId as Id<'channels'> } : 'skip',
		) ?? [];

	type TimelineItem =
		| { kind: 'group'; key: string; ts: number; group: DecryptedMessage[] }
		| { kind: 'poll'; key: string; ts: number; pollId: Id<'polls'> };

	const items = useMemo<TimelineItem[]>(() => {
		const list: TimelineItem[] = groups.map((group) => ({
			kind: 'group',
			key: `g-${group[0].id}`,
			ts: group[0].timestamp,
			group,
		}));
		for (const poll of pollsInChannel) {
			list.push({
				kind: 'poll',
				key: `p-${poll._id}`,
				ts: poll.createdAt,
				pollId: poll._id,
			});
		}
		list.sort((a, b) => a.ts - b.ts);
		return list;
	}, [groups, pollsInChannel]);

	// Whenever the timeline grows past the freshest timestamp we've
	// seen, update the ref and schedule a debounced mark-read. The
	// ref-only update doesn't cause re-renders — it just feeds the
	// mark-read flush with an up-to-date target.
	useEffect(() => {
		if (items.length === 0) return;
		const newest = items[items.length - 1].ts;
		if (newest > latestSeenTimestampRef.current) {
			latestSeenTimestampRef.current = newest;
			scheduleMarkRead();
		}
	}, [items, scheduleMarkRead]);

	// Window visibility → when the tab comes back into focus, flush
	// any pending mark-read so the sidebar dot disappears without
	// needing a new message to land.
	useEffect(() => {
		const onVisibility = () => {
			if (document.visibilityState === 'visible') {
				scheduleMarkRead();
			}
		};
		document.addEventListener('visibilitychange', onVisibility);
		window.addEventListener('focus', onVisibility);
		return () => {
			document.removeEventListener('visibilitychange', onVisibility);
			window.removeEventListener('focus', onVisibility);
		};
	}, [scheduleMarkRead]);

	// Channel unmount (navigated elsewhere / logged out) → fire a
	// final mark-read with whatever the latest observed timestamp
	// is, bypassing the debounce. Matches Fluxer's "leaving a
	// channel marks it read" UX so the sidebar dot doesn't linger.
	useEffect(() => {
		return () => {
			if (markReadTimerRef.current !== null) {
				window.clearTimeout(markReadTimerRef.current);
				markReadTimerRef.current = null;
			}
			if (!userId || !channelId) return;
			const ts = latestSeenTimestampRef.current;
			if (!ts) return;
			void markRead({
				userId: userId as any,
				channelId: channelId as any,
				lastReadTimestamp: ts,
			});
		};
		// eslint-disable-next-line react-hooks/exhaustive-deps
	}, [channelId]);

	return (
		<div className={styles.container} ref={scrollerRef} onScroll={handleScroll}>
			<div className={styles.scroller}>
				{status === 'LoadingMore' && (
					<div
						style={{
							padding: '8px 16px',
							color: 'var(--text-tertiary)',
							fontSize: 13,
							textAlign: 'center',
						}}
					>
						Loading older messages…
					</div>
				)}
				{/* Welcome header — only shown once we've loaded every
				    page so the user actually sees the start of the
				    history. While there are still older messages to
				    fetch, the loading row above takes its place. */}
				{status === 'Exhausted' && (
					<ChannelWelcomeSection
						channelId={channelId}
						channelName={channelDoc?.name}
						channelType={channelDoc?.type}
					/>
				)}
				{(() => {
					// Walk the timeline once, inserting a `DayDivider`
					// whenever the calendar day changes and a single
					// `NewMessagesDivider` before the first item whose
					// timestamp is strictly greater than the snapshot
					// of `lastReadTimestamp` taken when the channel
					// opened. `lastTs` tracks the most-recent rendered
					// item so two consecutive items on the same day
					// skip the date divider.
					//
					// The NEW line never shows for the viewer's own
					// messages — if you're the one that sent it, you
					// obviously already "saw" it. A message group
					// authored entirely by the current user is skipped
					// when looking for the divider boundary, so the
					// line stays anchored at the first message from
					// somebody else.
					let lastTs: number | null = null;
					const out: React.ReactNode[] = [];
					const snapshot =
						readSnapshot?.channelId === channelId
							? readSnapshot.lastRead
							: null;
					let newLinePlaced = snapshot === null;
					const isOwnGroup = (item: TimelineItem): boolean => {
						if (item.kind !== 'group') return false;
						if (!userId) return false;
						return item.group.every((m) => m.senderId === userId);
					};
					for (const item of items) {
						if (lastTs === null || !isSameDay(lastTs, item.ts)) {
							out.push(
								<DayDivider key={`day-${item.ts}`} timestamp={item.ts} />,
							);
						}
						if (
							!newLinePlaced &&
							snapshot !== null &&
							item.ts > snapshot &&
							!isOwnGroup(item)
						) {
							out.push(<NewMessagesDivider key={`new-${item.key}`} />);
							newLinePlaced = true;
						}
						lastTs = item.ts;
						if (item.kind === 'group') {
							out.push(
								<MessageGroup
									key={item.key}
									messages={item.group as any}
									channelId={channelId}
									onReply={onReply}
								/>,
							);
						} else {
							out.push(
								<div key={item.key} className={styles.pollWrapper}>
									<PollCard pollId={item.pollId} />
								</div>,
							);
						}
					}
					return out;
				})()}
			</div>
		</div>
	);
}
