import {
	Fragment,
	memo,
	type RefObject,
	type UIEvent,
	useEffect,
	useEffectEvent,
	useImperativeHandle,
	useMemo,
	useReducer,
	useRef,
	useState,
} from 'react';

import { ClientResponseError, ok } from '@atcute/client';
import type { ResourceUri } from '@atcute/lexicons';
import { isGraphemeLengthInRange } from '@atcute/util-text';

import { useQueryClient } from '@tanstack/react-query';
import { clsx } from 'clsx';

import { EmbeddingDisabledError } from '#/lib/api/resolve';
import { MAX_DRAFT_GRAPHEME_LENGTH, MAX_POST_GRAPHEME_LENGTH } from '#/lib/constants/composer';
import { VIDEO_UPLOAD_MIME_TYPES } from '#/lib/constants/video';
import { cleanError } from '#/lib/errors';
import { useNonReactiveCallback } from '#/lib/hooks/use-non-reactive-callback';
import { type ComposerImage, createComposerImage } from '#/lib/media/composer-image';
import { getImageDimensions, getVideoMetadata } from '#/lib/media/metadata';
import type { VideoAsset } from '#/lib/media/video/types';
import { postUriToTarget } from '#/lib/routes/targets';
import { retry } from '#/lib/utils/retry';

import { postCreated } from '#/state/events';
import { useRequireAltTextEnabled } from '#/state/preferences/alt-text';
import { savePostLanguageToHistory, toPostLanguages, usePostLanguage } from '#/state/preferences/languages';
import { usePreferencesQuery } from '#/state/queries/preferences';
import { useProfileQuery } from '#/state/queries/profile';
import { getClients, useSession } from '#/state/session';

import { ComposerReplyTo } from '#/features/composer/ComposerReplyTo';
import { ExternalEmbedGif, ExternalEmbedLink } from '#/features/composer/ExternalEmbed';
import { ExternalEmbedRemoveBtn } from '#/features/composer/ExternalEmbedRemoveBtn';
import { GifAltText } from '#/features/composer/GifAltText';
import {
	closeComposer,
	COMPOSER_DIALOG_ID,
	type ComposerOpts,
	type OnPostSuccessData,
} from '#/features/composer/open-composer';
import { Gallery } from '#/features/composer/photos/Gallery';
import { publishThread, ReplyDeletedError } from '#/features/composer/publish-thread';
import { SuggestedLanguage } from '#/features/composer/select-language/SuggestedLanguage';
// TODO: Prevent naming components that coincide with RN primitives
// due to linting false positives
import { TextInput } from '#/features/composer/text-input/TextInput';
import { SubtitleDialogBtn } from '#/features/composer/videos/SubtitleDialog';
import { VideoPreview } from '#/features/composer/videos/VideoPreview';

import * as Dialog from '#/components/Dialog';
import { closeAllDialogs } from '#/components/Dialog/registry';
import { GalleryBleed } from '#/components/images/Gallery';
import { LazyQuoteEmbed } from '#/components/Post/Embed/LazyQuoteEmbed';
import * as Prompt from '#/components/Prompt';
import * as Toast from '#/components/Toast';
import { UserAvatar } from '#/components/UserAvatar';
import { Button, ButtonIcon } from '#/components/web/Button';

import XIcon from '#/icons/central/CrossLarge_round_outlined_radius1_stroke2.svg';
import { m } from '#/paraglide/messages';
import { useRouter } from '#/router';

import * as styles from './Composer.css';
import * as ComposerError from './ComposerError';
import { ComposerFooter } from './ComposerFooter';
import { ComposerPills } from './ComposerPills';
import { ComposerTopBar } from './ComposerTopBar';
import { draftToComposerPosts, extractLocalRefs, type RestoredVideo } from './drafts/state/api';
import {
	loadDraftMedia,
	useCleanupPublishedDraftMutation,
	useSaveDraftMutation,
} from './drafts/state/queries';
import type { DraftSummary } from './drafts/state/schema';
import { createAddImagesWithCap } from './gallery-cap';
import {
	type ComposerAction,
	composerReducer,
	createComposerState,
	type EmbedDraft,
	type PostAction,
	type PostDraft,
	type ThreadDraft,
} from './state/composer';
import { NO_VIDEO, type NoVideoState, processVideo, type VideoState } from './state/video';
import type { TextInputRef } from './text-input/TextInput.types';

/** Minimum gap between honored language-detection nudges, so rapid detector firings don't re-pulse the button. */
const NUDGE_COOLDOWN_MS = 10_000;

const getDraftSaveError = (error: unknown): string => {
	if (error instanceof ClientResponseError && error.error === 'DraftLimitReached') {
		return m['view.composer.drafts.error.max']();
	}
	return m['view.composer.drafts.error.save']();
};

