import { useEffect, useRef, useState } from 'react';
import { ArrowSquareOut, Play } from '@phosphor-icons/react';
import { useAction } from 'convex/react';
import { api } from '../../../../../convex/_generated/api';
import { usePlatform } from '../../platform';
import { PausedGif } from './PausedGif';
import styles from './LinkEmbed.module.css';

interface UrlPreview {
	title?: string;
	description?: string;
	imageUrl?: string;
	siteName?: string;
	imageWidth?: number;
	imageHeight?: number;
}

const VIDEO_HOSTS = [
	'youtube.com',
	'youtu.be',
	'www.youtube.com',
	'vimeo.com',
	'www.vimeo.com',
	'twitch.tv',
	'www.twitch.tv',
	'dailymotion.com',
	'www.dailymotion.com',
];

const DIRECT_VIDEO_EXTS = ['.mp4', '.webm', '.ogg', '.mov'];
const DIRECT_IMAGE_EXTS = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'];

function isDirectMedia(url: string): 'video' | 'image' | null {
	try {
		const pathname = new URL(url).pathname.toLowerCase();
		if (DIRECT_VIDEO_EXTS.some((ext) => pathname.endsWith(ext))) return 'video';
		if (DIRECT_IMAGE_EXTS.some((ext) => pathname.endsWith(ext))) return 'image';
	} catch {}
	return null;
}

function isVideoUrl(url: string): boolean {
	try {
		const hostname = new URL(url).hostname;
		return VIDEO_HOSTS.some((h) => hostname === h || hostname.endsWith('.' + h));
	} catch {
		return false;
	}
}

// Module-scope cache so we don't refetch the same URL every re-render.
// `null` means "we tried and there's no preview" — we cache that too to
// avoid hammering the fetcher for URLs that will never resolve.
const previewCache = new Map<string, UrlPreview | null>();

// Natural dimensions learned on first successful <img> onLoad for every
// embed image (OG preview image, direct image URL). Populates the
// reserved-box aspect-ratio so the next mount of the same URL goes
// straight to its real proportions instead of the fixed fallback.
const embedImageDimsCache = new Map<string, { w: number; h: number }>();

// Fallback box for an embed image whose dimensions we haven't probed
// yet. Roughly matches the common OG-image aspect ratio (~1.91:1 for
// Twitter/Facebook card images). Fixed-size fallback > fluid fallback
// because a wrong fluid ratio shifts height when the real image lands.
const EMBED_IMG_FALLBACK = { w: 400, h: 210 } as const;

// Direct inline video embeds default to 16:9 — most web video ships at
// that ratio. A wrong default just means a little blank space above or
// below the video, not a scroll jump.
const DIRECT_VIDEO_FALLBACK_RATIO = '16 / 9';

function normaliseMetadata(raw: any): UrlPreview | null {
	if (!raw || typeof raw !== 'object') return null;

	// Accept a few possible shapes: the Electron preload IPC returns
	// `{ title, description, image, siteName, url }`; matrix-js-sdk style
	// returns `{ 'og:title', 'og:description', 'og:image', 'og:site_name' }`.
	const title =
		raw.title ?? raw['og:title'] ?? raw.ogTitle ?? undefined;
	const description =
		raw.description ?? raw['og:description'] ?? raw.ogDescription ?? undefined;
	const imageUrl =
		raw.image ?? raw.imageUrl ?? raw['og:image'] ?? raw.ogImage ?? undefined;
	const siteName =
		raw.siteName ?? raw['og:site_name'] ?? raw.ogSiteName ?? undefined;
	// Dimensions come from the Convex action's `imageWidth`/`imageHeight`
	// fields (parsed from og:image:width / og:image:height on the server).
	// Fall through a few other naming conventions in case a platform-
	// native fetcher emits the OG keys verbatim.
	const pickNum = (v: unknown): number | undefined => {
		if (typeof v === 'number' && Number.isFinite(v) && v > 0) return v;
		if (typeof v === 'string') {
			const n = Number(v);
			if (Number.isFinite(n) && n > 0) return n;
		}
		return undefined;
	};
	const imageWidth =
		pickNum(raw.imageWidth) ??
		pickNum(raw['og:image:width']) ??
		pickNum(raw.ogImageWidth);
	const imageHeight =
		pickNum(raw.imageHeight) ??
		pickNum(raw['og:image:height']) ??
		pickNum(raw.ogImageHeight);

	if (!title && !description && !imageUrl) return null;
	return { title, description, imageUrl, siteName, imageWidth, imageHeight };
}

