import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { IPreviewUrlResponse } from 'matrix-js-sdk';
import {
  Box,
  Chip,
  Icon,
  IconButton,
  Icons,
  Scroll,
  Spinner,
  Text,
  as,
  color,
  config,
} from 'folds';
import { ImageOverlay } from '../ImageOverlay';
import { MobileTouchTarget } from '../../styles/mobile.css';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { UrlPreview, UrlPreviewContent, UrlPreviewDescription, UrlPreviewImg } from './UrlPreview';
import {
  getIntersectionObserverEntry,
  useIntersectionObserver,
} from '../../hooks/useIntersectionObserver';
import * as css from './UrlPreviewCard.css';
import * as previewCss from './UrlPreview.css';
import { tryDecodeURIComponent } from '../../utils/dom';
import { mxcUrlToHttp } from '../../utils/matrix';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { ImageViewer } from '../image-viewer';
import { onEnterOrSpace } from '../../utils/keyboard';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import {
  extractEmbedHeight,
  getSteamTarget,
  getTikTokVideoId,
  getTweetId,
  isTikTokLink,
  MediaEmbed,
  parseMediaEmbed,
  steamWidgetEmbedUrl,
  tiktokIdFromOembed,
  tiktokOembedUrl,
  tiktokPlayerEmbedUrl,
} from '../../utils/videoEmbed';

const linkStyles = { color: color.Success.Main };

// ---------------------------------------------------------------------------
// Provider brand identity colors.
// These are official brand-palette values (logos, badges), NOT theme colors,
// so they intentionally stay as fixed hex and must not become --lt-* TDS vars.
// ---------------------------------------------------------------------------
const BRAND_COLORS = {
  tiktok: '#EE1D52',
  spotify: '#1db954',
  steam: '#c7d5e0',
  twitch: '#9146ff',
  reddit: '#ff4500',
  discord: '#5865f2',
  npm: '#cb3837',
  stackOverflow: '#f48024',
} as const;

// ---------------------------------------------------------------------------
// Helpers — URL parsing & variant detection
// ---------------------------------------------------------------------------

type CardVariant =
  | 'tiktok'
  | 'github'
  | 'twitter'
  | 'reddit'
  | 'spotify'
  | 'twitch'
  | 'steam'
  | 'wikipedia'
  | 'discord'
  | 'npm'
  | 'stackoverflow'
  | 'imdb'
  | 'giphy'
  | 'tenor'
  | 'generic';

function isTikTok(url: string): boolean {
  try {
    const { hostname } = new URL(url);
    const h = hostname.replace(/^www\./, '');
    return h === 'tiktok.com' || h === 'vm.tiktok.com';
  } catch {
    return false;
  }
}

function getTikTokUsername(url: string): string | null {
  try {
    const { pathname } = new URL(url);
    const m = pathname.match(/\/((@[^/]+))/);
    return m ? m[1] : null;
  } catch {
    return null;
  }
}

function isGitHubRepo(url: string): boolean {
  try {
    const parsed = new URL(url);
    const { hostname, pathname } = parsed;
    if (hostname !== 'github.com' && hostname !== 'www.github.com') return false;
    // Exactly two path segments: /<owner>/<repo>  (no deeper pages)
    const parts = pathname.replace(/\/$/, '').split('/').filter(Boolean);
    return parts.length === 2;
  } catch {
    return false;
  }
}

// Keep these hosts + the /status(es) pattern in sync with getTweetId
// (videoEmbed.ts): otherwise a mobile.twitter.com / legacy /statuses/ tweet has
// an extractable id but never routes to the Twitter card or "View post" embed.
const TWITTER_HOSTS = new Set(['twitter.com', 'x.com', 'mobile.twitter.com']);

function isTwitter(url: string): boolean {
  try {
    const { hostname } = new URL(url);
    return TWITTER_HOSTS.has(hostname.replace(/^www\./, ''));
  } catch {
    return false;
  }
}

function isTwitterTweet(url: string): boolean {
  try {
    const { hostname, pathname } = new URL(url);
    if (!TWITTER_HOSTS.has(hostname.replace(/^www\./, ''))) return false;
    return /\/status(?:es)?\/\d+/.test(pathname);
  } catch {
    return false;
  }
}

function getRedditInfo(url: string): {
  subreddit: string | null;
  isPost: boolean;
  isUser: boolean;
  postId: string | null;
} {
  try {
    const { hostname, pathname } = new URL(url);
    const h = hostname.replace(/^www\./, '');
    if (h !== 'reddit.com' && h !== 'redd.it') {
      return { subreddit: null, isPost: false, isUser: false, postId: null };
    }
    const subMatch = pathname.match(/^\/r\/([^/]+)/);
    const subreddit = subMatch ? subMatch[1] : null;
    const postMatch = pathname.match(/\/comments\/([^/]+)/);
    const postId = postMatch ? postMatch[1] : null;
    const userMatch = pathname.match(/^\/(u|user)\/([^/]+)/);
    return {
      subreddit,
      isPost: !!postId,
      isUser: !!userMatch,
      postId,
    };
  } catch {
    return { subreddit: null, isPost: false, isUser: false, postId: null };
  }
}

function getRedditSubreddit(url: string): string | null {
  return getRedditInfo(url).subreddit;
}