type Props = ComposerOpts;
export const ComposePost = ({
	replyTo,
	onPost,
	onPostSuccess,
	quote: initQuote,
	mention: initMention,
	text: initText,
	videoUri: initVideoUri,
	cancelRef,
}: Props & {
	cancelRef?: RefObject<CancelRef | null>;
}) => {
	const { currentAccount } = useSession();
	const { appview, pds, pdsUrl } = getClients();
	const queryClient = useQueryClient();
	const currentDid = currentAccount!.did;
	const requireAltTextEnabled = useRequireAltTextEnabled();
	const postLanguage = usePostLanguage();
	const textInputRef = useRef<TextInputRef>(null);
	const discardPromptHandle = Prompt.usePromptHandle();
	const emptyPostsPromptHandle = Prompt.usePromptHandle();
	const skipEmptyConfirmedRef = useRef(false);
	const { mutateAsync: saveDraft } = useSaveDraftMutation();
	const { mutate: cleanupPublishedDraft } = useCleanupPublishedDraftMutation();
	const { data: preferences } = usePreferencesQuery();
	const router = useRouter();

	const [isPublishing, setIsPublishing] = useState(false);
	const [error, setError] = useState('');

	/**
	 * A temporary local reference to a language suggestion that the user has accepted. This overrides the
	 * global post language preference, but is not stored permanently.
	 */
	const [acceptedLanguageSuggestion, setAcceptedLanguageSuggestion] = useState<string | null>(null);

	/** The language(s) of the post being replied to. */
	const [replyToLanguages, setReplyToLanguages] = useState<string[]>(replyTo?.langs || []);

	/**
	 * The currently selected languages of the post. Prefer local temporary language suggestion over global lang
	 * prefs, if available.
	 */
	const currentLanguages = acceptedLanguageSuggestion
		? [acceptedLanguageSuggestion]
		: toPostLanguages(postLanguage);

	/**
	 * clear temporary and suggested languages when the user selects a language from the composer language
	 * selector
	 */
	const onSelectLanguage = () => {
		setAcceptedLanguageSuggestion(null);
		setReplyToLanguages([]);
	};

	/** timestamp (ms) of the last honored nudge from language detection, used to rate-limit the pulse animation. */
	const [languageNudgeAt, setLanguageNudgeAt] = useState(0);
	const onLanguageNudge = () => {
		const now = Date.now();
		// only update state (and therefore re-pulse) once the cooldown has elapsed
		setLanguageNudgeAt((prev) => (now - prev > NUDGE_COOLDOWN_MS ? now : prev));
	};

	const [composerState, composerDispatch] = useReducer(
		composerReducer,
		{
			initQuoteUri: initQuote?.uri,
			initText,
			initMention,
			initInteractionSettings: preferences?.postInteractionSettings,
		},
		createComposerState,
	);

	const thread = composerState.thread;

	// Clear error when composer content changes, but only if all posts are
	// back within the character limit.
	const allPostsWithinLimit = thread.posts.every((post) =>
		isGraphemeLengthInRange(post.text, 0, MAX_DRAFT_GRAPHEME_LENGTH),
	);

	const activePost = thread.posts[composerState.activePostIndex]!;
	const nextPost: PostDraft | undefined = thread.posts[composerState.activePostIndex + 1];
	const dispatch = (postAction: PostAction) => {
		composerDispatch({
			type: 'updatePost',
			postId: activePost.id,
			postAction,
		});
	};

	const selectVideo = (postId: string, asset: VideoAsset) => {
		const abortController = new AbortController();
		composerDispatch({
			type: 'updatePost',
			postId: postId,
			postAction: {
				type: 'embedAddVideo',
				asset,
				abortController,
			},
		});
		if (!pds || !pdsUrl) {
			return;
		}
		void processVideo(
			asset,
			(videoAction) => {
				composerDispatch({
					type: 'updatePost',
					postId: postId,
					postAction: {
						type: 'embedUpdateVideo',
						videoAction,
					},
				});
			},
			pdsUrl,
			pds,
			abortController.signal,
		);
	};

	const onInitVideo = useEffectEvent(() => {
		if (initVideoUri) {
			selectVideo(activePost.id, initVideoUri);
		}
	});

	useEffect(() => {
		onInitVideo();
	}, []);

	const clearVideo = (postId: string) => {
		composerDispatch({
			type: 'updatePost',
			postId: postId,
			postAction: {
				type: 'embedRemoveVideo',
			},
		});
	};

	const restoreVideo = async (postId: string, videoInfo: RestoredVideo) => {
		try {
			const meta = await getVideoMetadata(videoInfo.blob);
			const asset: VideoAsset = {
				blob: videoInfo.blob,
				width: meta.width,
				height: meta.height,
				mimeType: videoInfo.mimeType,
				duration: meta.duration,
			};

			// Start video processing using existing flow
			const abortController = new AbortController();
			composerDispatch({
				type: 'updatePost',
				postId,
				postAction: {
					type: 'embedAddVideo',
					asset,
					abortController,
				},
			});

			// Restore alt text immediately
			if (videoInfo.altText) {
				composerDispatch({
					type: 'updatePost',
					postId,
					postAction: {
						type: 'embedUpdateVideo',
						videoAction: {
							type: 'updateAltText',
							altText: videoInfo.altText,
							signal: abortController.signal,
						},
					},
				});
			}

			// Restore captions (web only - captions use File objects)
			if (videoInfo.captions.length > 0) {
				const captionTracks = videoInfo.captions.map((c) => ({
					lang: c.lang,
					file: new File([c.content], `caption-${c.lang}.vtt`, {
						type: 'text/vtt',
					}),
				}));
				composerDispatch({
					type: 'updatePost',
					postId,
					postAction: {
						type: 'embedUpdateVideo',
						videoAction: {
							type: 'updateCaptions',
							updater: () => captionTracks,
							signal: abortController.signal,
						},
					},
				});
			}

			// Start video upload
			if (!pds || !pdsUrl) {
				return;
			}
			void processVideo(
				asset,
				(videoAction) => {
					composerDispatch({
						type: 'updatePost',
						postId,
						postAction: {
							type: 'embedUpdateVideo',
							videoAction,
						},
					});
				},
				pdsUrl,
				pds,
				abortController.signal,
			);
		} catch (e) {
			console.error('Failed to restore video from draft', postId, e);
		}
	};

	const handleSelectDraft = async (draftSummary: DraftSummary) => {
		// Load local media files for the draft
		const { loadedMedia } = await loadDraftMedia(draftSummary.draft);

		// Extract original localRefs for orphan detection on save
		const originalLocalRefs = extractLocalRefs(draftSummary.draft);

		// Convert server draft to composer posts (videos returned separately)
		const { posts, restoredVideos } = await draftToComposerPosts(draftSummary.draft, loadedMedia);

		// Dispatch restore action (this also sets draftId in state)
		composerDispatch({
			type: 'restoreFromDraft',
			draftId: draftSummary.id,
			posts,
			threadgateAllow: draftSummary.draft.threadgateAllow,
			postgateEmbeddingRules: draftSummary.draft.postgateEmbeddingRules,
			loadedMedia,
			originalLocalRefs,
		});

		// Initiate video processing for any restored videos
		// This is async but we don't await - videos process in the background
		for (const [postIndex, videoInfo] of restoredVideos) {
			const postId = posts[postIndex]!.id;
			void restoreVideo(postId, videoInfo);
		}
	};

	const [publishOnUpload, setPublishOnUpload] = useState(false);
	// monotonic token bumped (during render) when a publish queued on upload completion is ready to fire.
	// consumed once by the effect below via a handled-ref, so the publish side effect runs after commit
	// without any setState landing in the effect body.
	const [uploadCompletionPublishRequest, setUploadCompletionPublishRequest] = useState(0);
	const handledUploadCompletionPublishRequestRef = useRef(0);

	const validateDraftTextOrError = (): boolean => {
		const tooLong = composerState.thread.posts.some(
			(post) => !isGraphemeLengthInRange(post.text, 0, MAX_DRAFT_GRAPHEME_LENGTH),
		);
		if (tooLong) {
			setError(m['view.composer.drafts.error.tooLong']({ max: MAX_DRAFT_GRAPHEME_LENGTH }));
			return false;
		}
		return true;
	};

	const handleSaveDraft = async () => {
		setError('');
		if (!validateDraftTextOrError()) {
			return;
		}
		try {
			const result = await saveDraft({
				composerState,
				existingDraftId: composerState.draftId,
			});
			composerDispatch({ type: 'markSaved', draftId: result.draftId });

			closeComposer();
		} catch (e) {
			console.error('Failed to save draft', e);
			setError(getDraftSaveError(e));
		}
	};

	// Save without closing - for use by DraftsButton
	const saveCurrentDraft = async (): Promise<{
		success: boolean;
	}> => {
		setError('');
		if (!validateDraftTextOrError()) {
			return { success: false };
		}
		try {
			const result = await saveDraft({
				composerState,
				existingDraftId: composerState.draftId,
			});
			composerDispatch({ type: 'markSaved', draftId: result.draftId });
			return { success: true };
		} catch (e) {
			setError(getDraftSaveError(e));
			return { success: false };
		}
	};

	// Check if composer is empty (no content to save)
	const firstPost = thread.posts[0]!;
	const isComposerEmpty =
		thread.posts.length === 1 &&
		firstPost.text.trim().length === 0 &&
		!firstPost.embed.link &&
		!firstPost.embed.media &&
		!firstPost.embed.quote;

	// Clear the composer (discard current content)
	const handleClearComposer = () => {
		composerDispatch({
			type: 'clear',
			initInteractionSettings: preferences?.postInteractionSettings,
		});
	};

	/**
	 * decides how to handle a cancel request (Cancel button, Escape, backdrop press).
	 *
	 * @returns true if the composer should stay open (e.g., a sub-popup was closed or discard prompt shown), or
	 *   false if the caller should close the composer.
	 */
	const onPressCancel = (): boolean => {
		if (textInputRef.current?.maybeClosePopup()) {
			return true;
		}

		const hasContent = thread.posts.some(
			(post) => post.shortenedGraphemeLength > 0 || post.embed.media || post.embed.link,
		);

		// Show discard prompt if there's content AND either:
		// - No draft is loaded (new composition)
		// - Draft is loaded but has been modified
		if (hasContent && (!composerState.draftId || composerState.isDirty)) {
			// Dismiss sub-dialogs (emoji picker, etc.) but keep the composer itself open so the discard
			// prompt has something to confirm against.
			closeAllDialogs({ except: [COMPOSER_DIALOG_ID] });
			if (document.activeElement instanceof HTMLElement) {
				document.activeElement.blur();
			}
			discardPromptHandle.open(null);
			return true;
		}
		return false;
	};

	useImperativeHandle(cancelRef, () => ({ onPressCancel }));

	// The Cancel button drives the close itself (the dialog's `onOpenChange` does it for Escape/backdrop).
	const onRequestClose = () => {
		if (!onPressCancel()) {
			closeComposer();
		}
	};

	const missingAltError = ((): string | undefined => {
		if (!requireAltTextEnabled) {
			return;
		}
		for (let i = 0; i < thread.posts.length; i++) {
			const media = thread.posts[i]!.embed.media;
			if (media) {
				if ((media.type === 'images' || media.type === 'gallery') && media.images.some((img) => !img.alt)) {
					return m['view.composer.gallery.error.altMissing']();
				}
				if (media.type === 'gif' && !media.alt) {
					return m['view.composer.gif.error.altMissing']();
				}
				if (media.type === 'video' && media.video.status !== 'error' && !media.video.altText) {
					return m['view.composer.video.error.altMissing']();
				}
			}
		}
	})();

	const canPost =
		!missingAltError &&
		thread.posts.some((post) => !isEmptyPost(post)) &&
		thread.posts.every(
			(post) =>
				isEmptyPost(post) ||
				(post.shortenedGraphemeLength <= MAX_POST_GRAPHEME_LENGTH &&
					!(post.embed.media?.type === 'video' && post.embed.media.video.status === 'error')),
		);

	const getFilteredThread = (): {
		type: 'none' | 'trailingOnly' | 'nonTrailing';
		filteredThread: ThreadDraft;
	} => {
		const nonEmptyPosts = thread.posts.filter((post) => !isEmptyPost(post));

		if (nonEmptyPosts.length === thread.posts.length) {
			return { type: 'none', filteredThread: thread };
		}

		let lastNonEmptyIndex = -1;
		for (let i = thread.posts.length - 1; i >= 0; i--) {
			if (!isEmptyPost(thread.posts[i]!)) {
				lastNonEmptyIndex = i;
				break;
			}
		}

		const hasNonTrailingEmpty = thread.posts.some((post, i) => i < lastNonEmptyIndex && isEmptyPost(post));

		const filteredThread: ThreadDraft = { ...thread, posts: nonEmptyPosts };

		return {
			type: hasNonTrailingEmpty ? 'nonTrailing' : 'trailingOnly',
			filteredThread,
		};
	};

	const onPressPublish = async () => {
		if (isPublishing) {
			return;
		}

		if (!canPost) {
			return;
		}

		const { type: emptyType, filteredThread } = getFilteredThread();

		if (emptyType === 'nonTrailing' && !skipEmptyConfirmedRef.current) {
			emptyPostsPromptHandle.open(null);
			return;
		}

		if (
			filteredThread.posts.some(
				(post) =>
					post.embed.media?.type === 'video' &&
					post.embed.media.video.asset &&
					post.embed.media.video.status !== 'done',
			)
		) {
			setPublishOnUpload(true);
			return;
		}

		skipEmptyConfirmedRef.current = false;
		setError('');
		setIsPublishing(true);

		let postUri: ResourceUri | undefined;
		try {
			postUri = (
				await publishThread({ appview, did: currentDid, pds: pds! }, queryClient, {
					thread: filteredThread,
					replyTo: replyTo?.uri,
					langs: currentLanguages,
				})
			).uris[0];
		} catch (e: unknown) {
			console.error('Composer: create post failed', e);
			let err = cleanError(e);
			if (e instanceof ReplyDeletedError || err.includes('not locate record')) {
				err = m['view.composer.reply.deleted']();
			} else if (e instanceof EmbeddingDisabledError) {
				err = m['view.composer.quote.disabled']();
			}
			setError(err);
			setIsPublishing(false);
			return;
		}
		if (postUri && !replyTo) {
			postCreated.emit();
		}
		if (composerState.draftId && composerState.originalLocalRefs) {
			cleanupPublishedDraft({
				draftId: composerState.draftId,
				originalLocalRefs: composerState.originalLocalRefs,
			});
		}
		savePostLanguageToHistory();

		// avoid retaining draft media while the app view catches up
		const postCount = filteredThread.posts.length;
		void (async () => {
			let postSuccessData: OnPostSuccessData;
			try {
				if (postUri) {
					const posts = await retry(
						5,
						(_e) => true,
						async () => {
							const data = await ok(
								appview.get('app.bsky.unspecced.getPostThreadV2', {
									params: {
										anchor: postUri,
										above: false,
										below: postCount - 1,
										branchingFactor: 1,
									},
								}),
							);
							if (data.thread.length !== postCount) {
								throw new Error(`composer: app view is not ready`);
							}
							if (!data.thread.every((p) => p.value.$type === 'app.bsky.unspecced.defs#threadItemPost')) {
								throw new Error(`composer: app view returned non-post items`);
							}
							return data.thread;
						},
						1e3,
					);
					postSuccessData = {
						replyToUri: replyTo?.uri,
						posts,
					};
				}
			} catch {}

			onPost?.(postUri);
			onPostSuccess?.(postSuccessData);
		})().catch((e: unknown) => {
			console.error('Composer: post-publish notify failed', e);
		});

		closeComposer();
		setTimeout(() => {
			Toast.show(
				postCount > 1
					? m['view.composer.publish.postsSent']()
					: replyTo
						? m['view.composer.publish.replySent']()
						: m['view.composer.publish.postSent'](),
				{
					action: postUri
						? {
								label: m['view.composer.publish.action.view'](),
								onPress: () => {
									router.navigate({ to: postUriToTarget(postUri) });
								},
							}
						: undefined,
					type: 'success',
				},
			);
		}, 500);
	};

	const handleConfirmSkipEmpty = () => {
		skipEmptyConfirmedRef.current = true;
		void onPressPublish();
	};

	// Preserves the referential identity passed to each post item.
	// Avoids re-rendering all posts on each keystroke.
	const onComposerPostPublish = useNonReactiveCallback(() => {
		void onPressPublish();
	});

	// `publishOnUpload` latches when a publish is queued waiting for video uploads to finish. the upload
	// status is derived during render, and the latch clears via a render-time adjustment the moment uploads
	// reach a terminal state — so neither setState lands in an effect. on a clean completion the publish is
	// armed by bumping a monotonic token; the effect below fires it once after commit.
	const queuedVideoUploadStatus = useMemo((): 'blocked' | 'complete' | 'uploading' => {
		let hasUploadingVideo = false;
		for (const post of thread.posts) {
			if (isEmptyPost(post)) {
				continue;
			}
			if (post.embed.media?.type !== 'video') {
				continue;
			}
			switch (post.embed.media.video.status) {
				case 'done': {
					break;
				}
				case 'error': {
					return 'blocked';
				}
				default: {
					hasUploadingVideo = true;
					break;
				}
			}
		}
		return hasUploadingVideo ? 'uploading' : 'complete';
	}, [thread.posts]);

	if (publishOnUpload) {
		switch (queuedVideoUploadStatus) {
			case 'blocked': {
				setPublishOnUpload(false);
				break;
			}
			case 'complete': {
				setPublishOnUpload(false);
				setUploadCompletionPublishRequest((request) => request + 1);
				break;
			}
			case 'uploading': {
				break;
			}
		}
	}

	// fire the armed publish after commit. the handled-ref makes it idempotent against effect replay or
	// callback identity churn, so `onComposerPostPublish` (a useNonReactiveCallback) won't re-trigger.
	useEffect(() => {
		if (
			uploadCompletionPublishRequest === 0 ||
			handledUploadCompletionPublishRequestRef.current === uploadCompletionPublishRequest
		) {
			return;
		}
		handledUploadCompletionPublishRequestRef.current = uploadCompletionPublishRequest;
		onComposerPostPublish();
	}, [uploadCompletionPublishRequest, onComposerPostPublish]);

	// TODO: It might make more sense to display this error per-post.
	// Right now we're just displaying the first one.
	let erroredVideoPostId: string | undefined;
	let erroredVideo: VideoState | NoVideoState = NO_VIDEO;
	for (let i = 0; i < thread.posts.length; i++) {
		const post = thread.posts[i]!;
		if (post.embed.media?.type === 'video' && post.embed.media.video.status === 'error') {
			erroredVideoPostId = post.id;
			erroredVideo = post.embed.media.video;
			break;
		}
	}

	// The single error to surface: an explicit error string wins over a video-upload error.
	const displayedError: { error: string; detail?: string; onDismiss: () => void } | undefined = error
		? { error, onDismiss: () => setError('') }
		: erroredVideo.status === 'error'
			? {
					error: erroredVideo.error,
					detail: erroredVideo.jobId
						? m['view.composer.video.jobId']({ jobId: erroredVideo.jobId })
						: undefined,
					onDismiss: () => {
						if (erroredVideoPostId) {
							clearVideo(erroredVideoPostId);
						}
					},
				}
			: undefined;

	const scrollViewRef = useRef<HTMLDivElement | null>(null);
	// focus the text input once per focus request. the reducer bumps `activePostFocusRequestId` on
	// focus-requesting actions; this effect consumes each committed request exactly once by tracking
	// the last-handled id in a ref, so no reducer state is mutated after render.
	const handledFocusRequestIdRef = useRef(0);
	useEffect(() => {
		if (composerState.activePostFocusRequestId === handledFocusRequestIdRef.current) {
			return;
		}
		handledFocusRequestIdRef.current = composerState.activePostFocusRequestId;
		textInputRef.current?.focus();
	}, [composerState.activePostFocusRequestId]);

	const isLastThreadedPost = thread.posts.length > 1 && nextPost === undefined;
	const { scrollHandler, isScrolled } = useScrollTracker({
		scrollViewRef,
		stickyBottom: isLastThreadedPost,
	});

	const footer = (
		<>
			<SuggestedLanguage
				text={activePost.text}
				replyToLanguages={replyToLanguages}
				currentLanguages={currentLanguages}
				onAcceptSuggestedLanguage={setAcceptedLanguageSuggestion}
				onNudge={onLanguageNudge}
			/>
			<ComposerPills
				isReply={!!replyTo}
				post={activePost}
				thread={composerState.thread}
				dispatch={composerDispatch}
			/>
			<ComposerFooter
				post={activePost}
				dispatch={dispatch}
				showAddButton={!isEmptyPost(activePost) && (!nextPost || !isEmptyPost(nextPost))}
				onError={setError}
				onSelectVideo={selectVideo}
				onAddPost={() => {
					composerDispatch({
						type: 'addPost',
					});
				}}
				currentLanguages={currentLanguages}
				onSelectLanguage={onSelectLanguage}
				languageNudgeAt={languageNudgeAt}
				textInputRef={textInputRef}
			/>
		</>
	);

	const IS_WEBFooterSticky = thread.posts.length > 1;
	return (
		<>
			<ComposerTopBar
				border={isScrolled}
				canPost={canPost}
				isReply={!!replyTo}
				isPublishQueued={publishOnUpload}
				isPublishing={isPublishing}
				isThread={thread.posts.length > 1}
				onCancel={onRequestClose}
				onPublish={() => void onPressPublish()}
				onSelectDraft={(draftSummary) => void handleSelectDraft(draftSummary)}
				onSaveDraft={saveCurrentDraft}
				onDiscard={handleClearComposer}
				isEmpty={isComposerEmpty}
				isDirty={composerState.isDirty}
				isEditingDraft={!!composerState.draftId}
				canSaveDraft={allPostsWithinLimit}
			/>
			{/* The composer owns its own scrolling (the `Dialog.Body` / `scrollContainer` below) */}
			<Dialog.Body className={styles.dialogBody}>
				<ComposerError.Root>
					{missingAltError && <ComposerError.Box error={missingAltError} />}
					{displayedError && (
						<ComposerError.Box
							error={displayedError.error}
							detail={displayedError.detail}
							onDismiss={displayedError.onDismiss}
						/>
					)}
				</ComposerError.Root>
				<div ref={scrollViewRef} onScroll={scrollHandler} className={styles.scrollContainer}>
					{replyTo ? <ComposerReplyTo replyTo={replyTo} /> : undefined}
					{thread.posts.map((post, index) => (
						<Fragment key={post.id + (composerState.draftId ?? '')}>
							<ComposerPost
								post={post}
								dispatch={composerDispatch}
								textInputRef={post.id === activePost.id ? textInputRef : null}
								isFirstPost={index === 0}
								isLastPost={index === thread.posts.length - 1}
								isPartOfThread={thread.posts.length > 1}
								isReply={index > 0 || !!replyTo}
								isActive={post.id === activePost.id}
								canRemovePost={thread.posts.length > 1}
								canRemoveQuote={index > 0 || !initQuote}
								onSelectVideo={selectVideo}
								onClearVideo={clearVideo}
								onPublish={onComposerPostPublish}
								onError={setError}
							/>
							{IS_WEBFooterSticky && post.id === activePost.id && (
								<div className={styles.stickyFooterWeb}>{footer}</div>
							)}
						</Fragment>
					))}
				</div>
			</Dialog.Body>
			{!IS_WEBFooterSticky && footer}

			{replyTo ? (
				<Prompt.Basic
					handle={discardPromptHandle}
					title={m['view.composer.drafts.discard.title']()}
					confirmButtonCta={m['common.action.discard']()}
					confirmButtonColor="negative"
					onConfirm={closeComposer}
				/>
			) : (
				<Prompt.Outer handle={discardPromptHandle}>
					<Prompt.Content>
						<Prompt.TitleText>
							{allPostsWithinLimit
								? composerState.draftId
									? m['view.composer.drafts.saveChanges.title']()
									: m['view.composer.drafts.save.title']()
								: m['view.composer.discard.title']()}
						</Prompt.TitleText>
						<Prompt.DescriptionText>
							{allPostsWithinLimit
								? composerState.draftId
									? m['view.composer.drafts.saveChanges.message']()
									: m['view.composer.drafts.save.message']()
								: m['view.composer.drafts.error.tooLongFixed']({ max: MAX_DRAFT_GRAPHEME_LENGTH })}
						</Prompt.DescriptionText>
					</Prompt.Content>
					<Prompt.Actions>
						{allPostsWithinLimit && (
							<Prompt.Action
								cta={
									composerState.draftId
										? m['common.action.saveChanges']()
										: m['view.composer.drafts.action.save']()
								}
								onPress={() => void handleSaveDraft()}
								color="primary"
							/>
						)}
						<Prompt.Action
							cta={m['common.action.discard']()}
							onPress={closeComposer}
							color="negative_subtle"
						/>
						<Prompt.Cancel cta={m['view.composer.discard.keepEditing']()} />
					</Prompt.Actions>
				</Prompt.Outer>
			)}

			<Prompt.Basic
				handle={emptyPostsPromptHandle}
				title={m['view.composer.thread.skipEmpty.title']()}
				description={m['view.composer.thread.skipEmpty.message']()}
				confirmButtonCta={m['view.composer.publish.action.anyway']()}
				cancelButtonCta={m['view.composer.discard.keepEditing']()}
				onConfirm={handleConfirmSkipEmpty}
			/>
		</>
	);
};