function useUrlPreview(url: string): UrlPreview | null {
	const platform = usePlatform();
	const fetchPreviewAction = useAction(api.links.fetchPreview);
	const [preview, setPreview] = useState<UrlPreview | null>(
		previewCache.get(url) ?? null,
	);

	useEffect(() => {
		if (previewCache.has(url)) {
			const cached = previewCache.get(url) ?? null;
			// Same cache-seeding as the fresh-fetch branch below — ensures
			// the reserved image box is correct even when the preview came
			// out of the module cache on a re-render.
			if (
				cached?.imageUrl &&
				cached.imageWidth &&
				cached.imageHeight &&
				!embedImageDimsCache.has(cached.imageUrl)
			) {
				embedImageDimsCache.set(cached.imageUrl, {
					w: cached.imageWidth,
					h: cached.imageHeight,
				});
			}
			setPreview(cached);
			return;
		}

		let cancelled = false;

		(async () => {
			try {
				// Prefer the platform-native fetcher when available (Electron
				// ships one via IPC — no CORS, no round-trip through the
				// backend). On web, `fetchMetadata` returns null due to CORS,
				// so we fall through to the Convex Node action which performs
				// the fetch server-side.
				let result: UrlPreview | null = null;
				const fetcher = platform?.links?.fetchMetadata;
				if (typeof fetcher === 'function') {
					try {
						const raw = await fetcher(url);
						result = normaliseMetadata(raw);
					} catch {
						result = null;
					}
				}
				if (!result) {
					try {
						const raw = await fetchPreviewAction({ url });
						if (!cancelled) result = normaliseMetadata(raw);
					} catch {
						result = null;
					}
				}
				if (cancelled) return;
				previewCache.set(url, result);
				// Server-provided image dimensions populate the same
				// cache the <img> onLoad handler updates — so the
				// reserved box is correct on the very first paint of
				// the preview card, not only after the image finishes
				// decoding. Falls through to the onLoad probe if the
				// server didn't have width/height tags.
				if (
					result?.imageUrl &&
					result.imageWidth &&
					result.imageHeight &&
					!embedImageDimsCache.has(result.imageUrl)
				) {
					embedImageDimsCache.set(result.imageUrl, {
						w: result.imageWidth,
						h: result.imageHeight,
					});
				}
				setPreview(result);
			} catch {
				if (!cancelled) previewCache.set(url, null);
			}
		})();

		return () => {
			cancelled = true;
		};
	}, [url, platform, fetchPreviewAction]);

	return preview;
}

