import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { Modal } from './components/Modal';
import { AvatarUpload } from './components/AvatarUpload';
import { ProcessingView } from './components/ProcessingView';
import { ResultView } from './components/ResultView';
import { ErrorView } from './components/ErrorView';
import { QuotaBlockedView } from './components/QuotaBlockedView';
import { VirtualTryOnAPI, QuotaExhaustedError, VisitorRateLimitedError } from './api';
import {
	getAvatar,
	saveAvatar,
	clearAvatar,
	purgeLegacyHistory,
	getVisitorId,
	isStorageAvailable,
	hasConsent,
} from './storage';
import { fetchImageAsBase64, compressGarmentImage, UndecodableImageError } from './imageUtils';
import { getTranslations } from './i18n';
import { trackTryOnCompleted } from './events';
import {
	isRetryableTryonReason,
	newRunId,
	productAnalyticsAllowed,
	replaySettings,
	trackProductEvent,
	tryonFailureReason,
} from './analytics';
import { applyConsentDecision, modalIsMounted, startReplay, stopReplay } from './replay';
import { isDemo, demoTryOnLimit } from './demo/config';
import { ModalToolbar } from './demo/ModalToolbar';
import { GalleryView } from './demo/GalleryView';
import { DemoResultView } from './demo/DemoResultView';
import { LeadGateView } from './demo/LeadGateView';
import { SizeFlow } from './components/SizeFlow';
import { SizeAPI } from './sizeApi';
import { addRecommendedVariationToCart } from './woocommerceCart';
import { demoCounterLabel } from './demo/quotaLabel';
import { countDemoTryOns, recordDemoTryOn, remainingDemoTryOns } from './demo/demoCounter';
import { rememberLead, shouldAskForEmail, submitLead } from './demo/leadStore';
import { listTryOns, saveTryOn, deleteTryOn, countTryOns } from './demo/tryOnStore';
import type { WidgetConfig, WidgetState, AvatarData, TryOnHistoryItem } from './types';

interface AppProps {
	config: WidgetConfig;
}