const ComposerPost = memo(function ComposerPost({
	post,
	dispatch,
	textInputRef,
	isActive,
	isReply,
	isFirstPost,
	isLastPost,
	isPartOfThread,
	canRemovePost,
	canRemoveQuote,
	onClearVideo,
	onSelectVideo,
	onError,
	onPublish,
}: {
	post: PostDraft;
	dispatch: (action: ComposerAction) => void;
	textInputRef: RefObject<TextInputRef | null> | null;
	isActive: boolean;
	isReply: boolean;
	isFirstPost: boolean;
	isLastPost: boolean;
	isPartOfThread: boolean;
	canRemovePost: boolean;
	canRemoveQuote: boolean;
	onClearVideo: (postId: string) => void;
	onSelectVideo: (postId: string, asset: VideoAsset) => void;
	onError: (error: string) => void;
	onPublish: (text: string) => void;
}) {
	const { currentAccount } = useSession();
	const currentDid = currentAccount!.did;
	const { data: currentProfile } = useProfileQuery({ did: currentDid });
	const text = post.text;
	const isTextOnly = !post.embed.link && !post.embed.quote && !post.embed.media;
	const forceMinHeight = isTextOnly && isActive;
	const selectTextInputPlaceholder = isReply
		? isFirstPost
			? m['common.compose.replyPlaceholder']()
			: m['view.composer.thread.action.addPost']()
		: m['common.compose.placeholder']();
	const discardPromptHandle = Prompt.usePromptHandle();

	const dispatchPost = (action: PostAction) => {
		dispatch({
			type: 'updatePost',
			postId: post.id,
			postAction: action,
		});
	};

	const postImagesCount =
		post.embed.media?.type === 'images' || post.embed.media?.type === 'gallery'
			? post.embed.media.images.length
			: 0;
	const onImageAdd = createAddImagesWithCap(postImagesCount, dispatchPost);

	const onNewLink = (uri: string) => {
		dispatchPost({ type: 'embedAddUri', uri });
	};

	const onPhotoPasted = async (blob: Blob) => {
		const mimeType = blob.type;
		if (mimeType.startsWith('video/') || mimeType === 'image/gif') {
			if (!VIDEO_UPLOAD_MIME_TYPES.some((supported) => supported === mimeType)) {
				Toast.show(m['view.composer.video.error.unsupportedType']({ mimeType }), {
					type: 'error',
				});
				return;
			}
			if (mimeType === 'image/gif') {
				const { width, height } = await getImageDimensions(blob);
				onSelectVideo(post.id, { blob, width, height, mimeType, duration: null });
			} else {
				const { width, height, duration } = await getVideoMetadata(blob);
				onSelectVideo(post.id, { blob, width, height, mimeType, duration });
			}
		} else {
			let image: ComposerImage;
			try {
				image = await createComposerImage(blob);
			} catch (e) {
				console.error('createComposerImage failed', blob.type, blob.size, e);
				onError(m['view.composer.gallery.error.paste']());
				return;
			}
			onImageAdd([image]);
		}
	};

	return (
		<GalleryBleed>
			<div className={clsx(styles.postContainer, !isActive && styles.inactivePost)}>
				<UserAvatar
					avatar={currentProfile?.avatar}
					size={36}
					type={currentProfile?.associated?.labeler ? 'labeler' : 'user'}
				/>

				<div className={styles.col}>
					<TextInput
						ref={textInputRef}
						text={text}
						placeholder={selectTextInputPlaceholder}
						autoFocus={isLastPost}
						forceMinHeight={forceMinHeight}
						// To avoid overlap with the close button:
						hasRightPadding={isPartOfThread}
						isActive={isActive}
						setText={(nextText) => {
							dispatchPost({ type: 'updateText', text: nextText });
						}}
						onFocus={() => {
							dispatch({
								type: 'focusPost',
								postId: post.id,
							});
						}}
						onPhotoPasted={(blob) => void onPhotoPasted(blob)}
						onNewLink={onNewLink}
						onError={onError}
						onPressPublish={onPublish}
						accessibilityLabel={m['common.compose.action.write']()}
						accessibilityHint={m['view.composer.text.maxLengthHint']({
							count: MAX_POST_GRAPHEME_LENGTH || 0,
						})}
					/>

					{canRemovePost && isActive && (
						<>
							<Button
								label={m['common.post.delete']()}
								size="small"
								color="secondary"
								variant="ghost"
								shape="round"
								className={styles.remove}
								onClick={() => {
									if (
										post.shortenedGraphemeLength > 0 ||
										post.embed.media ||
										post.embed.link ||
										post.embed.quote
									) {
										discardPromptHandle.open(null);
									} else {
										dispatch({
											type: 'removePost',
											postId: post.id,
										});
									}
								}}
							>
								<ButtonIcon icon={XIcon} />
							</Button>

							<Prompt.Basic
								handle={discardPromptHandle}
								title={m['view.composer.discard.title']()}
								description={m['view.composer.discard.message']()}
								onConfirm={() => {
									dispatch({
										type: 'removePost',
										postId: post.id,
									});
								}}
								confirmButtonCta={m['common.action.discard']()}
								confirmButtonColor="negative"
							/>
						</>
					)}

					<ComposerEmbeds
						canRemoveQuote={canRemoveQuote}
						embed={post.embed}
						dispatch={dispatchPost}
						clearVideo={() => onClearVideo(post.id)}
						text={post.text}
					/>
				</div>
			</div>
		</GalleryBleed>
	);
});