function DirectMediaEmbed({
	url,
	type,
	onOpenGif,
}: {
	url: string;
	type: 'video' | 'image';
	onOpenGif?: (url: string) => void;
}) {
	const videoRef = useRef<HTMLVideoElement>(null);
	const [playing, setPlaying] = useState(false);

	if (type === 'video') {
		const handlePlay = () => {
			if (!videoRef.current) return;
			videoRef.current.controls = true;
			void videoRef.current.play();
			setPlaying(true);
		};

		// `preload="metadata"` leaves the <video> element at zero height
		// until `loadedmetadata` fires — that was a measurable source of
		// scroll jump. Wrap it in an aspect-ratio box so the space is
		// reserved from the first paint. 16:9 is the overwhelming majority
		// of web video; when the real metadata lands and differs slightly
		// the ResizeObserver catches it, but the gross box is already
		// there.
		return (
			<div className={`${styles.embed} ${styles.embedBare}`}>
				<div
					className={styles.directVideoWrapper}
					style={{
						aspectRatio: DIRECT_VIDEO_FALLBACK_RATIO,
						width: 400,
						maxWidth: '100%',
					}}
				>
					<video
						ref={videoRef}
						className={styles.directVideo}
						src={url}
						preload="metadata"
						style={{ width: '100%', height: '100%' }}
						onLoadedMetadata={() => {
							window.dispatchEvent(
								new CustomEvent('brycord:attachment-loaded'),
							);
						}}
						onPause={() => {
							if (videoRef.current && videoRef.current.ended) {
								videoRef.current.controls = false;
								setPlaying(false);
							}
						}}
						onEnded={() => {
							if (videoRef.current) {
								videoRef.current.controls = false;
								setPlaying(false);
							}
						}}
					/>
					{!playing && (
						<button
							type="button"
							className={styles.directPlayOverlay}
							onClick={handlePlay}
						>
							<Play size={36} weight="fill" />
						</button>
					)}
				</div>
			</div>
		);
	}

	// GIFs (and other direct images) render through PausedGif so
	// the idle state freezes on the first frame and only animates
	// on hover. Click opens a fullscreen viewer via `onOpenGif` —
	// the parent MessageGroup owns the lightbox state and reuses
	// its existing ImageLightbox.
	const isGif = /\.gif(\?|#|$)/i.test(url);
	if (isGif) {
		return (
			<PausedGif url={url} onOpen={onOpenGif} className={styles.directGif} />
		);
	}

	// Direct image embed: reserve a box from the probed cache (or a
	// fixed fallback) so the image lands in a slot of known height
	// instead of expanding the wrapper from zero. `loading="lazy"` was
	// here but removed — a direct-image embed is always rendered in
	// view when it first mounts, and the deferred decode defeats the
	// scroll anchor window we're trying to hold onto.
	const probed = embedImageDimsCache.get(url) ?? EMBED_IMG_FALLBACK;
	const boxW = Math.min(probed.w, 400);
	const boxH = Math.round(boxW * (probed.h / probed.w));
	return (
		<div className={`${styles.embed} ${styles.embedBare}`}>
			<a href={url} target="_blank" rel="noopener noreferrer">
				<img
					className={styles.directImage}
					src={url}
					alt=""
					decoding="async"
					width={boxW}
					height={boxH}
					style={{
						aspectRatio: `${probed.w} / ${probed.h}`,
					}}
					onLoad={(e) => {
						const img = e.currentTarget;
						const w = img.naturalWidth;
						const h = img.naturalHeight;
						if (w > 0 && h > 0) {
							embedImageDimsCache.set(url, { w, h });
						}
						window.dispatchEvent(
							new CustomEvent('brycord:attachment-loaded'),
						);
					}}
				/>
			</a>
		</div>
	);
}

interface LinkEmbedProps {
	url: string;
	/** Called when the user clicks a paused GIF preview. The parent
	 *  owns the fullscreen viewer state (MessageGroup reuses its
	 *  existing ImageLightbox). Ignored for non-GIF embeds. */
	onOpenGif?: (url: string) => void;
}

export function LinkEmbed({ url, onOpenGif }: LinkEmbedProps) {
	// Thin router: direct media gets its own component, rich previews
	// get theirs. Keeping the two paths in separate component instances
	// means `useUrlPreview` (which internally wraps `useAction` and
	// therefore `useMemo`) is never called conditionally — otherwise a
	// URL change that flipped `isDirectMedia` would mutate the hook
	// count and trigger React error #310.
	const directType = isDirectMedia(url);
	if (directType) {
		return <DirectMediaEmbed url={url} type={directType} onOpenGif={onOpenGif} />;
	}
	return <UrlPreviewEmbed url={url} />;
}

function UrlPreviewEmbed({ url }: { url: string }) {
	const preview = useUrlPreview(url);

	// If the platform has no metadata fetcher (or it returned null / threw),
	// degrade gracefully to nothing — the raw link is already rendered in
	// the message body by MessageContent.
	if (!preview) return null;

	const isVideo = isVideoUrl(url);
	const hasImage = !!preview.imageUrl;

	return (
		<div className={styles.embed}>
			<div className={styles.grid}>
				<div className={styles.embedContent}>
					{preview.siteName && (
						<div className={styles.provider}>{preview.siteName}</div>
					)}

					{preview.title && (
						<a
							className={styles.title}
							href={url}
							target="_blank"
							rel="noopener noreferrer"
						>
							{preview.title}
						</a>
					)}

					{preview.description && (
						<div className={styles.description}>{preview.description}</div>
					)}

					{hasImage && (() => {
						// Reserve a box for the OG image so the card's
						// final height is known before the image loads.
						// Without this the card appears title-first, then
						// expands downward as the image decodes — the
						// classic link-preview height shift.
						const imgUrl = preview.imageUrl!;
						const probed =
							embedImageDimsCache.get(imgUrl) ?? EMBED_IMG_FALLBACK;
						return (
							<div
								className={styles.mediaContainer}
								style={{
									aspectRatio: `${probed.w} / ${probed.h}`,
									// max-height matches .mediaImage CSS so
									// the container never exceeds the
									// image's own cap and the reserved
									// space matches the rendered space.
									maxHeight: 300,
									width: '100%',
								}}
							>
								<img
									className={styles.mediaImage}
									src={imgUrl}
									alt={preview.title || ''}
									decoding="async"
									style={{
										width: '100%',
										height: '100%',
										objectFit: 'cover',
									}}
									onLoad={(e) => {
										const img = e.currentTarget;
										const w = img.naturalWidth;
										const h = img.naturalHeight;
										if (w > 0 && h > 0) {
											embedImageDimsCache.set(imgUrl, { w, h });
										}
										window.dispatchEvent(
											new CustomEvent('brycord:attachment-loaded'),
										);
									}}
								/>
								{isVideo && (
									<a
										className={styles.mediaOverlay}
										href={url}
										target="_blank"
										rel="noopener noreferrer"
									>
										<div className={styles.mediaControls}>
											<button type="button" className={styles.playButton}>
												<Play size={28} weight="fill" />
											</button>
											<button type="button" className={styles.openButton}>
												<ArrowSquareOut size={22} />
											</button>
										</div>
									</a>
								)}
							</div>
						);
					})()}
				</div>
			</div>
		</div>
	);
}

export default LinkEmbed;