export function App( { config }: AppProps ) {
	const t = useMemo( () => getTranslations( config.locale ), [ config.locale ] );

	const [ isModalOpen, setIsModalOpen ] = useState( false );
	/**
	 * One id per modal open, so a shopper's two try-ons are two funnels rather
	 * than one confused one. In a ref because every emitter reads it from a
	 * closure, and a stale render's value would silently merge the two.
	 */
	const runIdRef = useRef< string >( '' );
	const openCountRef = useRef( 0 );
	const openedAtRef = useRef( 0 );
	const requestIdRef = useRef< string >( '' );
	const requestStartedAtRef = useRef( 0 );
	const attemptRef = useRef( 0 );
	const resultShownAtRef = useRef( 0 );
	const lastFailureReasonRef = useRef< string >( 'unexpected' );
	/** The phase the modal was in when it closed — the drop-off point. */
	const phaseRef = useRef< string >( 'idle' );
	/**
	 * Read inside async callbacks that outlive a render. `isModalOpen` captured
	 * in a closure is the value at closure time, and starting a replay on that
	 * would record a modal the shopper has already closed.
	 */
	const [ widgetState, setWidgetState ] = useState<WidgetState>( 'idle' );
	const [ avatar, setAvatar ] = useState<AvatarData | null>( null );
	const [ progress, setProgress ] = useState( 0 );
	const [ resultImage, setResultImage ] = useState<string | null>( null );
	const [ clothingImage, setClothingImage ] = useState<string | null>( null );
	const [ errorMessage, setErrorMessage ] = useState<string>( '' );
	const [ , setIsSubmitting ] = useState( false );
	const isSubmittingRef = useRef( false );
	const [ cooldownMinutes, setCooldownMinutes ] = useState<number | undefined>( undefined );

	// --- Demo-only state. Inert on every merchant store (see demo/config.ts). ---
	const demo = isDemo( config );
	const tryOnLimit = useMemo( () => demoTryOnLimit( config ), [ config ] );
	const [ galleryItems, setGalleryItems ] = useState<TryOnHistoryItem[]>( [] );
	const [ galleryCount, setGalleryCount ] = useState( 0 );
	const [ demoRemaining, setDemoRemaining ] = useState( () =>
		demo ? remainingDemoTryOns( demoTryOnLimit( config ) ) : 0
	);
	// The photo the visitor was about to try on when the gate interrupted, so
	// giving the email resumes exactly that try-on instead of starting over.
	const gatedAvatarRef = useRef<string | null>( null );

	/**
	 * Two count reads can be in flight at once (one from opening the modal, one
	 * from a try-on that just finished) and IndexedDB does not promise to answer
	 * them in order. Without this, the stale "0" from the open can land after the
	 * fresh "1" and quietly hide the gallery the shopper just filled.
	 */
	const countSeqRef = useRef( 0 );
	const refreshGalleryCount = useCallback( () => {
		const seq = ++countSeqRef.current;
		void countTryOns().then( ( total ) => {
			if ( seq === countSeqRef.current ) {
				setGalleryCount( total );
			}
		} );
	}, [] );

	// Track the current garment image; the bootstrap mutates config.productImage
	// on variation change and dispatches `aisthetix:variation-changed`.
	const productImageRef = useRef<string>( config.productImage );

	// Resolve the per-visitor rate-limit id (X-Aisthetix-Visitor). Prefer the
	// logged-in customer, then the durable first-party visitorKey (the plugin's
	// aisthetix_vid cookie — survives ITP), and finally the localStorage anon id.
	const visitorId = useMemo( () => {
		if ( config.customerId ) {
			return `customer_${ config.customerId }`;
		}
		if ( config.visitorKey ) {
			return `vid_${ config.visitorKey }`;
		}
		return getVisitorId();
	}, [ config.customerId, config.visitorKey ] );

	const api = useMemo(
		() => new VirtualTryOnAPI( config.apiBaseUrl, config.publishableKey, visitorId ),
		[ config.apiBaseUrl, config.publishableKey, visitorId ]
	);
	const sizeApi = useMemo(
		() => new SizeAPI(
			config.sizeApiUrl || `${(config.brainUrl || '').replace(/\/$/, '')}/api/storefront/size`,
			config.sizePhotoQcUrl || `${(config.brainUrl || '').replace(/\/$/, '')}/api/storefront/photo-qc`,
			config.publishableKey,
			visitorId,
			config.customerId
		),
		[ config.sizeApiUrl, config.sizePhotoQcUrl, config.brainUrl, config.publishableKey, config.customerId, visitorId ]
	);

	// Load avatar on mount. Older widgets left megabytes of unread try-on history
	// in localStorage, which is what made saving the avatar start failing — clear
	// it out before we touch storage for anything else.
	useEffect( () => {
		if ( isStorageAvailable() ) {
			purgeLegacyHistory();
			setAvatar( getAvatar() );
		}
	}, [] );

	/**
	 * Replay configuration, from the injected config.
	 *
	 * Served by the plugin rather than baked into the bundle, so replay can be
	 * turned off without a plugin release and a wordpress.org review cycle.
	 */
	const replayConfig = useMemo( () => replaySettings( config ), [ config ] );

	// The consent subscription is set up once; a ref is what lets the listener
	// read the current settings without resubscribing, and resubscribing would
	// drop a decision collected in the gap.
	const replayConfigRef = useRef( replayConfig );
	replayConfigRef.current = replayConfig;

	/**
	 * Start the recorder once the modal is actually on the page.
	 *
	 * An effect rather than a line in the trigger handler, and the difference is
	 * the whole bug: `setIsModalOpen( true )` does not take effect synchronously,
	 * so a ref assigned during render still says "closed" for the rest of that
	 * handler. `startReplay` read it, refused, and — for a shopper whose CMP had
	 * already granted, which is most of them — nothing ever asked again. No
	 * consent-change event follows an unchanged decision.
	 *
	 * It also puts the start AFTER the overlay is in the DOM. Child effects run
	 * before parent effects, so by the time this runs Modal has appended it, and
	 * rrweb's opening snapshot has the element the recording is confined to.
	 *
	 * Still loaded no earlier than this: fetching the SDK is itself a request a
	 * non-consenting shopper never agreed to, and one who never opens the modal
	 * must cost nothing at all.
	 */
	useEffect( () => {
		if ( ! isModalOpen ) return;
		void startReplay( replayConfig, () => modalIsMounted() && productAnalyticsAllowed() );
	}, [ isModalOpen, replayConfig ] );

	/**
	 * Consent, for the whole session.
	 *
	 * The bootstrap watches each supported CMP's own change event and announces
	 * a decision on `aisthetix:consent-changed`. Subscribing is what makes a
	 * withdrawal take effect at once — for a replay that is already recording,
	 * that is the difference between honouring it and ignoring it.
	 */
	useEffect( () => {
		const onConsentChanged = ( e: Event ) => {
			// Both directions. A withdrawal has to stop the recorder at once —
			// but a shopper who opened the modal before the banner was answered
			// and then accepted has to START being recorded, and nothing else in
			// the widget will ever ask again: the start path runs on modal open
			// and has already decided by then.
			const allowed = ( e as CustomEvent ).detail?.productAnalytics === true;
			void applyConsentDecision(
				allowed,
				replayConfigRef.current,
				() => modalIsMounted() && productAnalyticsAllowed()
			);
		};
		document.addEventListener( 'aisthetix:consent-changed', onConsentChanged );
		return () => {
			document.removeEventListener( 'aisthetix:consent-changed', onConsentChanged );
			stopReplay();
		};
	}, [] );

	// Keep the garment image fresh when the shopper switches variation.
	useEffect( () => {
		const handler = ( e: Event ) => {
			const detail = ( e as CustomEvent ).detail;
			if ( detail?.productImage ) {
				productImageRef.current = detail.productImage;
			}
		};
		document.addEventListener( 'aisthetix:variation-changed', handler );
		return () => document.removeEventListener( 'aisthetix:variation-changed', handler );
	}, [] );

	const startTryOnWithAvatar = useCallback(
		async ( avatarImageData: string, gateAlreadyPassed = false ) => {
			// Consent gate: this is the single funnel into generation (trigger-button
			// click on a returning visitor, retry, and the post-upload path all land
			// here), so it must be enforced here rather than only inside AvatarUpload.
			// Without this, a browser with a saved avatar from before consent existed
			// (avatar present, hasConsent() === false) could reach AI generation of a
			// person's photo without ever seeing the consent checkbox. Route back to
			// the AvatarUpload screen instead; the shopper must actively check the
			// box (which persists consent synchronously) before generation can proceed.
			if ( ! hasConsent() ) {
				setWidgetState( 'avatar-upload' );
				return;
			}

			// Demo-only: the first try-on is free, then we ask for the email. Sits
			// here for the same reason as the consent gate above: this is the single
			// funnel into generation, so a returning visitor with a saved photo
			// cannot walk around it.
			if ( ! gateAlreadyPassed && shouldAskForEmail( config, countDemoTryOns() ) ) {
				gatedAvatarRef.current = avatarImageData;
				setWidgetState( 'lead-gate' );
				return;
			}

			// Prevent duplicate submissions across closures.
			if ( isSubmittingRef.current ) {
				return;
			}

			isSubmittingRef.current = true;
			setIsSubmitting( true );
			setWidgetState( 'processing' );
			phaseRef.current = 'processing';
			setProgress( 0 );
			setErrorMessage( '' );

			// One request id per attempt, shared by requested/completed/failed so
			// the three line up into a funnel without a join on time.
			requestIdRef.current = newRunId();
			requestStartedAtRef.current = Date.now();
			attemptRef.current += 1;
			trackProductEvent( 'tryon_requested', {
				productId: config.productId,
				requestId: requestIdRef.current,
				attempt: attemptRef.current,
				runId: runIdRef.current,
			} );

			// Tracked rather than inferred from an error message: which step failed
			// is knowable here and guessable at best from a string.
			let stage: 'photo' | 'inference' | 'render' = 'photo';

			try {
				// Prefer the live (possibly variation-updated) image, then
				// normalize it client-side so it's never refused for size.
				const garmentUrl = productImageRef.current || config.productImage;
				const fetchedGarment = await fetchImageAsBase64( garmentUrl );
				const clothingImageBase64 = await compressGarmentImage( fetchedGarment );
				setClothingImage( clothingImageBase64 );
				setProgress( 10 );

				// Comma-joined category slugs so the brain can route to a
				// category-aware provider (e.g. swimwear); empty when uncategorized.
				const productCategory = ( config.productCategories ?? [] ).join( ',' );

				stage = 'inference';
				// The brain gateway enforces quota/ledger and proxies to the engine.
				const result = await api.tryOnSync( avatarImageData, clothingImageBase64, productCategory, config.productId );
				stage = 'render';

				const resultImageData = `data:image/png;base64,${ result.image }`;
				setResultImage( resultImageData );
				setProgress( 100 );
				setWidgetState( 'completed' );

				// Attribution: a completed try-on is the funnel's try-on anchor
				// (try-on -> productId only). Fire-and-forget; never blocks the UI.
				trackTryOnCompleted( config, config.productId );

				// Product analytics, on its own stricter gate and its own endpoint
				// shape. Separate from the attribution call above, not merged into
				// it: the two answer to different consent decisions, and merging
				// them would make it possible to lose that distinction in a
				// refactor. The brain maps the attribution event once for PostHog,
				// so this is `tryon_completed` reported by the client with the
				// timing the client alone knows.
				trackProductEvent( 'tryon_completed', {
					productId: config.productId,
					requestId: requestIdRef.current,
					durationMs: Date.now() - requestStartedAtRef.current,
					provider: 'unknown',
					cached: false,
					runId: runIdRef.current,
				} );

				resultShownAtRef.current = Date.now();
				phaseRef.current = 'completed';
				trackProductEvent( 'tryon_result_viewed', {
					productId: config.productId,
					requestId: requestIdRef.current,
					timeToViewMs: Date.now() - requestStartedAtRef.current,
					runId: runIdRef.current,
				} );

				// Demo-only: count the spend and file the render in the shopper's own
				// browser. Not awaited on purpose, so a slow (or refused) IndexedDB
				// never delays the picture they are waiting for.
				if ( demo ) {
					recordDemoTryOn();
					setDemoRemaining( remainingDemoTryOns( tryOnLimit ) );
					void saveTryOn( {
						resultImage: resultImageData,
						clothingImage: clothingImageBase64,
						productTitle: config.productTitle,
						productId: config.productId,
						productUrl: config.productUrl,
						createdAt: new Date().toISOString(),
					} ).then( refreshGalleryCount );
				}
			} catch ( error ) {
				// The reason is mapped to a contract enum; the message the shopper
				// sees is never the value reported. A fetch failure's message can
				// carry a signed URL, and a provider's can carry its own prompt.
				const reason = tryonFailureReason( error );
				lastFailureReasonRef.current = reason;
				trackProductEvent( 'tryon_failed', {
					productId: config.productId,
					requestId: requestIdRef.current,
					reason,
					stage,
					retryable: isRetryableTryonReason( reason ),
					durationMs: Date.now() - requestStartedAtRef.current,
					runId: runIdRef.current,
				} );

				// Server-side hard stops map to the dedicated blocked views.
				if ( error instanceof QuotaExhaustedError ) {
					setWidgetState( 'quota-exceeded' );
					phaseRef.current = 'blocked';
				} else if ( error instanceof VisitorRateLimitedError ) {
					setCooldownMinutes( error.cooldownMinutesRemaining );
					setWidgetState( 'visitor-rate-limited' );
					phaseRef.current = 'blocked';
				} else {
					setErrorMessage( getErrorMessage( error ) );
					setWidgetState( 'error' );
					phaseRef.current = 'error';
				}
			} finally {
				isSubmittingRef.current = false;
				setIsSubmitting( false );
			}
		},
		// visitorId flows into the brain via `api` (X-Aisthetix-Visitor header).
		[ api, config, config.productImage, config.productTitle, config.productId, demo, tryOnLimit, refreshGalleryCount ]
	);

	const handleAvatarSaved = useCallback(
		( imageData: string ) => {
			// `source` is the only thing reported about the photo — whether it was
			// freshly chosen or the one already in this browser. The photo itself
			// never leaves the shopper's device except to the try-on endpoint.
			trackProductEvent( 'tryon_photo_selected', {
				source: avatar?.imageData === imageData ? 'saved' : 'upload',
				productId: config.productId,
				runId: runIdRef.current,
			} );

			const newAvatar: AvatarData = {
				imageData,
				createdAt: new Date().toISOString(),
			};
			saveAvatar( imageData );
			setAvatar( newAvatar );
			startTryOnWithAvatar( imageData );
		},
		[ avatar?.imageData, config.productId, startTryOnWithAvatar ]
	);

	// --- Demo-only handlers. None of this is reachable on a merchant store. ---

	/**
	 * The gallery reads IndexedDB only when the modal is opened, so a product
	 * page the shopper never touches pays nothing for it.
	 */
	const refreshGallery = useCallback( async () => {
		const seq = ++countSeqRef.current;
		const items = await listTryOns();
		setGalleryItems( items );
		if ( seq === countSeqRef.current ) {
			setGalleryCount( items.length );
		}
	}, [] );

	/** Where "Back" returns to, so the gallery never strands the shopper. */
	const galleryReturnRef = useRef<WidgetState>( 'avatar-upload' );

	const handleOpenGallery = useCallback( async () => {
		galleryReturnRef.current = widgetState;
		await refreshGallery();
		setWidgetState( 'gallery' );
	}, [ refreshGallery, widgetState ] );

	const handleCloseGallery = useCallback( () => {
		setWidgetState( galleryReturnRef.current );
	}, [] );

	const handleDeleteTryOn = useCallback(
		async ( id: string ) => {
			await deleteTryOn( id );
			await refreshGallery();
		},
		[ refreshGallery ]
	);

	const handleLeadSubmit = useCallback(
		async ( email: string ): Promise<boolean> => {
			if ( ! config.demoLeadUrl ) {
				return false;
			}
			const saved = await submitLead( config.demoLeadUrl, {
				email,
				consent: true,
				productId: config.productId,
				locale: config.locale,
			} );
			if ( ! saved ) {
				return false;
			}
			rememberLead( email );
			const pending = gatedAvatarRef.current;
			gatedAvatarRef.current = null;
			if ( pending ) {
				void startTryOnWithAvatar( pending, true );
			} else {
				setWidgetState( 'avatar-upload' );
			}
			return true;
		},
		[ config.demoLeadUrl, config.productId, config.locale, startTryOnWithAvatar ]
	);

	// What the trigger does, kept in a ref so the listener below can stay bound
	// for the life of the widget. See the effect for why that matters.
	const onTriggerRef = useRef< () => void >( () => {} );
	onTriggerRef.current = () => {
		setIsModalOpen( true );

		// A fresh run id per open: a shopper who tries two garments produces two
		// funnels, not one that appears to loop.
		runIdRef.current = newRunId();
		openCountRef.current += 1;
		openedAtRef.current = Date.now();
		attemptRef.current = 0;
		phaseRef.current = 'avatar_upload';

		const savedPhoto = getAvatar();
		trackProductEvent( 'tryon_modal_opened', {
			productId: config.productId,
			hasSavedPhoto: savedPhoto !== null,
			placement: 'auto',
			runId: runIdRef.current,
			openIndex: openCountRef.current,
		} );

		if ( demo ) {
			setDemoRemaining( remainingDemoTryOns( tryOnLimit ) );
			refreshGalleryCount();
		}
		const currentAvatar = getAvatar();
		if ( currentAvatar ) {
			startTryOnWithAvatar( currentAvatar.imageData );
		} else {
			setWidgetState( 'avatar-upload' );
		}
	};

	// Bind to the trigger button rendered by the plugin, exactly once.
	//
	// This deliberately has no dependencies. bootstrap.js replays the cold first
	// click by calling trigger.click() the moment AISTHETIX_WIDGET_READY turns
	// true; if this effect re-ran, it would remove the listener and clear that
	// flag for an instant, and a replay landing in that gap is dropped with no
	// trace. The shopper sees a button that does nothing on the first press. So
	// the listener goes on once and reads the current behaviour through a ref.
	useEffect( () => {
		const triggerButton = document.getElementById( 'aisthetix-tryon-trigger' );
		if ( ! triggerButton ) {
			return;
		}
		const handleClick = () => onTriggerRef.current();
		triggerButton.addEventListener( 'click', handleClick );
		window.AISTHETIX_WIDGET_READY = true;
		return () => {
			triggerButton.removeEventListener( 'click', handleClick );
			window.AISTHETIX_WIDGET_READY = false;
		};
	}, [] );

	const handleRetry = useCallback( () => {
		trackProductEvent( 'tryon_retry_requested', {
			reason: lastFailureReasonRef.current,
			attempt: attemptRef.current + 1,
			productId: config.productId,
			runId: runIdRef.current,
		} );
		if ( avatar ) {
			startTryOnWithAvatar( avatar.imageData );
		} else {
			setWidgetState( 'avatar-upload' );
		}
	}, [ avatar, config.productId, startTryOnWithAvatar ] );

	const handleTryAnother = useCallback( () => {
		clearAvatar();
		setAvatar( null );
		setResultImage( null );
		setWidgetState( 'avatar-upload' );
	}, [] );

	const handleRecommendedAddToCart = useCallback( async ( variantId: string, size: string ) => {
		const selection = config.variationSelections?.[ variantId ];
		const added = await addRecommendedVariationToCart( variantId, selection );
		if ( ! added ) return false;
		trackProductEvent( 'tryon_added_to_cart', {
			productId: config.productId,
			variantId,
			recommendedSizeUsed: size,
			requestId: requestIdRef.current,
			runId: runIdRef.current,
		} );
		return true;
	}, [ config.productId, config.variationSelections ] );

	const handleClose = useCallback( () => {
		// Emitted BEFORE the state reset below: the phase the shopper left from
		// is the whole point of the event, and the reset erases it.
		if ( resultShownAtRef.current > 0 && requestIdRef.current ) {
			trackProductEvent( 'tryon_result_dismissed', {
				requestId: requestIdRef.current,
				dwellMs: Date.now() - resultShownAtRef.current,
				phase: phaseRef.current,
				productId: config.productId,
				runId: runIdRef.current,
			} );
		}
		trackProductEvent( 'tryon_modal_closed', {
			phase: phaseRef.current,
			dwellMs: openedAtRef.current > 0 ? Date.now() - openedAtRef.current : 0,
			productId: config.productId,
			runId: runIdRef.current,
			openIndex: openCountRef.current,
		} );

		// The recorder stops with the modal, not with the page: everything
		// outside this modal belongs to the merchant's storefront, not to us.
		stopReplay();

		setIsModalOpen( false );
		setTimeout( () => {
			setWidgetState( 'idle' );
			setProgress( 0 );
			setResultImage( null );
			setClothingImage( null );
			setErrorMessage( '' );
			resultShownAtRef.current = 0;
			phaseRef.current = 'idle';
		}, 300 );
	}, [ config.productId ] );

	const getErrorMessage = ( error: unknown ): string => {
		if ( error instanceof UndecodableImageError ) {
			return t.errors.imageLoad;
		}
		if ( ! ( error instanceof Error ) ) {
			return t.errors.unexpected;
		}
		const message = error.message.toLowerCase();
		if ( message.includes( 'failed to fetch' ) || message.includes( 'cors' ) ) {
			return t.errors.network;
		}
		if ( message.includes( 'failed to fetch image' ) || message.includes( 'could not load image' ) ) {
			return t.errors.imageLoad;
		}
		if ( message.includes( 'timed out' ) ) {
			return t.errors.timeout;
		}
		if ( message.includes( 'api error' ) || message.includes( '401' ) || message.includes( '403' ) ) {
			return t.errors.server;
		}
		return t.errors.unexpected;
	};

	const getModalTitle = () => {
		switch ( widgetState ) {
			case 'avatar-upload':
				return t.modal.uploadPhoto;
			case 'processing':
				return t.modal.creatingTryOn;
			case 'completed':
				return t.modal.yourTryOn;
			case 'error':
				return t.modal.oops;
			case 'quota-exceeded':
			case 'visitor-rate-limited':
				return t.modal.virtualTryOn;
			case 'gallery':
				return t.gallery.title;
			case 'lead-gate':
				return t.lead.title;
			default:
				return t.modal.virtualTryOn;
		}
	};

	// The counter is deliberately on every screen, including the upload and the
	// wait: the point of it is that running out is never a surprise.
	const quotaLabel = demo ? demoCounterLabel( demoRemaining, tryOnLimit, t ) : null;

	return (
		<Modal isOpen={ isModalOpen } onClose={ handleClose } title={ getModalTitle() }>
			{ demo && widgetState !== 'gallery' && (
				<ModalToolbar
					quotaLabel={ quotaLabel }
					galleryCount={ galleryCount }
					onOpenGallery={ () => void handleOpenGallery() }
					t={ t.gallery }
				/>
			) }

			{ demo && widgetState === 'gallery' && (
				<GalleryView
					items={ galleryItems }
					onBack={ handleCloseGallery }
					onDelete={ ( id ) => void handleDeleteTryOn( id ) }
					t={ t.gallery }
					shareT={ t.share }
				/>
			) }

			{ demo && widgetState === 'lead-gate' && (
				<LeadGateView
					onSubmit={ handleLeadSubmit }
					onSkip={ handleClose }
					t={ t.lead }
				/>
			) }

			{ widgetState === 'quota-exceeded' && (
				<QuotaBlockedView
					status="quota_exhausted"
					planName={ null }
					onClose={ handleClose }
					t={ t.quota }
					customContent={ config.unavailableMessage }
				/>
			) }

			{ widgetState === 'visitor-rate-limited' && (
				<QuotaBlockedView
					status="visitor_rate_limited"
					planName={ null }
					cooldownMinutesRemaining={ cooldownMinutes }
					onClose={ handleClose }
					t={ t.quota }
				/>
			) }

			{ widgetState === 'avatar-upload' && (
				<AvatarUpload
					onAvatarSaved={ handleAvatarSaved }
					onCancel={ handleClose }
					t={ t.upload }
					initialPreview={ avatar?.imageData ?? null }
				/>
			) }

			{ widgetState === 'processing' && (
				<ProcessingView
					progress={ progress }
					clothingImage={ clothingImage || undefined }
					t={ t.processing }
				/>
			) }

			{ widgetState === 'completed' && resultImage && ! demo && (
				<>
					<ResultView
						resultImage={ resultImage }
						originalClothingImage={ clothingImage || undefined }
						productTitle={ config.productTitle }
						onTryAnother={ handleTryAnother }
						onClose={ handleClose }
						t={ t.result }
					/>
					<SizeFlow
						t={ t }
						api={ sizeApi }
						productId={ config.productId }
						productCategories={ config.productCategories ?? [] }
						productVariants={ config.productVariants ?? [] }
						sizeConfig={ config.size ?? {} }
						photo={ avatar?.imageData ?? '' }
						onAddToCart={ handleRecommendedAddToCart }
						runId={ runIdRef.current }
					/>
				</>
			) }

			{ widgetState === 'completed' && resultImage && demo && (
				<DemoResultView
					resultImage={ resultImage }
					originalClothingImage={ clothingImage || undefined }
					productTitle={ config.productTitle }
					productUrl={ config.productUrl }
					onTryAnother={ handleTryAnother }
					onClose={ handleClose }
					t={ t.result }
					shareT={ t.share }
				/>
			) }

			{ widgetState === 'error' && (
				<ErrorView
					message={ errorMessage }
					onRetry={ handleRetry }
					onClose={ handleClose }
					t={ t.error }
				/>
			) }
		</Modal>
	);
}