function ComposerEmbeds({
	embed,
	dispatch,
	clearVideo,
	canRemoveQuote,
	text,
}: {
	embed: EmbedDraft;
	dispatch: (action: PostAction) => void;
	clearVideo: () => void;
	canRemoveQuote: boolean;
	text: string;
}) {
	const video = embed.media?.type === 'video' ? embed.media.video : null;
	return (
		<>
			{(embed.media?.type === 'images' || embed.media?.type === 'gallery') && (
				<Gallery images={embed.media.images} dispatch={dispatch} text={text} />
			)}
			{embed.media?.type === 'gif' && (
				<div className={styles.gifContainer} key={embed.media.gif.url}>
					<ExternalEmbedGif gif={embed.media.gif} onRemove={() => dispatch({ type: 'embedRemoveGif' })} />
					<GifAltText
						gif={embed.media.gif}
						altText={embed.media.alt ?? ''}
						onSubmit={(altText: string) => {
							dispatch({ type: 'embedUpdateGif', alt: altText });
						}}
					/>
				</div>
			)}
			{!embed.media && embed.link && (
				<div className={styles.linkContainer} key={embed.link.uri}>
					<ExternalEmbedLink
						uri={embed.link.uri}
						hasQuote={!!embed.quote}
						onRemove={() => dispatch({ type: 'embedRemoveLink' })}
					/>
				</div>
			)}
			{video && (
				<div className={styles.videoContainer}>
					{video.asset ? <VideoPreview asset={video.asset} clear={clearVideo} /> : null}
					<SubtitleDialogBtn
						defaultAltText={video.altText}
						saveAltText={(altText) =>
							dispatch({
								type: 'embedUpdateVideo',
								videoAction: {
									type: 'updateAltText',
									altText,
									signal: video.abortController.signal,
								},
							})
						}
						captions={video.captions}
						setCaptions={(updater) => {
							dispatch({
								type: 'embedUpdateVideo',
								videoAction: {
									type: 'updateCaptions',
									updater,
									signal: video.abortController.signal,
								},
							});
						}}
					/>
				</div>
			)}
			{embed.quote?.uri ? (
				<div className={video ? styles.quoteContainerWithVideo : styles.quoteContainerWithoutVideo}>
					<div style={{ position: 'relative' }}>
						<LazyQuoteEmbed uri={embed.quote.uri} linkDisabled />
						{canRemoveQuote && (
							<ExternalEmbedRemoveBtn
								onRemove={() => dispatch({ type: 'embedRemoveQuote' })}
								className={styles.externalEmbedRemoveBtn}
							/>
						)}
					</div>
				</div>
			) : null}
		</>
	);
}