function getSpotifyType(url: string): string | null {
  try {
    const { hostname, pathname } = new URL(url);
    if (hostname !== 'open.spotify.com') return null;
    const m = pathname.match(/^\/(track|album|playlist|artist)\//);
    return m ? m[1] : null;
  } catch {
    return null;
  }
}

function getTwitchType(url: string): 'live' | 'clip' | 'vod' | null {
  try {
    const { hostname, pathname } = new URL(url);
    const h = hostname.replace(/^www\./, '');
    if (h === 'clips.twitch.tv') return 'clip';
    if (h !== 'twitch.tv') return null;
    if (pathname.match(/\/[^/]+\/clip\//)) return 'clip';
    if (pathname.match(/\/[^/]+\/v(?:ideos?)?\//)) return 'vod';
    // Single path segment = channel
    const parts = pathname.replace(/\/$/, '').split('/').filter(Boolean);
    if (parts.length === 1) return 'live';
    return null;
  } catch {
    return null;
  }
}

function getTwitchChannel(url: string): string | null {
  try {
    const { hostname, pathname } = new URL(url);
    const h = hostname.replace(/^www\./, '');
    if (h === 'clips.twitch.tv') return null;
    if (h !== 'twitch.tv') return null;
    const parts = pathname.replace(/\/$/, '').split('/').filter(Boolean);
    return parts[0] ?? null;
  } catch {
    return null;
  }
}

function isTwitch(url: string): boolean {
  try {
    const { hostname } = new URL(url);
    const h = hostname.replace(/^www\./, '');
    return h === 'twitch.tv' || h === 'clips.twitch.tv';
  } catch {
    return false;
  }
}

function isWikipedia(url: string): boolean {
  try {
    const { hostname, pathname } = new URL(url);
    return hostname.endsWith('.wikipedia.org') && pathname.startsWith('/wiki/');
  } catch {
    return false;
  }
}

function isDiscordInvite(url: string): boolean {
  try {
    const { hostname, pathname } = new URL(url);
    const h = hostname.replace(/^www\./, '');
    return (
      (h === 'discord.gg' || h === 'discord.com') &&
      (pathname.startsWith('/invite/') || h === 'discord.gg')
    );
  } catch {
    return false;
  }
}

function isNpm(url: string): boolean {
  try {
    const { hostname, pathname } = new URL(url);
    const h = hostname.replace(/^www\./, '');
    return h === 'npmjs.com' && pathname.startsWith('/package/');
  } catch {
    return false;
  }
}

function isStackOverflow(url: string): boolean {
  try {
    const { hostname, pathname } = new URL(url);
    const h = hostname.replace(/^www\./, '');
    return h === 'stackoverflow.com' && pathname.startsWith('/questions/');
  } catch {
    return false;
  }
}

function isImdb(url: string): boolean {
  try {
    const { hostname, pathname } = new URL(url);
    const h = hostname.replace(/^www\./, '');
    return h === 'imdb.com' && pathname.startsWith('/title/');
  } catch {
    return false;
  }
}

function isGiphy(url: string): boolean {
  try {
    const { hostname, pathname } = new URL(url);
    const h = hostname.replace(/^www\./, '');
    if (h === 'gph.is') return true;
    if (h === 'giphy.com' || h === 'media.giphy.com') {
      return (
        pathname.startsWith('/gifs/') ||
        pathname.startsWith('/clips/') ||
        pathname.startsWith('/media/')
      );
    }
    return false;
  } catch {
    return false;
  }
}

function isTenor(url: string): boolean {
  try {
    const { hostname, pathname } = new URL(url);
    const h = hostname.replace(/^www\./, '');
    return h === 'tenor.com' && pathname.startsWith('/view/');
  } catch {
    return false;
  }
}

// Synapse's thumbnailer flattens animated images to a single still frame, so a
// GIF served from /thumbnail renders but never plays. Detect GIF previews so the
// card can point at /download (the original) instead.
function isGifPreview(url: string, prev: IPreviewUrlResponse): boolean {
  if (prev['og:image:type'] === 'image/gif') return true;
  try {
    return new URL(url).pathname.toLowerCase().endsWith('.gif');
  } catch {
    return false;
  }
}

// Ceiling on the /download upgrade below: a self-hosted GIF can be hundreds of
// MB, and unlike a thumbnail it is served unscaled. Past the cap we keep the
// (frozen) thumbnail — the card still links out, so the GIF is one click away.
const GIF_ORIGINAL_MAX_BYTES = 10 * 1024 * 1024;

// Should this preview's image be fetched whole (so it animates) rather than
// thumbnailed? Size is advisory: Synapse usually reports it, and when it's
// absent we prefer a working animation over a hypothetical huge file.
function shouldServeGifOriginal(url: string, prev: IPreviewUrlResponse): boolean {
  if (!isGifPreview(url, prev)) return false;
  const size = prev['matrix:image:size'];
  return typeof size !== 'number' || size <= GIF_ORIGINAL_MAX_BYTES;
}

function getCardVariant(url: string): CardVariant {
  // NOTE: embeddable providers (YouTube/Vimeo/TikTok/Spotify/Twitch/…) are handled
  // upstream by parseMediaEmbed + MediaEmbedCard; getCardVariant only routes the
  // non-embed fallbacks (incl. TikTok short links with no resolvable id).
  if (isTikTok(url)) return 'tiktok';
  if (isGitHubRepo(url)) return 'github';
  if (isTwitter(url)) return 'twitter';
  if (getRedditSubreddit(url) !== null) return 'reddit';
  if (getSpotifyType(url) !== null) return 'spotify';
  if (isTwitch(url)) return 'twitch';
  if (getSteamTarget(url)) return 'steam';
  if (isWikipedia(url)) return 'wikipedia';
  if (isDiscordInvite(url)) return 'discord';
  if (isNpm(url)) return 'npm';
  if (isStackOverflow(url)) return 'stackoverflow';
  if (isImdb(url)) return 'imdb';
  if (isGiphy(url)) return 'giphy';
  if (isTenor(url)) return 'tenor';
  return 'generic';
}

function getDomain(url: string): string {
  try {
    return new URL(url).hostname.replace(/^www\./, '');
  } catch {
    return url;
  }
}

// ---------------------------------------------------------------------------
// Parsing helpers for individual cards
// ---------------------------------------------------------------------------

interface ParsedTweet {
  name: string;
  handle?: string;
  text?: string;
}

function parseTweetTitle(ogTitle: string): ParsedTweet {
  // Pattern 1: "Name on X: \"tweet text\"" or "Name on Twitter: \"tweet text\""
  const onXMatch = ogTitle.match(/^(.+?) on (?:X|Twitter):\s*["""](.+)["""]?$/s);
  if (onXMatch) return { name: onXMatch[1], text: onXMatch[2] };

  // Pattern 2: "X / Name (@handle)" or "Twitter / Name (@handle)"
  const xSlashMatch = ogTitle.match(/^(?:X|Twitter)\s*\/\s*(.+?)\s*\(@(.+?)\)/);
  if (xSlashMatch) return { name: xSlashMatch[1], handle: xSlashMatch[2] };

  // Pattern 3: "Name (@handle): text" or "Name (@handle)"
  const handleMatch = ogTitle.match(/^(.+?)\s*\(@(.+?)\)(?::\s*(.+))?$/s);
  if (handleMatch) return { name: handleMatch[1], handle: handleMatch[2], text: handleMatch[3] };

  return { name: ogTitle };
}

function parseTikTokTitle(ogTitle: string): { username: string | null; caption: string | null } {
  // Format 1: "@username on TikTok: "caption""
  const fmt1 = ogTitle.match(/^(@\S+)\s+on\s+TikTok:\s*["""](.+)["""]?$/s);
  if (fmt1) return { username: fmt1[1], caption: fmt1[2] };

  // Format 2: "username TikTok | caption"
  const fmt2 = ogTitle.match(/^(.+?)\s+TikTok\s*\|\s*(.+)$/s);
  if (fmt2) return { username: fmt2[1], caption: fmt2[2] };

  // Format 3: just caption
  return { username: null, caption: ogTitle || null };
}

function extractHashtags(text: string): string[] {
  const found: string[] = [];
  const re = /#\w+/g;
  let m = re.exec(text);
  while (m !== null && found.length < 5) {
    found.push(m[0]);
    m = re.exec(text);
  }
  return found;
}

function parseRedditMeta(ogDescription: string): {
  author: string | null;
  upvotes: string | null;
  comments: string | null;
} {
  let author: string | null = null;
  let upvotes: string | null = null;
  let comments: string | null = null;

  const authorM = ogDescription.match(/Posted\s+by\s+u\/(\S+)/i);
  if (authorM) author = authorM[1];

  const pointsM = ogDescription.match(/([\d,.]+[kKmM]?)\s+(?:points?|upvotes?)/i);
  if (pointsM) upvotes = pointsM[1];

  const commentsM = ogDescription.match(/([\d,.]+[kKmM]?)\s+comments?/i);
  if (commentsM) comments = commentsM[1];

  return { author, upvotes, comments };
}

function parseTwitchGame(ogDescription: string): string | null {
  const m = ogDescription.match(/play(?:ing)?\s+(.+?)(?:\s*[-–—]|\s*\d+\s+viewer|$)/i);
  return m ? m[1].trim() : null;
}

// ---------------------------------------------------------------------------
// Inline SVG logos
// ---------------------------------------------------------------------------

function GitHubIcon({ size = 24 }: { size?: number }) {
  return (
    <svg
      width={size}
      height={size}
      viewBox="0 0 24 24"
      fill="currentColor"
      aria-hidden="true"
      role="img"
    >
      <path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
    </svg>
  );
}

function XLogoIcon({ size = 20 }: { size?: number }) {
  return (
    <svg
      width={size}
      height={size}
      viewBox="0 0 24 24"
      fill="currentColor"
      aria-hidden="true"
      role="img"
    >
      <path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-4.714-6.231-5.401 6.231H2.744l7.737-8.847L2 2.25h6.957l4.265 5.64L18.244 2.25zm-1.161 17.52h1.833L7.084 4.126H5.117L17.083 19.77z" />
    </svg>
  );
}

// ---------------------------------------------------------------------------
// Shared badge component
// ---------------------------------------------------------------------------

function SiteBadge({ label, colorClass }: { label: string; colorClass: string }) {
  return (
    <span className={`${previewCss.SiteBadge} ${colorClass}`} aria-label={label}>
      {label}
    </span>
  );
}

// ---------------------------------------------------------------------------
// Card: TikTok (fallback for short vm.tiktok.com links that can't resolve an id)
// ---------------------------------------------------------------------------

function TikTokCard({
  url,
  prev,
  mx,
  useAuthentication,
}: {
  url: string;
  prev: IPreviewUrlResponse;
  mx: ReturnType<typeof useMatrixClient>;
  useAuthentication: boolean;
}) {
  const rawTitle = (prev['og:title'] as string | undefined) ?? '';
  const rawDescription = (prev['og:description'] as string | undefined) ?? '';
  const mxcImage = prev['og:image'] as string | undefined;

  const thumbSrc = mxcImage
    ? mxcUrlToHttp(mx, mxcImage, useAuthentication, 160, 284, 'scale', false)
    : null;

  const { username: parsedUsername, caption: parsedCaption } = parseTikTokTitle(rawTitle);
  const usernameFromUrl = getTikTokUsername(url);
  const username = parsedUsername ?? usernameFromUrl;
  const caption = parsedCaption ?? rawDescription;

  // Extract hashtags from caption and description combined
  const hashtagSource = `${caption ?? ''} ${rawDescription}`;
  const hashtags = extractHashtags(hashtagSource);

  // Strip hashtags from caption display
  const captionDisplay = caption ? caption.replace(/#\w+/g, '').trim() : '';

  return (
    <>
      {/* Header */}
      <div className={previewCss.ShortsHeader}>
        {/* TikTok badge with musical note icon */}
        <span className={`${previewCss.SiteBadge} ${previewCss.BadgeTikTok}`}>
          <Icon
            style={{ color: BRAND_COLORS.tiktok, marginRight: '2px' }}
            size="Inherit"
            src={Icons.VolumeHigh}
          />
          TikTok
        </span>
        {username && (
          <Text size="T200" priority="300" style={{ opacity: 0.75 }}>
            {username}
          </Text>
        )}
        <Box grow="Yes" />
        <Text
          size="T200"
          priority="300"
          style={{ opacity: 0.45 }}
          as="a"
          href={url}
          target="_blank"
          rel="noreferrer"
        >
          tiktok.com
        </Text>
      </div>
      {/* Body */}
      <div className={previewCss.PortraitSideLayout}>
        {thumbSrc ? (
          <a
            href={url}
            target="_blank"
            rel="noreferrer"
            className={previewCss.PortraitThumbnail}
            aria-label={`Watch TikTok: ${caption ?? ''}`}
          >
            <img
              className={previewCss.PortraitThumbnailImg}
              src={thumbSrc}
              alt={captionDisplay}
              loading="lazy"
            />
            <div className={previewCss.PortraitPlayOverlay}>
              <div className={previewCss.PortraitPlayButton}>
                <Icon size="Inherit" src={Icons.Play} />
              </div>
            </div>
          </a>
        ) : (
          <div className={previewCss.PortraitPlaceholder}>
            <Icon style={{ color: BRAND_COLORS.tiktok }} size="Inherit" src={Icons.VolumeHigh} />
          </div>
        )}
        <Box grow="Yes" direction="Column" gap="100">
          {captionDisplay && (
            <Text
              size="T300"
              priority="400"
              style={{
                display: '-webkit-box',
                WebkitLineClamp: 3,
                WebkitBoxOrient: 'vertical',
                overflow: 'hidden',
              }}
            >
              {captionDisplay}
            </Text>
          )}
          {hashtags.length > 0 && (
            <div>
              {hashtags.map((tag) => (
                <span key={tag} className={previewCss.HashtagChip}>
                  {tag}
                </span>
              ))}
            </div>
          )}
        </Box>
      </div>
    </>
  );
}

// ---------------------------------------------------------------------------
// Card 3: Twitter / X
// ---------------------------------------------------------------------------

// Origins allowed to drive a self-resizing embed (stable refs → effect deps).
const TWITTER_ORIGINS = ['https://platform.twitter.com', 'https://platform.x.com'];
const INSTAGRAM_ORIGINS = ['https://www.instagram.com'];
const REDDIT_ORIGINS = ['https://embed.reddit.com'];
const BLUESKY_ORIGINS = ['https://embed.bsky.app'];
const NO_RESIZE_ORIGINS: string[] = [];
const EMBED_MAX_HEIGHT = 1400;

// Defense-in-depth on top of the CSP frame-src allowlist. Critically OMITS
// allow-top-navigation, so a compromised embed can't redirect the whole app
// (phishing). Fullscreen still works — it's gated by allow=, not a sandbox token.
const EMBED_SANDBOX =
  'allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox allow-presentation';

// Variable-height iframe embeds (Twitter/Instagram/Reddit) report their content
// height via postMessage; listen — scoped to the allowed origins + OUR iframe —
// and grow to fit. `origins` must be a stable reference.
function useIframeAutoHeight(origins: string[], initial: number) {
  const ref = useRef<HTMLIFrameElement | null>(null);
  const [height, setHeight] = useState(initial);
  useEffect(() => {
    if (origins.length === 0) return undefined;
    const onMessage = (e: MessageEvent) => {
      if (!origins.includes(e.origin)) return;
      if (ref.current && e.source !== ref.current.contentWindow) return;
      let data: unknown = e.data;
      if (typeof data === 'string') {
        try {
          data = JSON.parse(data);
        } catch {
          return;
        }
      }
      const h = extractEmbedHeight(data);
      if (typeof h === 'number' && h > 0) setHeight(Math.min(Math.ceil(h), EMBED_MAX_HEIGHT));
    };
    window.addEventListener('message', onMessage);
    return () => window.removeEventListener('message', onMessage);
  }, [origins]);
  return { ref, height };
}

// Interactive X/Twitter post embed — playable video/GIF, galleries, quote tweets.
// Self-sizes via useIframeAutoHeight. Only mounted after "View post" (facade).
function TweetEmbed({ id }: { id: string }) {
  const { ref, height } = useIframeAutoHeight(TWITTER_ORIGINS, 320);
  const theme =
    typeof window !== 'undefined' &&
    window.matchMedia &&
    window.matchMedia('(prefers-color-scheme: light)').matches
      ? 'light'
      : 'dark';

  return (
    <iframe
      ref={ref}
      className={previewCss.EmbedIframeStatic}
      src={`https://platform.twitter.com/embed/Tweet.html?id=${encodeURIComponent(
        id,
      )}&theme=${theme}&dnt=true`}
      title="Post on X"
      style={{ height: `${height}px` }}
      sandbox={EMBED_SANDBOX}
      allow="autoplay; encrypted-media; picture-in-picture; fullscreen"
      loading="lazy"
      scrolling="no"
    />
  );
}

function TwitterCard({
  url,
  prev,
  mx,
  useAuthentication,
}: {
  url: string;
  prev: IPreviewUrlResponse;
  mx: ReturnType<typeof useMatrixClient>;
  useAuthentication: boolean;
}) {
  const [inlineMediaEmbeds] = useSetting(settingsAtom, 'inlineMediaEmbeds');
  const [expanded, setExpanded] = useState(false);

  const rawTitle = (prev['og:title'] as string | undefined) ?? '';
  const rawDescription = (prev['og:description'] as string | undefined) ?? '';
  const mxcImage = prev['og:image'] as string | undefined;
  const imageWidth = prev['og:image:width'] as number | undefined;

  const isTweet = isTwitterTweet(url);
  const tweetId = getTweetId(url);
  const { name, handle, text } = parseTweetTitle(rawTitle);

  const tweetText = text ?? (isTweet ? rawDescription : undefined);

  // Show media image if og:image is wide (>= 300px or unspecified with a tweet URL)
  const showMedia = !!mxcImage && (imageWidth === undefined || imageWidth >= 300) && isTweet;
  const mediaThumbSrc = showMedia
    ? mxcUrlToHttp(mx, mxcImage!, useAuthentication, 400, 200, 'scale', false)
    : null;

  const canEmbed = isTweet && inlineMediaEmbeds && !!tweetId;

  const header = (
    <div className={previewCss.TwitterHeader}>
      <XLogoIcon size={18} />
      <Text priority="400" style={{ fontWeight: 600 }} truncate>
        {name}
      </Text>
      {handle && (
        <Text size="T200" priority="300" style={{ opacity: 0.55 }} truncate>
          @{handle}
        </Text>
      )}
      <Box grow="Yes" />
      <SiteBadge label="𝕏" colorClass={previewCss.BadgeTwitter} />
    </div>
  );

  // Expanded: the interactive, self-sizing post embed (playable video, gallery…).
  if (canEmbed && expanded && tweetId) {
    return (
      <>
        <div className={previewCss.TwitterHeader}>
          <XLogoIcon size={18} />
          <Text priority="400" style={{ fontWeight: 600 }} truncate>
            {name}
          </Text>
          {handle && (
            <Text size="T200" priority="300" style={{ opacity: 0.55 }} truncate>
              @{handle}
            </Text>
          )}
          <Box grow="Yes" />
          <IconButton
            size="300"
            radii="300"
            variant="SurfaceVariant"
            className={MobileTouchTarget}
            onClick={() => setExpanded(false)}
            aria-label="Collapse post"
          >
            <Icon size="100" src={Icons.Cross} />
          </IconButton>
        </div>
        <UrlPreviewContent>
          <TweetEmbed id={tweetId} />
        </UrlPreviewContent>
      </>
    );
  }

  return (
    <>
      {header}

      <UrlPreviewContent>
        {isTweet && tweetText ? (
          <div className={previewCss.TweetBlock}>{tweetText}</div>
        ) : (
          !isTweet &&
          rawDescription && (
            <Text size="T200" priority="300">
              <UrlPreviewDescription>{rawDescription}</UrlPreviewDescription>
            </Text>
          )
        )}

        {mediaThumbSrc && (
          <img
            className={previewCss.TweetMediaImg}
            src={mediaThumbSrc}
            alt={tweetText ?? name}
            loading="lazy"
          />
        )}

        <Box alignItems="Center" gap="200" justifyContent="SpaceBetween">
          <Text
            size="T200"
            priority="300"
            style={{ opacity: 0.5 }}
            as="a"
            href={url}
            target="_blank"
            rel="noreferrer"
          >
            {getDomain(url)}
          </Text>
          {canEmbed && (
            <Chip
              variant="Secondary"
              radii="Pill"
              className={MobileTouchTarget}
              onClick={() => setExpanded(true)}
              before={<Icon size="50" src={Icons.Play} />}
            >
              <Text size="T200">View post</Text>
            </Chip>
          )}
        </Box>
      </UrlPreviewContent>
    </>
  );
}

// ---------------------------------------------------------------------------
// Card 4: Twitch
// ---------------------------------------------------------------------------

function TwitchCard({
  url,
  prev,
  mx,
  useAuthentication,
}: {
  url: string;
  prev: IPreviewUrlResponse;
  mx: ReturnType<typeof useMatrixClient>;
  useAuthentication: boolean;
}) {
  const title = (prev['og:title'] as string | undefined) ?? '';
  const description = (prev['og:description'] as string | undefined) ?? '';
  const mxcImage = prev['og:image'] as string | undefined;

  const twitchType = getTwitchType(url) ?? 'live';
  const channel = getTwitchChannel(url);
  const isLive = twitchType === 'live';

  const game = description ? parseTwitchGame(description) : null;

  const thumbSrc = mxcImage
    ? mxcUrlToHttp(mx, mxcImage, useAuthentication, 480, 270, 'scale', false)
    : null;

  return (
    <>
      {/* Thumbnail */}
      {thumbSrc ? (
        <a
          href={url}
          target="_blank"
          rel="noreferrer"
          className={previewCss.TwitchThumbnailWrapper}
          aria-label={`Watch on Twitch: ${title}`}
        >
          <img
            className={previewCss.TwitchThumbnailImg}
            src={thumbSrc}
            alt={title}
            loading="lazy"
          />
          {isLive ? (
            <div className={previewCss.TwitchLiveOverlay}>
              <div className={previewCss.LiveDot} />
              LIVE
            </div>
          ) : (
            <div className={previewCss.MediaPlayOverlay}>
              <div className={previewCss.MediaPlayButton}>
                <Icon size="400" src={Icons.Play} />
              </div>
            </div>
          )}
        </a>
      ) : (
        <Box
          shrink="No"
          alignItems="Center"
          justifyContent="Center"
          className={previewCss.IconWrapper}
        >
          <svg
            width={28}
            height={28}
            viewBox="0 0 24 24"
            fill={BRAND_COLORS.twitch}
            aria-hidden="true"
            role="img"
          >
            <path d="M11.64 5.93h1.43v4.28h-1.43m3.93-4.28H17v4.28h-1.43M7 2L3.43 5.57v12.86h4.28V22l3.58-3.57h2.85L20.57 12V2m-1.43 9.29-2.85 2.85h-2.86l-2.5 2.5v-2.5H7.71V3.43h11.43z" />
          </svg>
        </Box>
      )}

      {/* Content */}
      <UrlPreviewContent>
        <Box alignItems="Center" gap="100" wrap="Wrap">
          <SiteBadge label="Twitch" colorClass={previewCss.BadgeTwitch} />
          {channel && (
            <Text priority="400" style={{ fontWeight: 600 }}>
              {channel}
            </Text>
          )}
          {isLive && (
            <span className={previewCss.TwitchLiveBadge} style={{ marginLeft: '4px' }}>
              LIVE
            </span>
          )}
        </Box>
        {title && (
          <Text truncate priority="400">
            <b>{title}</b>
          </Text>
        )}
        {game && (
          <Text size="T200" priority="300" style={{ opacity: 0.65 }}>
            🎮 {game}
          </Text>
        )}
        {!game && description && (
          <Text size="T200" priority="300">
            <UrlPreviewDescription>{description}</UrlPreviewDescription>
          </Text>
        )}
      </UrlPreviewContent>
    </>
  );
}

// ---------------------------------------------------------------------------
// Card 5: Reddit
// ---------------------------------------------------------------------------

function RedditCard({
  url,
  prev,
  mx,
  useAuthentication,
}: {
  url: string;
  prev: IPreviewUrlResponse;
  mx: ReturnType<typeof useMatrixClient>;
  useAuthentication: boolean;
}) {
  const title = (prev['og:title'] as string | undefined) ?? '';
  const description = (prev['og:description'] as string | undefined) ?? '';
  const mxcImage = prev['og:image'] as string | undefined;

  const { subreddit, isPost, isUser } = getRedditInfo(url);
  const { author, upvotes, comments } = parseRedditMeta(description);

  // Only render post thumbnail if we have a real image
  const thumbSrc =
    mxcImage && isPost
      ? mxcUrlToHttp(mx, mxcImage, useAuthentication, 160, 120, 'scale', false)
      : null;

  // Text preview: strip "Posted by u/..." metadata prefix
  const textPreview = description
    .replace(/Posted\s+by\s+u\/\S+\s*[·•]?\s*/i, '')
    .replace(/\s*\d+[\d,.]*(k|M)?\s+(points?|upvotes?)\s*[·•]?\s*/gi, '')
    .replace(/\s*\d+[\d,.]*(k|M)?\s+comments?\s*/gi, '')
    .trim();

  if (isUser) {
    // Simple user card
    return (
      <>
        <Box
          shrink="No"
          alignItems="Center"
          justifyContent="Center"
          className={previewCss.IconWrapper}
        >
          <svg
            width={28}
            height={28}
            viewBox="0 0 24 24"
            fill={BRAND_COLORS.reddit}
            aria-hidden="true"
            role="img"
          >
            <circle cx="12" cy="12" r="12" fill={BRAND_COLORS.reddit} />
            <path
              fill="#ffffff"
              d="M20 12a2 2 0 0 0-2-2 2 2 0 0 0-1.36.54C15.28 9.8 13.72 9.4 12 9.39l.7-3.26 2.23.47a1.4 1.4 0 1 0 .15-.69l-2.5-.52a.25.25 0 0 0-.29.19l-.78 3.65c-1.74.06-3.3.46-4.63 1.17A2 2 0 0 0 4 12a2 2 0 0 0 1.07 1.76 3.5 3.5 0 0 0 0 .49C5.07 16.69 8.27 19 12.07 19s6.97-2.31 6.97-4.75a3.5 3.5 0 0 0 0-.47A2 2 0 0 0 20 12zm-13 2a1 1 0 1 1 2 0 1 1 0 0 1-2 0zm5.59 2.71c-.72.72-2.1.77-2.53.77s-1.82-.05-2.53-.77a.25.25 0 0 1 .35-.35c.46.46 1.48.62 2.18.62s1.72-.16 2.18-.62a.25.25 0 0 1 .35.35zm-.15-1.71a1 1 0 1 1 2 0 1 1 0 0 1-2 0z"
            />
          </svg>
        </Box>
        <UrlPreviewContent>
          <Text
            style={linkStyles}
            truncate
            as="a"
            href={url}
            target="_blank"
            rel="noreferrer"
            size="T200"
            priority="300"
          >
            <SiteBadge label="Reddit" colorClass={previewCss.BadgeReddit} />
          </Text>
          {title && (
            <Text truncate priority="400">
              <b>{title}</b>
            </Text>
          )}
          {description && (
            <Text size="T200" priority="300">
              <UrlPreviewDescription>{description}</UrlPreviewDescription>
            </Text>
          )}
        </UrlPreviewContent>
      </>
    );
  }

  return (
    <Box direction="Column" style={{ width: '100%' }}>
      {/* Meta header */}
      <Box
        alignItems="Center"
        gap="100"
        style={{
          padding: `${config.space.S100} ${config.space.S200}`,
          paddingTop: config.space.S200,
          flexWrap: 'wrap',
        }}
      >
        {subreddit && <span className={previewCss.RedditSubBadge}>r/{subreddit}</span>}
        {author && (
          <Text size="T200" priority="300" style={{ opacity: 0.65 }}>
            · Posted by u/{author}
          </Text>
        )}
      </Box>

      {/* Main content row */}
      <div className={previewCss.RedditPostLayout}>
        <Box grow="Yes" direction="Column" gap="100">
          {title && (
            <Text
              priority="400"
              style={{
                fontWeight: 700,
                display: '-webkit-box',
                WebkitLineClamp: 3,
                WebkitBoxOrient: 'vertical',
                overflow: 'hidden',
              }}
            >
              {title}
            </Text>
          )}
          {textPreview && textPreview !== title && (
            <Text size="T200" priority="300" style={{ opacity: 0.7 }}>
              <UrlPreviewDescription>{textPreview}</UrlPreviewDescription>
            </Text>
          )}
          {/* Stats row */}
          <div className={previewCss.RedditMeta}>
            {upvotes && <span className={previewCss.RedditUpvote}>▲ {upvotes}</span>}
            {comments && (
              <span>
                <Icon size="Inherit" src={Icons.Message} /> {comments}
              </span>
            )}
          </div>
        </Box>

        {thumbSrc && (
          <img className={previewCss.RedditThumb} src={thumbSrc} alt={title} loading="lazy" />
        )}
      </div>
    </Box>
  );
}

// ---------------------------------------------------------------------------
// Remaining card variants (unchanged from original)
// ---------------------------------------------------------------------------

// Media-forward video tile with a privacy-friendly click-to-play facade: shows
// the homeserver's cached thumbnail + a play button; only on click does it swap
// in the (cookie-less) YouTube / Vimeo iframe, so nothing loads from the third
// party until the user presses play. Gated by the `inlineMediaEmbeds` setting —
// when off it falls back to a link that opens the video in a new tab.
const EMBED_BADGE: Record<string, { label: string; class: string }> = {
  youtube: { label: 'YouTube', class: '' },
  vimeo: { label: 'Vimeo', class: previewCss.BadgeVimeo },
  tiktok: { label: 'TikTok', class: previewCss.BadgeTikTok },
  dailymotion: { label: 'Dailymotion', class: previewCss.BadgeDailymotion },
  streamable: { label: 'Streamable', class: previewCss.BadgeStreamable },
  twitch: { label: 'Twitch', class: previewCss.BadgeTwitchPurple },
  spotify: { label: 'Spotify', class: previewCss.BadgeSpotify },
  soundcloud: { label: 'SoundCloud', class: previewCss.BadgeSoundCloud },
  applemusic: { label: 'Apple Music', class: previewCss.BadgeAppleMusic },
  tidal: { label: 'TIDAL', class: previewCss.BadgeTidal },
  instagram: { label: 'Instagram', class: previewCss.BadgeInstagram },
  reddit: { label: 'Reddit', class: previewCss.BadgeReddit },
  bluesky: { label: 'Bluesky', class: previewCss.BadgeBluesky },
  loom: { label: 'Loom', class: previewCss.BadgeLoom },
  kick: { label: 'Kick', class: previewCss.BadgeKick },
  mixcloud: { label: 'Mixcloud', class: previewCss.BadgeMixcloud },
  deezer: { label: 'Deezer', class: previewCss.BadgeDeezer },
};

// The homeserver preview for some sites (notably Reddit) comes back as a bot-check
// "please wait for verification" page; don't surface that as the caption.
const looksLikeBotWall = (s: string): boolean =>
  /please wait|are you a (human|robot)|verifying|verification|just a moment|attention required/i.test(
    s,
  );

// One media-forward tile for every embeddable provider (video / portrait / audio).
// Privacy-friendly facade: shows the homeserver's cached thumbnail + a play
// button; only on click does it swap in the provider iframe, so nothing loads
// from the third party until the user presses play. Gated by `inlineMediaEmbeds`
// — when off it falls back to a link that opens the media in a new tab.
function MediaEmbedCard({
  url,
  prev,
  embed,
}: {
  url: string;
  prev: IPreviewUrlResponse;
  embed: MediaEmbed;
}) {
  const mx = useMatrixClient();
  const useAuthentication = useMediaAuthentication();
  const [inlineMediaEmbeds] = useSetting(settingsAtom, 'inlineMediaEmbeds');
  const [playing, setPlaying] = useState(false);
  const mediaRef = useRef<HTMLDivElement>(null);

  const rawTitle = prev['og:title'] ?? '';
  const rawDescription = prev['og:description'] ?? '';
  const portrait = embed.kind === 'portrait';
  const video = embed.kind === 'landscape' || embed.kind === 'portrait';
  const rich = embed.kind === 'rich';
  const fixedHeight = embed.kind === 'audio' || embed.kind === 'rich';
  // Instagram/Reddit report their height via postMessage → self-resize.
  const resizeOrigins = !rich
    ? NO_RESIZE_ORIGINS
    : embed.provider === 'instagram'
      ? INSTAGRAM_ORIGINS
      : embed.provider === 'bluesky'
        ? BLUESKY_ORIGINS
        : REDDIT_ORIGINS;
  // Only subscribe to resize messages while the iframe is actually mounted.
  const { ref: resizeRef, height: resizeHeight } = useIframeAutoHeight(
    playing ? resizeOrigins : NO_RESIZE_ORIGINS,
    embed.height ?? 480,
  );
  // 'rich' providers (esp. Reddit) can carry a bot-wall title — suppress it.
  const title = looksLikeBotWall(rawTitle) ? '' : rawTitle;
  const description = looksLikeBotWall(rawDescription) ? '' : rawDescription;
  const mxcImage = prev['og:image'] as string | undefined;
  const thumbnailUrl = mxcImage
    ? mxcUrlToHttp(
        mx,
        mxcImage,
        useAuthentication,
        portrait ? 320 : 480,
        portrait ? 568 : 270,
        'scale',
        false,
      )
    : undefined;

  // Shorts share the youtube provider but get their own badge.
  const badge =
    embed.provider === 'youtube' && portrait
      ? { label: 'Shorts', class: previewCss.BadgeYouTubeShorts }
      : (EMBED_BADGE[embed.provider] ?? { label: embed.provider, class: '' });

  const mediaClass = fixedHeight
    ? previewCss.EmbedMediaAudio
    : portrait
      ? previewCss.EmbedMediaPortrait
      : previewCss.EmbedMediaLandscape;
  const mediaStyle = fixedHeight ? { height: `${embed.height ?? 152}px` } : undefined;

  const thumb = thumbnailUrl ? (
    // alt="" — the parent button/link already carries the accessible name.
    <img className={previewCss.MediaThumbnailImg} src={thumbnailUrl} alt="" loading="lazy" />
  ) : (
    <span className={previewCss.EmbedPlaceholder}>
      <Icon size="600" src={Icons.Play} />
    </span>
  );
  const playOverlay = (
    <span className={previewCss.MediaPlayOverlay}>
      <span className={previewCss.MediaPlayButton}>
        <Icon size="400" src={Icons.Play} />
      </span>
    </span>
  );

  const enterFullscreen = () => {
    mediaRef.current?.requestFullscreen?.().catch(() => undefined);
  };

  return (
    <Box direction="Column">
      {playing && rich ? (
        // Rich post embeds (Instagram/Reddit) are variable-height and self-size.
        <iframe
          ref={resizeRef}
          className={previewCss.EmbedIframeStatic}
          src={embed.embedUrl}
          title={title || badge.label}
          style={{ height: `${resizeHeight}px` }}
          allow="autoplay; encrypted-media; picture-in-picture; fullscreen; clipboard-write"
          sandbox={EMBED_SANDBOX}
          allowFullScreen
          loading="lazy"
          scrolling="no"
        />
      ) : (
        <div ref={mediaRef} className={mediaClass} style={mediaStyle}>
          {playing ? (
            <iframe
              className={previewCss.EmbedIframe}
              src={embed.embedUrl}
              title={title || badge.label}
              allow="autoplay; encrypted-media; picture-in-picture; fullscreen; clipboard-write"
              sandbox={EMBED_SANDBOX}
              allowFullScreen
              loading="lazy"
            />
          ) : inlineMediaEmbeds ? (
            <button
              type="button"
              className={previewCss.EmbedFacade}
              onClick={() => setPlaying(true)}
              aria-label={`Play: ${title || badge.label}`}
            >
              {thumb}
              {playOverlay}
            </button>
          ) : (
            <a
              href={url}
              target="_blank"
              rel="noreferrer"
              className={previewCss.EmbedFacade}
              aria-label={title ? `Open on ${badge.label}: ${title}` : `Open on ${badge.label}`}
            >
              {thumb}
              {playOverlay}
            </a>
          )}
        </div>
      )}
      <UrlPreviewContent>
        <Box alignItems="Center" gap="200" justifyContent="SpaceBetween">
          <Text
            style={linkStyles}
            truncate
            as="a"
            href={url}
            target="_blank"
            rel="noreferrer"
            size="T200"
            priority="300"
          >
            <SiteBadge label={badge.label} colorClass={badge.class} />
          </Text>
          {playing && (
            <Box alignItems="Center" gap="100" shrink="No">
              {video && (
                <Chip
                  variant="Secondary"
                  radii="Pill"
                  className={MobileTouchTarget}
                  onClick={enterFullscreen}
                  aria-label="Fullscreen"
                >
                  <Text size="T200">⛶ Fullscreen</Text>
                </Chip>
              )}
              <IconButton
                size="300"
                radii="300"
                variant="SurfaceVariant"
                className={MobileTouchTarget}
                onClick={() => setPlaying(false)}
                aria-label="Close player"
              >
                <Icon size="100" src={Icons.Cross} />
              </IconButton>
            </Box>
          )}
        </Box>
        {/* While a video plays, drop the title/description so the player isn't
            squeezed by text below it. */}
        {!(playing && video) && title && (
          <Text truncate priority="400">
            <b>{title}</b>
          </Text>
        )}
        {!(playing && video) && description && (
          <Text size="T200" priority="300">
            <UrlPreviewDescription>{description}</UrlPreviewDescription>
          </Text>
        )}
      </UrlPreviewContent>
    </Box>
  );
}

// TikTok needs its own card: short "copy-link" URLs (vm.tiktok.com, tiktok.com/t/…)
// don't carry the video id, and the homeserver's link preview is bot-walled. So we
// resolve the id client-side via TikTok's CORS-enabled oEmbed API (on click, to
// keep the facade privacy model), then play the player/v1 embed.
function TikTokEmbedCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
  const mx = useMatrixClient();
  const useAuthentication = useMediaAuthentication();
  const [inlineMediaEmbeds] = useSetting(settingsAtom, 'inlineMediaEmbeds');
  const [videoId, setVideoId] = useState<string | null>(() => getTikTokVideoId(url));
  const [playing, setPlaying] = useState(false);
  const [resolving, setResolving] = useState(false);
  const [failed, setFailed] = useState(false);
  const mediaRef = useRef<HTMLDivElement>(null);
  const abortRef = useRef<AbortController | null>(null);

  // Abort an in-flight oEmbed resolve if the card unmounts (scrolled away).
  useEffect(() => () => abortRef.current?.abort(), []);

  const mxcImage = prev['og:image'] as string | undefined;
  const rawTitle = prev['og:title'] ?? '';
  const title = looksLikeBotWall(rawTitle) ? '' : rawTitle;
  const thumbnailUrl = mxcImage
    ? mxcUrlToHttp(mx, mxcImage, useAuthentication, 320, 568, 'scale', false)
    : undefined;

  const start = useCallback(async () => {
    if (videoId) {
      setPlaying(true);
      return;
    }
    setResolving(true);
    setFailed(false);
    const controller = new AbortController();
    abortRef.current = controller;
    try {
      const res = await fetch(tiktokOembedUrl(url), { signal: controller.signal });
      const id = tiktokIdFromOembed(await res.json());
      if (controller.signal.aborted) return;
      if (id) {
        setVideoId(id);
        setPlaying(true);
      } else {
        setFailed(true);
      }
    } catch {
      if (!controller.signal.aborted) setFailed(true);
    } finally {
      if (!controller.signal.aborted) setResolving(false);
    }
  }, [url, videoId]);

  const enterFullscreen = () => mediaRef.current?.requestFullscreen?.().catch(() => undefined);

  const facadeInner = (
    <>
      {thumbnailUrl ? (
        <img className={previewCss.MediaThumbnailImg} src={thumbnailUrl} alt="" loading="lazy" />
      ) : (
        <span className={previewCss.EmbedPlaceholder} />
      )}
      <span className={previewCss.MediaPlayOverlay}>
        <span className={previewCss.MediaPlayButton}>
          {resolving ? <Spinner size="100" /> : <Icon size="400" src={Icons.Play} />}
        </span>
      </span>
    </>
  );

  return (
    <Box direction="Column">
      <div ref={mediaRef} className={previewCss.EmbedMediaPortrait}>
        {playing && videoId ? (
          <iframe
            className={previewCss.EmbedIframe}
            src={tiktokPlayerEmbedUrl(videoId)}
            title={title || 'TikTok'}
            allow="autoplay; encrypted-media; picture-in-picture; fullscreen; clipboard-write"
            sandbox={EMBED_SANDBOX}
            allowFullScreen
            loading="lazy"
          />
        ) : inlineMediaEmbeds ? (
          <button
            type="button"
            className={previewCss.EmbedFacade}
            onClick={start}
            disabled={resolving}
            aria-busy={resolving}
            aria-label={resolving ? 'Loading TikTok…' : `Play TikTok${title ? `: ${title}` : ''}`}
          >
            {facadeInner}
          </button>
        ) : (
          <a
            href={url}
            target="_blank"
            rel="noreferrer"
            className={previewCss.EmbedFacade}
            aria-label={`Open on TikTok${title ? `: ${title}` : ''}`}
          >
            {facadeInner}
          </a>
        )}
      </div>
      <UrlPreviewContent>
        <Box alignItems="Center" gap="200" justifyContent="SpaceBetween">
          <Text
            style={linkStyles}
            truncate
            as="a"
            href={url}
            target="_blank"
            rel="noreferrer"
            size="T200"
            priority="300"
          >
            <SiteBadge label="TikTok" colorClass={previewCss.BadgeTikTok} />
          </Text>
          {playing && videoId && (
            <Box alignItems="Center" gap="100" shrink="No">
              <Chip
                variant="Secondary"
                radii="Pill"
                className={MobileTouchTarget}
                onClick={enterFullscreen}
                aria-label="Fullscreen"
              >
                <Text size="T200">⛶ Fullscreen</Text>
              </Chip>
              <IconButton
                size="300"
                radii="300"
                variant="SurfaceVariant"
                className={MobileTouchTarget}
                onClick={() => setPlaying(false)}
                aria-label="Close player"
              >
                <Icon size="100" src={Icons.Cross} />
              </IconButton>
            </Box>
          )}
        </Box>
        {failed && (
          <Text size="T200" style={{ color: color.Critical.Main }}>
            Couldn’t load this TikTok — open it on TikTok.
          </Text>
        )}
        {!(playing && videoId) && title && (
          <Text truncate priority="400">
            <b>{title}</b>
          </Text>
        )}
      </UrlPreviewContent>
    </Box>
  );
}

function GitHubCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
  const title = prev['og:title'] ?? '';
  const description = prev['og:description'] ?? '';

  // GitHub og:title is usually "owner/repo: short description" — split on ': '
  const colonIdx = title.indexOf(': ');
  const repoName = colonIdx !== -1 ? title.slice(0, colonIdx) : title;
  const repoDesc = colonIdx !== -1 ? title.slice(colonIdx + 2) : description;

  return (
    <>
      <Box
        shrink="No"
        alignItems="Center"
        justifyContent="Center"
        className={previewCss.GitHubIconWrapper}
      >
        <GitHubIcon size={28} />
      </Box>
      <UrlPreviewContent>
        <Text
          style={linkStyles}
          truncate
          as="a"
          href={url}
          target="_blank"
          rel="noreferrer"
          size="T200"
          priority="300"
        >
          GitHub
        </Text>
        {repoName && (
          <Text truncate priority="400">
            <b>{repoName}</b>
          </Text>
        )}
        {repoDesc && (
          <Text size="T200" priority="300">
            <UrlPreviewDescription>{repoDesc}</UrlPreviewDescription>
          </Text>
        )}
        {description && description !== repoDesc && (
          <Text size="T200" priority="300">
            <UrlPreviewDescription>{description}</UrlPreviewDescription>
          </Text>
        )}
      </UrlPreviewContent>
    </>
  );
}

function SpotifyCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
  const mx = useMatrixClient();
  const useAuthentication = useMediaAuthentication();
  const title = prev['og:title'] ?? '';
  const description = prev['og:description'] ?? '';
  const mxcImage = prev['og:image'] as string | undefined;
  // Route through the homeserver like every other card — a raw og:image would
  // be an mxc:// URI (broken <img>) on a standard HS, or an off-HS request that
  // defeats the click-to-play facade on a nonstandard one.
  const artworkUrl = mxcImage
    ? mxcUrlToHttp(mx, mxcImage, useAuthentication, 96, 96, 'scale', false)
    : null;
  const spotifyType = getSpotifyType(url) ?? 'track';
  const typeLabel = spotifyType.charAt(0).toUpperCase() + spotifyType.slice(1);

  return (
    <>
      {artworkUrl ? (
        <img className={previewCss.ArtworkThumbnail} src={artworkUrl} alt={title} loading="lazy" />
      ) : (
        <Box
          shrink="No"
          alignItems="Center"
          justifyContent="Center"
          className={previewCss.IconWrapper}
        >
          <Icon
            style={{ fontSize: '2rem', color: BRAND_COLORS.spotify }}
            size="Inherit"
            src={Icons.VolumeHigh}
          />
        </Box>
      )}
      <UrlPreviewContent>
        <Text
          style={linkStyles}
          truncate
          as="a"
          href={url}
          target="_blank"
          rel="noreferrer"
          size="T200"
          priority="300"
        >
          <SiteBadge label={`Spotify ${typeLabel}`} colorClass={previewCss.BadgeSpotify} />
        </Text>
        {title && (
          <Text truncate priority="400">
            <b>{title}</b>
          </Text>
        )}
        {description && (
          <Text size="T200" priority="300">
            <UrlPreviewDescription>{description}</UrlPreviewDescription>
          </Text>
        )}
      </UrlPreviewContent>
    </>
  );
}

// Steam bundle/sub/dlc or other store page — OG card (no per-app widget).
function SteamStoreCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
  const mx = useMatrixClient();
  const useAuthentication = useMediaAuthentication();
  const title = prev['og:title'] ?? '';
  const description = prev['og:description'] ?? '';
  const mxcImage = prev['og:image'] as string | undefined;
  const thumbnailUrl = mxcImage
    ? mxcUrlToHttp(mx, mxcImage, useAuthentication, 480, 270, 'scale', false)
    : null;

  return (
    <>
      {thumbnailUrl ? (
        <a
          href={url}
          target="_blank"
          rel="noreferrer"
          className={previewCss.MediaThumbnailWrapper}
          aria-label={`View on Steam: ${title}`}
        >
          <img
            className={previewCss.MediaThumbnailImg}
            src={thumbnailUrl}
            alt={title}
            loading="lazy"
          />
        </a>
      ) : (
        <Box
          shrink="No"
          alignItems="Center"
          justifyContent="Center"
          className={previewCss.IconWrapper}
        >
          <Icon
            style={{ fontSize: '1.5rem', color: BRAND_COLORS.steam }}
            size="Inherit"
            src={Icons.Setting}
          />
        </Box>
      )}
      <UrlPreviewContent>
        <Text
          style={linkStyles}
          truncate
          as="a"
          href={url}
          target="_blank"
          rel="noreferrer"
          size="T200"
          priority="300"
        >
          <SiteBadge label="Steam" colorClass={previewCss.BadgeSteam} />
        </Text>
        {title && (
          <Text truncate priority="400">
            <b>{title}</b>
          </Text>
        )}
        {description && (
          <Text size="T200" priority="300">
            <UrlPreviewDescription>{description}</UrlPreviewDescription>
          </Text>
        )}
      </UrlPreviewContent>
    </>
  );
}

// Steam news / announcement post — rich OG card (Steam has no official embed
// widget for announcements): banner + headline + body preview.
function SteamNewsCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
  const mx = useMatrixClient();
  const useAuthentication = useMediaAuthentication();
  const title = prev['og:title'] ?? '';
  const description = prev['og:description'] ?? '';
  const mxcImage = prev['og:image'] as string | undefined;
  const bannerUrl = mxcImage
    ? mxcUrlToHttp(mx, mxcImage, useAuthentication, 460, 215, 'scale', false)
    : null;

  return (
    <Box direction="Column" style={{ width: '100%' }}>
      {bannerUrl && (
        <a
          href={url}
          target="_blank"
          rel="noreferrer"
          className={previewCss.SteamBannerWrapper}
          aria-label={`View on Steam: ${title}`}
        >
          <img className={previewCss.SteamBannerImg} src={bannerUrl} alt={title} loading="lazy" />
        </a>
      )}
      <UrlPreviewContent>
        <Box alignItems="Center" gap="100" wrap="Wrap">
          <SiteBadge label="Steam" colorClass={previewCss.BadgeSteam} />
          <Text size="T200" priority="300" style={{ opacity: 0.7 }}>
            Announcement
          </Text>
        </Box>
        {title && (
          <Text
            priority="400"
            style={{
              fontWeight: 700,
              display: '-webkit-box',
              WebkitLineClamp: 2,
              WebkitBoxOrient: 'vertical',
              overflow: 'hidden',
            }}
          >
            {title}
          </Text>
        )}
        {description && (
          <Text size="T200" priority="300">
            <UrlPreviewDescription>{description}</UrlPreviewDescription>
          </Text>
        )}
        <Text
          style={linkStyles}
          as="a"
          href={url}
          target="_blank"
          rel="noreferrer"
          size="T200"
          priority="300"
        >
          View on Steam
        </Text>
      </UrlPreviewContent>
    </Box>
  );
}

// Steam store app page — OG header + a click-to-play facade that loads Steam's
// official store-widget iframe (live, region-aware price / discount / Buy on
// Steam). Nothing loads from Steam until the user presses "Show price & store".
function SteamAppCard({
  url,
  prev,
  appId,
}: {
  url: string;
  prev: IPreviewUrlResponse;
  appId: string;
}) {
  const mx = useMatrixClient();
  const useAuthentication = useMediaAuthentication();
  const [inlineMediaEmbeds] = useSetting(settingsAtom, 'inlineMediaEmbeds');
  const [showWidget, setShowWidget] = useState(false);
  const title = prev['og:title'] ?? '';
  const description = prev['og:description'] ?? '';
  const mxcImage = prev['og:image'] as string | undefined;
  const capsuleUrl = mxcImage
    ? mxcUrlToHttp(mx, mxcImage, useAuthentication, 460, 215, 'scale', false)
    : null;

  return (
    <Box direction="Column" style={{ width: '100%' }}>
      {capsuleUrl && (
        <a
          href={url}
          target="_blank"
          rel="noreferrer"
          className={previewCss.SteamBannerWrapper}
          aria-label={`View on Steam: ${title}`}
        >
          <img className={previewCss.SteamBannerImg} src={capsuleUrl} alt={title} loading="lazy" />
        </a>
      )}
      <UrlPreviewContent>
        <SiteBadge label="Steam" colorClass={previewCss.BadgeSteam} />
        {title && (
          <Text truncate priority="400">
            <b>{title}</b>
          </Text>
        )}
        {description && (
          <Text size="T200" priority="300">
            <UrlPreviewDescription>{description}</UrlPreviewDescription>
          </Text>
        )}
        {showWidget ? (
          <iframe
            className={previewCss.SteamWidget}
            src={steamWidgetEmbedUrl(appId)}
            title={title ? `Steam store: ${title}` : 'Steam store widget'}
            sandbox={EMBED_SANDBOX}
            loading="lazy"
          />
        ) : (
          <Box alignItems="Center" gap="200" justifyContent="SpaceBetween">
            <Text
              style={linkStyles}
              truncate
              as="a"
              href={url}
              target="_blank"
              rel="noreferrer"
              size="T200"
              priority="300"
            >
              store.steampowered.com
            </Text>
            {inlineMediaEmbeds && (
              <Chip
                variant="Secondary"
                radii="Pill"
                onClick={() => setShowWidget(true)}
                before={<Icon size="50" src={Icons.Setting} />}
              >
                <Text size="T200">Show price &amp; store</Text>
              </Chip>
            )}
          </Box>
        )}
      </UrlPreviewContent>
    </Box>
  );
}

function SteamCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
  const target = getSteamTarget(url);
  if (target?.kind === 'news') return <SteamNewsCard url={url} prev={prev} />;
  if (target?.kind === 'app') return <SteamAppCard url={url} prev={prev} appId={target.appId} />;
  return <SteamStoreCard url={url} prev={prev} />;
}

function WikipediaCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
  const title = prev['og:title'] ?? '';
  const rawDescription = prev['og:description'] ?? '';
  const description =
    rawDescription.length > 200 ? `${rawDescription.slice(0, 200)}…` : rawDescription;

  return (
    <>
      <Box
        shrink="No"
        alignItems="Center"
        justifyContent="Center"
        className={previewCss.IconWrapper}
      >
        {/* Wikipedia "W" mark */}
        <svg
          width={32}
          height={32}
          viewBox="0 0 50 50"
          fill="currentColor"
          aria-hidden="true"
          role="img"
        >
          <path d="M26.5 4a1 1 0 0 0-1 1v.1c-2.1.3-4 1.4-5.3 3.1L12 22.5 5.9 8.7A1 1 0 0 0 5 8H2a1 1 0 1 0 0 2h2.3l7.2 16.9c.2.4.6.7 1 .7h.1c.5 0 .9-.3 1.1-.7l7.1-14.2c.9-1.7 2.4-2.9 4.2-3.4v28.4c-1.3.3-2.5 1-3.4 2H10.5a1 1 0 1 0 0 2h10.6C22 43 23.9 44 26 44h-.5a1 1 0 1 0 0 2H26a9 9 0 0 0 8.9-7.8H45a1 1 0 1 0 0-2H35c-.9-1-2.1-1.7-3.5-2V9.8c1.8.4 3.3 1.7 4.2 3.4l7.1 14.2c.2.4.6.7 1.1.7h.1c.4 0 .8-.3 1-.7L52.7 10H55a1 1 0 1 0 0-2h-3a1 1 0 0 0-.9.7L44 22.5l-8.2-14.3C34.5 5.4 32.6 4.3 30.5 4H27a1 1 0 0 0-.5 0z" />
        </svg>
      </Box>
      <UrlPreviewContent>
        <Text
          style={linkStyles}
          truncate
          as="a"
          href={url}
          target="_blank"
          rel="noreferrer"
          size="T200"
          priority="300"
        >
          <SiteBadge label="Wikipedia" colorClass={previewCss.BadgeWikipedia} />
        </Text>
        {title && (
          <Text truncate priority="400">
            <b>{title}</b>
          </Text>
        )}
        {description && (
          <Text size="T200" priority="300">
            <UrlPreviewDescription>{description}</UrlPreviewDescription>
          </Text>
        )}
      </UrlPreviewContent>
    </>
  );
}

function DiscordCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
  const { t } = useTranslation();
  const mx = useMatrixClient();
  const useAuthentication = useMediaAuthentication();
  const title = prev['og:title'] ?? '';
  const description = prev['og:description'] ?? '';
  const mxcImage = prev['og:image'] as string | undefined;
  const iconUrl = mxcImage
    ? mxcUrlToHttp(mx, mxcImage, useAuthentication, 96, 96, 'scale', false)
    : null;

  return (
    <>
      {iconUrl ? (
        <img className={previewCss.ArtworkThumbnail} src={iconUrl} alt={title} loading="lazy" />
      ) : (
        <Box
          shrink="No"
          alignItems="Center"
          justifyContent="Center"
          className={previewCss.IconWrapper}
        >
          {/* Discord logo (simple geometric mark) */}
          <svg
            width={28}
            height={28}
            viewBox="0 0 24 24"
            fill={BRAND_COLORS.discord}
            aria-hidden="true"
            role="img"
          >
            <path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
          </svg>
        </Box>
      )}
      <UrlPreviewContent>
        <Text
          style={linkStyles}
          truncate
          as="a"
          href={url}
          target="_blank"
          rel="noreferrer"
          size="T200"
          priority="300"
        >
          <SiteBadge label="Discord" colorClass={previewCss.BadgeDiscord} />
          <span style={{ marginLeft: '6px', opacity: 0.7, fontSize: '0.85em' }}>
            {t('Organisms.UrlPreview.join_server')}
          </span>
        </Text>
        {title && (
          <Text truncate priority="400">
            <b>{title}</b>
          </Text>
        )}
        {description && (
          <Text size="T200" priority="300">
            <UrlPreviewDescription>{description}</UrlPreviewDescription>
          </Text>
        )}
      </UrlPreviewContent>
    </>
  );
}

function NpmCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
  const title = prev['og:title'] ?? '';
  const description = prev['og:description'] ?? '';

  return (
    <>
      <Box
        shrink="No"
        alignItems="Center"
        justifyContent="Center"
        className={previewCss.IconWrapper}
      >
        {/* npm logo — simple square mark */}
        <svg width={32} height={32} viewBox="0 0 18 7" fill="none" aria-hidden="true" role="img">
          <rect width="18" height="7" fill={BRAND_COLORS.npm} />
          <rect x="1" y="1" width="4" height="5" fill="white" />
          <rect x="2" y="1" width="1" height="4" fill={BRAND_COLORS.npm} />
          <rect x="6" y="1" width="4" height="5" fill="white" />
          <rect x="7" y="1" width="1" height="3" fill={BRAND_COLORS.npm} />
          <rect x="11" y="1" width="4" height="4" fill="white" />
          <rect x="12" y="1" width="1" height="3" fill={BRAND_COLORS.npm} />
        </svg>
      </Box>
      <UrlPreviewContent>
        <Text
          style={linkStyles}
          truncate
          as="a"
          href={url}
          target="_blank"
          rel="noreferrer"
          size="T200"
          priority="300"
        >
          <SiteBadge label="npm" colorClass={previewCss.BadgeNpm} />
        </Text>
        {title && (
          <Text truncate priority="400">
            <b>{title}</b>
          </Text>
        )}
        {description && (
          <Text size="T200" priority="300">
            <UrlPreviewDescription>{description}</UrlPreviewDescription>
          </Text>
        )}
      </UrlPreviewContent>
    </>
  );
}

function StackOverflowCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
  const title = prev['og:title'] ?? '';
  const description = prev['og:description'] ?? '';

  return (
    <>
      <Box
        shrink="No"
        alignItems="Center"
        justifyContent="Center"
        className={previewCss.IconWrapper}
      >
        {/* Stack Overflow logo — simplified stack of bars */}
        <svg
          width={28}
          height={28}
          viewBox="0 0 24 24"
          fill={BRAND_COLORS.stackOverflow}
          aria-hidden="true"
          role="img"
        >
          <path d="M18.986 21.865v-6.404h2.134V24H0v-8.539h2.134v6.404zM3.34 13.232l.415-2.396 9.913 1.73-.415 2.396zm1.241-4.065.832-2.26 9.308 3.424-.832 2.26-9.308-3.424zm2.498-3.993 1.248-2.097 8.17 4.857-1.248 2.097L7.079 5.174zm4.568-3.741 1.665-1.665 6.971 6.971-1.665 1.665-6.971-6.97zm7.559 11.866h-2.134V6.796h2.134z" />
        </svg>
      </Box>
      <UrlPreviewContent>
        <Text
          style={linkStyles}
          truncate
          as="a"
          href={url}
          target="_blank"
          rel="noreferrer"
          size="T200"
          priority="300"
        >
          <SiteBadge label="Stack Overflow" colorClass={previewCss.BadgeStackOverflow} />
        </Text>
        {title && (
          <Text truncate priority="400">
            <b>{title}</b>
          </Text>
        )}
        {description && (
          <Text size="T200" priority="300">
            <UrlPreviewDescription>{description}</UrlPreviewDescription>
          </Text>
        )}
      </UrlPreviewContent>
    </>
  );
}

function ImdbCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
  const mx = useMatrixClient();
  const useAuthentication = useMediaAuthentication();
  const title = prev['og:title'] ?? '';
  const description = prev['og:description'] ?? '';
  const mxcImage = prev['og:image'] as string | undefined;
  const posterUrl = mxcImage
    ? mxcUrlToHttp(mx, mxcImage, useAuthentication, 120, 180, 'scale', false)
    : null;

  return (
    <>
      {posterUrl ? (
        <img className={previewCss.PosterThumbnail} src={posterUrl} alt={title} loading="lazy" />
      ) : (
        <Box
          shrink="No"
          alignItems="Center"
          justifyContent="Center"
          className={previewCss.IconWrapper}
        >
          <SiteBadge label="IMDb" colorClass={previewCss.BadgeImdb} />
        </Box>
      )}
      <UrlPreviewContent>
        <Text
          style={linkStyles}
          truncate
          as="a"
          href={url}
          target="_blank"
          rel="noreferrer"
          size="T200"
          priority="300"
        >
          <SiteBadge label="IMDb" colorClass={previewCss.BadgeImdb} />
        </Text>
        {title && (
          <Text truncate priority="400">
            <b>{title}</b>
          </Text>
        )}
        {description && (
          <Text size="T200" priority="300">
            <UrlPreviewDescription>{description}</UrlPreviewDescription>
          </Text>
        )}
      </UrlPreviewContent>
    </>
  );
}

// ---------------------------------------------------------------------------
// Card: GIF (Giphy / Tenor)
// ---------------------------------------------------------------------------

function GifCard({
  url,
  prev,
  mx,
  useAuthentication,
  siteBadgeLabel,
  siteBadgeClass,
}: {
  url: string;
  prev: IPreviewUrlResponse;
  mx: ReturnType<typeof useMatrixClient>;
  useAuthentication: boolean;
  siteBadgeLabel: string;
  siteBadgeClass: string;
}) {
  const title = (prev['og:title'] as string | undefined) ?? '';
  const mxcImage = prev['og:image'] as string | undefined;

  // A GIF card exists to show a moving GIF, so request the original rather than
  // a thumbnail — the thumbnail endpoint would return a frozen first frame.
  // `loading="lazy"` below keeps it off the wire until it's near the viewport.
  const thumbSrc = mxcImage
    ? shouldServeGifOriginal(url, prev)
      ? mxcUrlToHttp(mx, mxcImage, useAuthentication)
      : mxcUrlToHttp(mx, mxcImage, useAuthentication, 400, 200, 'scale', false)
    : null;

  // If there's no image, fall back to a generic-style layout
  if (!thumbSrc) {
    return (
      <>
        <UrlPreviewContent>
          <Text
            style={linkStyles}
            truncate
            as="a"
            href={url}
            target="_blank"
            rel="noreferrer"
            size="T200"
            priority="300"
          >
            <SiteBadge label={siteBadgeLabel} colorClass={siteBadgeClass} />
          </Text>
          {title && (
            <Text truncate priority="400">
              <b>{title}</b>
            </Text>
          )}
        </UrlPreviewContent>
      </>
    );
  }

  return (
    <Box direction="Column" style={{ width: '100%' }}>
      {/* GIF thumbnail — full width */}
      <a
        href={url}
        target="_blank"
        rel="noreferrer"
        className={previewCss.GifThumbnailWrapper}
        aria-label={`View GIF on ${siteBadgeLabel}: ${title}`}
      >
        <img className={previewCss.GifThumbnailImg} src={thumbSrc} alt={title} loading="lazy" />
        <span className={previewCss.GifBadge}>GIF</span>
      </a>
      {/* Footer row */}
      <UrlPreviewContent>
        <Box alignItems="Center" gap="100" wrap="Wrap">
          <SiteBadge label={siteBadgeLabel} colorClass={siteBadgeClass} />
          {title && (
            <Text truncate priority="400" style={{ fontWeight: 600 }}>
              {title}
            </Text>
          )}
        </Box>
      </UrlPreviewContent>
    </Box>
  );
}

function GenericCard({
  url,
  prev,
  onOpenViewer,
  viewer,
  onCloseViewer,
  thumbUrl,
  imgUrl,
}: {
  url: string;
  prev: IPreviewUrlResponse;
  onOpenViewer: () => void;
  viewer: boolean;
  onCloseViewer: () => void;
  thumbUrl: string | null;
  imgUrl: string | null;
}) {
  const title = prev['og:title'] ?? '';
  const description = prev['og:description'] ?? '';
  const siteName = typeof prev['og:site_name'] === 'string' ? prev['og:site_name'] : undefined;

  // Synapse returns 400 from the thumbnail endpoint when it can't thumbnail a
  // cached preview image (e.g. SVG/animated). Fall back to the full image, then
  // hide entirely if that also fails — otherwise the card shows a broken image
  // and the browser keeps re-requesting the failing thumbnail (console spam).
  const [useFullImg, setUseFullImg] = useState(false);
  const [imgFailed, setImgFailed] = useState(false);
  const displayThumb = useFullImg ? imgUrl : thumbUrl;
  const handleImgError = () => {
    if (!useFullImg && imgUrl && imgUrl !== thumbUrl) setUseFullImg(true);
    else setImgFailed(true);
  };

  return (
    <>
      {!imgFailed && displayThumb && (
        <UrlPreviewImg
          src={displayThumb}
          alt={prev['og:title']}
          title={prev['og:title']}
          loading="lazy"
          tabIndex={0}
          onKeyDown={(evt) => onEnterOrSpace(() => onOpenViewer())(evt)}
          onClick={onOpenViewer}
          onError={handleImgError}
        />
      )}
      {imgUrl && (
        <ImageOverlay
          src={imgUrl}
          alt={prev['og:title']}
          viewer={viewer}
          requestClose={onCloseViewer}
          renderViewer={(p) => <ImageViewer {...p} />}
        />
      )}
      <UrlPreviewContent>
        <Text
          style={linkStyles}
          truncate
          as="a"
          href={url}
          target="_blank"
          rel="noreferrer"
          size="T200"
          priority="300"
        >
          {(!displayThumb || imgFailed) && (
            <Icon
              src={Icons.Link}
              size="50"
              aria-hidden="true"
              style={{ marginRight: '4px', verticalAlign: 'text-bottom', opacity: 0.5 }}
            />
          )}
          {siteName ? `${siteName} | ` : ''}
          {tryDecodeURIComponent(url)}
        </Text>
        {title && (
          <Text truncate priority="400">
            <b>{title}</b>
          </Text>
        )}
        {description && (
          <Text size="T200" priority="300">
            <UrlPreviewDescription>{description}</UrlPreviewDescription>
          </Text>
        )}
      </UrlPreviewContent>
    </>
  );
}