function useScrollTracker({
	scrollViewRef,
	stickyBottom,
}: {
	scrollViewRef: RefObject<HTMLDivElement | null>;
	stickyBottom: boolean;
}) {
	const [isScrolled, setIsScrolled] = useState(false);
	const contentOffset = useRef(0);
	const scrollViewHeight = useRef(Infinity);
	const contentHeight = useRef(0);

	const scrollHandler = (event: UIEvent<HTMLDivElement>) => {
		const el = event.currentTarget;
		contentOffset.current = Math.floor(el.scrollTop);
		contentHeight.current = Math.floor(el.scrollHeight);
		scrollViewHeight.current = Math.floor(el.clientHeight);

		const scrolled = el.scrollTop > 0;
		setIsScrolled((prev) => {
			if (prev !== scrolled) {
				return scrolled;
			}
			return prev;
		});
	};

	useEffect(() => {
		const el = scrollViewRef.current;
		if (!el) {
			return;
		}

		const handleResize = () => {
			const newContentHeight = Math.floor(el.scrollHeight);
			const oldContentHeight = contentHeight.current;
			let shouldScrollToBottom = false;
			if (stickyBottom && newContentHeight > oldContentHeight) {
				const isFairlyCloseToBottom =
					oldContentHeight - contentOffset.current - 100 <= scrollViewHeight.current;
				if (isFairlyCloseToBottom) {
					shouldScrollToBottom = true;
				}
			}
			contentHeight.current = newContentHeight;
			scrollViewHeight.current = Math.floor(el.clientHeight);
			if (shouldScrollToBottom) {
				el.scrollTo({ top: newContentHeight, behavior: 'smooth' });
			}
		};

		contentHeight.current = Math.floor(el.scrollHeight);
		scrollViewHeight.current = Math.floor(el.clientHeight);

		const observer = new ResizeObserver(handleResize);
		const target = el.firstElementChild || el;
		observer.observe(target);

		return () => {
			observer.disconnect();
		};
	}, [scrollViewRef, stickyBottom]);

	return {
		scrollHandler,
		isScrolled,
	};
}

function isEmptyPost(post: PostDraft) {
	return post.text.trim().length === 0 && !post.embed.media && !post.embed.link && !post.embed.quote;
}

export type CancelRef = {
	/** Returns `true` if the composer should stay open, `false` if the caller should close it. */
	onPressCancel: () => boolean;
};