// ---------------------------------------------------------------------------
// Main UrlPreviewCard component
// ---------------------------------------------------------------------------

export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
  ({ url, ts, ...props }, ref) => {
    const mx = useMatrixClient();
    const useAuthentication = useMediaAuthentication();
    const [viewer, setViewer] = useState(false);
    const [previewStatus, loadPreview] = useAsyncCallback(
      useCallback(() => mx.getUrlPreview(url, ts), [url, ts, mx]),
    );

    useEffect(() => {
      loadPreview().catch(() => {});
    }, [loadPreview]);

    if (previewStatus.status === AsyncStatus.Error) return null;

    // Interactive embeds (players, tweets) render in a wider, responsive card so
    // player chrome / tweet content isn't cramped or clipped.
    const embed = parseMediaEmbed(url, window.location.hostname);
    // Short "copy-link" links carry no id, so the embed is only resolvable from
    // the homeserver's canonical og:url. Resolve it here so `wide` reflects the
    // ACTUALLY rendered card — an og:url-resolved MediaEmbedCard must still get
    // the wide layout, not the cramped narrow one.
    const resolveEmbed = (prev: IPreviewUrlResponse): MediaEmbed | null => {
      if (embed) return embed;
      const ogUrl = prev['og:url'];
      return typeof ogUrl === 'string' && ogUrl !== url
        ? parseMediaEmbed(ogUrl, window.location.hostname)
        : null;
    };
    // A Steam app page renders the official ~646px store-widget iframe, so it
    // needs the wide card too.
    const steamAppWide = getSteamTarget(url)?.kind === 'app';
    // Twitter/Twitch/TikTok(fallback) cards render header/thumbnail beside content
    // in the card flex row; stack them on phones (no-op for the single-column
    // embed cards). Desktop keeps the row layout.
    const stackOnMobile = isTwitter(url) || isTwitch(url) || isTikTok(url);
    const buildCardClass = (wide: boolean): string | undefined =>
      [wide && previewCss.UrlPreviewWide, stackOnMobile && previewCss.StackOnMobile]
        .filter(Boolean)
        .join(' ') || undefined;

    const renderContent = (
      prev: IPreviewUrlResponse,
      resolvedEmbed: MediaEmbed | null,
    ): React.ReactNode => {
      // Embeddable media (YouTube/Vimeo/TikTok/Dailymotion/Streamable/Twitch/
      // Spotify/SoundCloud/Apple Music/Tidal/Instagram/Reddit) → click-to-play
      // tile. `resolvedEmbed` (computed by the caller via resolveEmbed) already
      // folds in the og:url fallback for short "copy-link" share URLs.
      if (resolvedEmbed) {
        return <MediaEmbedCard url={url} prev={prev} embed={resolvedEmbed} />;
      }

      // TikTok video links (incl. short share links) get their own oEmbed-resolving card.
      if (isTikTokLink(url)) {
        return <TikTokEmbedCard url={url} prev={prev} />;
      }

      const variant = getCardVariant(url);

      switch (variant) {
        case 'tiktok':
          return <TikTokCard url={url} prev={prev} mx={mx} useAuthentication={useAuthentication} />;
        case 'github':
          return <GitHubCard url={url} prev={prev} />;
        case 'twitter':
          return (
            <TwitterCard url={url} prev={prev} mx={mx} useAuthentication={useAuthentication} />
          );
        case 'reddit':
          return <RedditCard url={url} prev={prev} mx={mx} useAuthentication={useAuthentication} />;
        case 'spotify':
          return <SpotifyCard url={url} prev={prev} />;
        case 'twitch':
          return <TwitchCard url={url} prev={prev} mx={mx} useAuthentication={useAuthentication} />;
        case 'steam':
          return <SteamCard url={url} prev={prev} />;
        case 'wikipedia':
          return <WikipediaCard url={url} prev={prev} />;
        case 'discord':
          return <DiscordCard url={url} prev={prev} />;
        case 'npm':
          return <NpmCard url={url} prev={prev} />;
        case 'stackoverflow':
          return <StackOverflowCard url={url} prev={prev} />;
        case 'imdb':
          return <ImdbCard url={url} prev={prev} />;
        case 'giphy':
          return (
            <GifCard
              url={url}
              prev={prev}
              mx={mx}
              useAuthentication={useAuthentication}
              siteBadgeLabel="Giphy"
              siteBadgeClass={previewCss.BadgeGiphy}
            />
          );
        case 'tenor':
          return (
            <GifCard
              url={url}
              prev={prev}
              mx={mx}
              useAuthentication={useAuthentication}
              siteBadgeLabel="Tenor"
              siteBadgeClass={previewCss.BadgeTenor}
            />
          );
        default: {
          // Generic fallback — skip empty cards
          if (!prev['og:title'] && !prev['og:description']) return null;

          const imgUrl = mxcUrlToHttp(mx, prev['og:image'] || '', useAuthentication);
          // Show the original for GIFs so they animate; thumbnailing freezes them.
          const thumbUrl = shouldServeGifOriginal(url, prev)
            ? imgUrl
            : mxcUrlToHttp(mx, prev['og:image'] || '', useAuthentication, 256, 256, 'scale', false);

          return (
            <GenericCard
              url={url}
              prev={prev}
              onOpenViewer={() => setViewer(true)}
              viewer={viewer}
              onCloseViewer={() => setViewer(false)}
              thumbUrl={thumbUrl}
              imgUrl={imgUrl}
            />
          );
        }
      }
    };

    // Don't render the card wrapper when content is empty (loaded but nothing to show)
    if (previewStatus.status === AsyncStatus.Success) {
      const prev = previewStatus.data;
      const resolvedEmbed = resolveEmbed(prev);
      const content = renderContent(prev, resolvedEmbed);
      if (content === null) return null;
      // `wide` follows the resolved embed (incl. the og:url fallback), so a short
      // link that resolves to a player still gets the wide layout.
      const wide = !!resolvedEmbed || isTwitterTweet(url) || steamAppWide;
      return (
        <UrlPreview {...props} ref={ref} className={buildCardClass(wide)}>
          {content}
        </UrlPreview>
      );
    }

    // Loading/idle: no preview data yet, so base `wide` on the url-only embed.
    return (
      <UrlPreview
        {...props}
        ref={ref}
        className={buildCardClass(!!embed || isTwitterTweet(url) || steamAppWide)}
      >
        <Box grow="Yes" alignItems="Center" justifyContent="Center">
          <Spinner variant="Secondary" size="400" />
        </Box>
      </UrlPreview>
    );
  },
);

export const UrlPreviewHolder = as<'div'>(({ children, ...props }, ref) => {
  const scrollRef = useRef<HTMLDivElement>(null);
  const backAnchorRef = useRef<HTMLDivElement>(null);
  const frontAnchorRef = useRef<HTMLDivElement>(null);
  const [backVisible, setBackVisible] = useState(true);
  const [frontVisible, setFrontVisible] = useState(true);

  const intersectionObserver = useIntersectionObserver(
    useCallback((entries) => {
      const backAnchor = backAnchorRef.current;
      const frontAnchor = frontAnchorRef.current;
      const backEntry = backAnchor && getIntersectionObserverEntry(backAnchor, entries);
      const frontEntry = frontAnchor && getIntersectionObserverEntry(frontAnchor, entries);
      if (backEntry) {
        setBackVisible(backEntry.isIntersecting);
      }
      if (frontEntry) {
        setFrontVisible(frontEntry.isIntersecting);
      }
    }, []),
    useCallback(
      () => ({
        root: scrollRef.current,
        rootMargin: '10px',
      }),
      [],
    ),
  );

  useEffect(() => {
    const backAnchor = backAnchorRef.current;
    const frontAnchor = frontAnchorRef.current;
    if (backAnchor) intersectionObserver?.observe(backAnchor);
    if (frontAnchor) intersectionObserver?.observe(frontAnchor);
    return () => {
      if (backAnchor) intersectionObserver?.unobserve(backAnchor);
      if (frontAnchor) intersectionObserver?.unobserve(frontAnchor);
    };
  }, [intersectionObserver]);

  const handleScrollBack = () => {
    const scroll = scrollRef.current;
    if (!scroll) return;
    const { offsetWidth, scrollLeft } = scroll;
    scroll.scrollTo({
      left: scrollLeft - offsetWidth / 1.3,
      behavior: 'smooth',
    });
  };
  const handleScrollFront = () => {
    const scroll = scrollRef.current;
    if (!scroll) return;
    const { offsetWidth, scrollLeft } = scroll;
    scroll.scrollTo({
      left: scrollLeft + offsetWidth / 1.3,
      behavior: 'smooth',
    });
  };

  return (
    <Box
      direction="Column"
      {...props}
      ref={ref}
      style={{ marginTop: config.space.S200, position: 'relative' }}
    >
      <Scroll ref={scrollRef} direction="Horizontal" size="0" visibility="Hover" hideTrack>
        <Box shrink="No" alignItems="Center">
          <div ref={backAnchorRef} />
          {!backVisible && (
            <>
              <div className={css.UrlPreviewHolderGradient({ position: 'Left' })} />
              <IconButton
                className={css.UrlPreviewHolderBtn({ position: 'Left' })}
                aria-label="Previous preview"
                variant="Secondary"
                radii="Pill"
                size="300"
                outlined
                onClick={handleScrollBack}
              >
                <Icon size="300" src={Icons.ArrowLeft} />
              </IconButton>
            </>
          )}
          <Box alignItems="Inherit" gap="200">
            {children}

            {!frontVisible && (
              <>
                <div className={css.UrlPreviewHolderGradient({ position: 'Right' })} />
                <IconButton
                  className={css.UrlPreviewHolderBtn({ position: 'Right' })}
                  aria-label="Next preview"
                  variant="Primary"
                  radii="Pill"
                  size="300"
                  outlined
                  onClick={handleScrollFront}
                >
                  <Icon size="300" src={Icons.ArrowRight} />
                </IconButton>
              </>
            )}
            <div ref={frontAnchorRef} />
          </Box>
        </Box>
      </Scroll>
    </Box>
  );
});
