import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
  Box,
  Button,
  Chip,
  Header,
  Icon,
  IconButton,
  Icons,
  Overlay,
  OverlayBackdrop,
  Scroll,
  Spinner,
  Text,
  Tooltip,
  TooltipProvider,
  color,
  config,
} from 'folds';
import { EventType, MatrixClient, MatrixEvent, MsgType, Room } from 'matrix-js-sdk';
import FocusTrap from 'focus-trap-react';
import classNames from 'classnames';
import { useNearViewport } from '../../hooks/useNearViewport';
import { useZoom } from '../../hooks/useZoom';
import { usePan, Pan } from '../../hooks/usePan';
import { IEncryptedFile, IImageInfo, IThumbnailContent } from '../../../types/matrix/common';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../utils/matrix';
import { AudioContent, FileDownloadButton } from '../../components/message';
import { MediaControl } from '../../components/media';
import { getBlobSafeMimeType, mimeTypeToExt } from '../../utils/mimeTypes';
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { ContainerColor } from '../../styles/ContainerColor.css';
import { stopPropagation } from '../../utils/keyboard';
import * as css from './MediaGallery.css';

type GalleryTab = 'image' | 'video' | 'file' | 'audio';

const TAB_LABELS: Record<GalleryTab, string> = {
  image: 'Images',
  video: 'Videos',
  audio: 'Audio',
  file: 'Files',
};

const TAB_MSGTYPES: Record<GalleryTab, MsgType> = {
  image: MsgType.Image,
  video: MsgType.Video,
  audio: MsgType.Audio,
  file: MsgType.File,
};

// ── Decrypt hook ──────────────────────────────────────────────────────────────

type DecryptState = { status: 'loading' } | { status: 'ok'; url: string } | { status: 'error' };

function useDecryptedMediaUrl(
  mx: MatrixClient,
  mxcUrl: string | undefined,
  encInfo: IEncryptedFile | undefined,
  useAuthentication: boolean,
  mimeType?: string,
  enabled = true,
): DecryptState {
  const [state, setState] = useState<DecryptState>({ status: 'loading' });
  const prevBlobUrl = useRef<string | null>(null);

  useEffect(() => {
    if (!enabled) return undefined;
    if (!mxcUrl) {
      setState({ status: 'error' });
      return;
    }
    let cancelled = false;
    setState({ status: 'loading' });

    const run = async () => {
      const httpUrl = mxcUrlToHttp(mx, mxcUrl, useAuthentication);
      if (!httpUrl) throw new Error('bad url');
      if (encInfo) {
        const blob = await downloadEncryptedMedia(httpUrl, (buf) =>
          decryptFile(buf, mimeType ?? 'application/octet-stream', encInfo),
        );
        const blobUrl = URL.createObjectURL(blob);
        if (cancelled) {
          URL.revokeObjectURL(blobUrl);
          return;
        }
        if (prevBlobUrl.current) URL.revokeObjectURL(prevBlobUrl.current);
        prevBlobUrl.current = blobUrl;
        setState({ status: 'ok', url: blobUrl });
      } else {
        setState({ status: 'ok', url: httpUrl });
      }
    };

    run().catch(() => {
      if (!cancelled) setState({ status: 'error' });
    });

    return () => {
      cancelled = true;
    };
  }, [mx, mxcUrl, encInfo, useAuthentication, mimeType, enabled]);

  useEffect(
    () => () => {
      if (prevBlobUrl.current) URL.revokeObjectURL(prevBlobUrl.current);
    },
    [],
  );

  return state;
}

// ── Helpers ───────────────────────────────────────────────────────────────────

function formatRelativeDate(ts: number): string {
  const diff = Date.now() - ts;
  const mins = Math.floor(diff / 60000);
  if (mins < 2) return 'Just now';
  if (mins < 60) return `${mins}m ago`;
  const hrs = Math.floor(diff / 3600000);
  if (hrs < 24) return `${hrs}h ago`;
  const days = Math.floor(diff / 86400000);
  if (days < 7) return `${days}d ago`;
  return new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}

function formatBytes(bytes: number): string {
  if (bytes < 1024) return `${bytes} B`;
  if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
  return `${(bytes / 1048576).toFixed(1)} MB`;
}

// A sensible download filename: prefer the event body/filename; if it has no
// plausible extension already, append one derived from the mimetype so the
// saved file opens. "Plausible" = a short alphanumeric tail after the last dot,
// so "Screenshot 2024.01.05" still gets a real extension appended.
function hasFileExtension(name: string): boolean {
  const dot = name.lastIndexOf('.');
  if (dot <= 0 || dot === name.length - 1) return false;
  return /^[a-z0-9]{1,5}$/i.test(name.slice(dot + 1));
}
function mediaFilename(body: string, mimeType?: string): string {
  const name = body.trim() || 'media';
  if (hasFileExtension(name)) return name;
  const ext = mimeType ? mimeTypeToExt(mimeType) : '';
  return ext ? `${name}.${ext}` : name;
}

function monthLabel(ts: number): string {
  return new Date(ts).toLocaleDateString(undefined, { month: 'long', year: 'numeric' });
}

function getSenderName(room: Room, userId: string): string {
  return room.getMember(userId)?.name ?? userId.split(':')[0]?.slice(1) ?? userId;
}

// Resolve the thumbnail/display MXC for an image/video event, mirroring the
// grid's preference order (encrypted thumb > file > thumbnail_url > url). Both
// the grid and the lightbox must use this so their positional indices stay in
// lockstep — otherwise a tile skipped for lack of a thumb would shift the
// lightbox and open the wrong media.
function getThumbMxc(mEvent: MatrixEvent): string | undefined {
  const c = mEvent.getContent();
  const isEnc = !!c.file;
  const info: (IImageInfo & IThumbnailContent) | undefined = c.info;
  return isEnc ? (info?.thumbnail_file?.url ?? c.file?.url) : (info?.thumbnail_url ?? c.url);
}

// ── Lightbox ──────────────────────────────────────────────────────────────────

type LightboxItem = {
  mxcUrl: string;
  encInfo?: IEncryptedFile;
  mimeType?: string;
  msgtype: MsgType.Image | MsgType.Video;
  body: string;
  sender: string;
  ts: number;
  eventId: string;
};

function LightboxMedia({
  item,
  useAuthentication,
  zoom,
  pan,
  cursor,
  onMouseDown,
  onTouchStart,
  onImageDoubleClick,
}: {
  item: LightboxItem;
  useAuthentication: boolean;
  zoom: number;
  pan: Pan;
  cursor: string;
  onMouseDown: React.MouseEventHandler<HTMLElement>;
  onTouchStart: React.TouchEventHandler<HTMLElement>;
  onImageDoubleClick: () => void;
}) {
  const mx = useMatrixClient();
  const media = useDecryptedMediaUrl(
    mx,
    item.mxcUrl,
    item.encInfo,
    useAuthentication,
    item.mimeType,
  );

  return (
    <Box
      direction="Column"
      alignItems="Center"
      justifyContent="Center"
      style={{ height: '100%', gap: config.space.S200 }}
    >
      {media.status === 'loading' && (
        <Box direction="Column" alignItems="Center" gap="200">
          <Spinner size="600" />
          <Text size="T300" priority="300">
            {item.encInfo ? 'Decrypting…' : 'Loading…'}
          </Text>
        </Box>
      )}
      {media.status === 'error' && (
        <Box direction="Column" alignItems="Center" gap="200">
          <Icon src={Icons.Warning} size="600" />
          <Text size="T300" priority="300">
            Failed to load
          </Text>
        </Box>
      )}
      {media.status === 'ok' &&
        (item.msgtype === MsgType.Video ? (
          <video
            aria-label="Video attachment"
            src={media.url}
            controls
            autoPlay
            style={{
              maxWidth: '100%',
              maxHeight: 'calc(100vh - 120px)',
              borderRadius: config.radii.R300,
              display: 'block',
              background: '#000',
            }}
          />
        ) : (
          <img
            src={media.url}
            alt={item.body}
            draggable={false}
            onMouseDown={onMouseDown}
            onTouchStart={onTouchStart}
            onDoubleClick={onImageDoubleClick}
            style={{
              maxWidth: '100%',
              maxHeight: 'calc(100vh - 120px)',
              objectFit: 'contain',
              borderRadius: config.radii.R300,
              display: 'block',
              cursor,
              // translate is nested inside scale(), so it runs in scaled space —
              // divide by zoom so a dragged pixel moves the image one screen pixel
              // (1:1 with the cursor) rather than `zoom` pixels.
              transform: `scale(${zoom}) translate(${pan.translateX / zoom}px, ${
                pan.translateY / zoom
              }px)`,
              transition: cursor === 'grabbing' ? 'none' : 'transform 120ms ease-out',
              willChange: 'transform',
            }}
          />
        ))}
    </Box>
  );
}

function Lightbox({
  items,
  initialIndex,
  useAuthentication,
  onClose,
  onJump,
}: {
  items: LightboxItem[];
  initialIndex: number;
  useAuthentication: boolean;
  onClose: () => void;
  onJump: (eventId: string) => void;
}) {
  const [index, setIndex] = useState(initialIndex);

  const item = items[index];
  const isImage = item?.msgtype === MsgType.Image;

  const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
  // Pan is only active for a zoomed-in image; usePan resets its offset when this
  // flips false (i.e. back to 1x, on navigation, or on a video).
  const { pan, cursor, onMouseDown, onTouchStart } = usePan(isImage && zoom !== 1);
  const toggleZoom = useCallback(() => setZoom((z) => (z === 1 ? 2 : 1)), [setZoom]);

  // Reset zoom when navigating to another item (and thus pan, via usePan).
  useEffect(() => {
    setZoom(1);
  }, [index, setZoom]);

  const prev = useCallback(() => setIndex((i) => Math.max(0, i - 1)), []);
  const next = useCallback(
    () => setIndex((i) => Math.min(items.length - 1, i + 1)),
    [items.length],
  );
  const handleKeyDown = useCallback(
    (e: React.KeyboardEvent) => {
      if (e.key === 'ArrowLeft') prev();
      else if (e.key === 'ArrowRight') next();
      else if (e.key === 'Escape') onClose();
      else if (isImage && (e.key === '+' || e.key === '=')) zoomIn();
      else if (isImage && e.key === '-') zoomOut();
      else if (isImage && e.key === '0') setZoom(1);
    },
    [prev, next, onClose, isImage, zoomIn, zoomOut, setZoom],
  );
  const handleWheel = useCallback(
    (e: React.WheelEvent) => {
      if (!isImage) return;
      if (e.deltaY < 0) zoomIn();
      else if (e.deltaY > 0) zoomOut();
    },
    [isImage, zoomIn, zoomOut],
  );

  if (!item) return null;

  const dateStr = new Date(item.ts).toLocaleDateString(undefined, {
    month: 'short',
    day: 'numeric',
    year: 'numeric',
  });

  return (
    <Overlay open backdrop={<OverlayBackdrop />}>
      <FocusTrap
        focusTrapOptions={{
          initialFocus: false,
          clickOutsideDeactivates: false,
          escapeDeactivates: false,
        }}
      >
        <div
          role="dialog"
          aria-modal
          aria-label="Media viewer"
          onKeyDown={handleKeyDown}
          tabIndex={-1}
          style={{
            position: 'fixed',
            inset: 0,
            zIndex: 1000,
            background: 'rgba(0,0,0,0.92)',
            display: 'flex',
            flexDirection: 'column',
          }}
        >
          {/* Header bar */}
          <Box
            alignItems="Center"
            gap="200"
            style={{
              padding: `${config.space.S200} ${config.space.S300}`,
              borderBottom: '1px solid rgba(255,255,255,0.08)',
              flexShrink: 0,
            }}
          >
            <Box grow="Yes" direction="Column" style={{ overflow: 'hidden' }}>
              <Text size="T400" truncate style={{ color: '#fff', fontWeight: 500 }}>
                {item.body || (item.msgtype === MsgType.Video ? 'Video' : 'Image')}
              </Text>
              <Text size="T200" style={{ color: 'rgba(255,255,255,0.5)' }}>
                {item.sender} · {dateStr}
              </Text>
            </Box>
            <Text size="T200" style={{ color: 'rgba(255,255,255,0.4)', flexShrink: 0 }}>
              {index + 1} / {items.length}
            </Text>
            {isImage && (
              <Box
                shrink="No"
                alignItems="Center"
                gap="100"
                role="group"
                aria-label="Zoom controls"
              >
                <IconButton
                  variant="Surface"
                  size="300"
                  radii="300"
                  aria-label="Zoom out"
                  onClick={zoomOut}
                  disabled={zoom <= 0.2}
                >
                  <Icon size="50" src={Icons.Minus} />
                </IconButton>
                <Chip variant="Surface" radii="Pill" onClick={toggleZoom} aria-label="Reset zoom">
                  <Text size="B300">{Math.round(zoom * 100)}%</Text>
                </Chip>
                <IconButton
                  variant="Surface"
                  size="300"
                  radii="300"
                  aria-label="Zoom in"
                  onClick={zoomIn}
                  disabled={zoom >= 5}
                >
                  <Icon size="50" src={Icons.Plus} />
                </IconButton>
              </Box>
            )}
            {item.mxcUrl && (
              <TooltipProvider
                position="Bottom"
                align="End"
                offset={4}
                tooltip={
                  <Tooltip>
                    <Text>Download</Text>
                  </Tooltip>
                }
              >
                {(ref) => (
                  <span ref={ref}>
                    <FileDownloadButton
                      filename={mediaFilename(item.body, item.mimeType)}
                      url={item.mxcUrl}
                      mimeType={item.mimeType ?? 'application/octet-stream'}
                      encInfo={item.encInfo}
                    />
                  </span>
                )}
              </TooltipProvider>
            )}
            {item.eventId && (
              <TooltipProvider
                position="Bottom"
                align="End"
                offset={4}
                tooltip={
                  <Tooltip>
                    <Text>Go to message</Text>
                  </Tooltip>
                }
              >
                {(ref) => (
                  <IconButton
                    ref={ref}
                    variant="Surface"
                    aria-label="Go to message"
                    onClick={() => onJump(item.eventId)}
                  >
                    <Icon src={Icons.Message} />
                  </IconButton>
                )}
              </TooltipProvider>
            )}
            <TooltipProvider
              position="Bottom"
              align="End"
              offset={4}
              tooltip={
                <Tooltip>
                  <Text>Close</Text>
                </Tooltip>
              }
            >
              {(ref) => (
                <IconButton ref={ref} variant="Surface" aria-label="Close" onClick={onClose}>
                  <Icon src={Icons.Cross} />
                </IconButton>
              )}
            </TooltipProvider>
          </Box>

          {/* Media area with nav arrows */}
          <Box
            grow="Yes"
            alignItems="Center"
            justifyContent="Center"
            onWheel={handleWheel}
            style={{ overflow: 'hidden', padding: config.space.S400 }}
          >
            {index > 0 && (
              <IconButton
                variant="Surface"
                aria-label="Previous"
                onClick={prev}
                style={{ flexShrink: 0, marginRight: config.space.S200 }}
              >
                <Icon src={Icons.ArrowLeft} />
              </IconButton>
            )}
            <Box
              grow="Yes"
              alignItems="Center"
              justifyContent="Center"
              style={{ overflow: 'hidden', height: '100%' }}
            >
              <LightboxMedia
                key={`${item.mxcUrl}-${item.ts}`}
                item={item}
                useAuthentication={useAuthentication}
                zoom={zoom}
                pan={pan}
                cursor={cursor}
                onMouseDown={onMouseDown}
                onTouchStart={onTouchStart}
                onImageDoubleClick={toggleZoom}
              />
            </Box>
            {index < items.length - 1 && (
              <IconButton
                variant="Surface"
                aria-label="Next"
                onClick={next}
                style={{ flexShrink: 0, marginLeft: config.space.S200 }}
              >
                <Icon src={Icons.ArrowRight} />
              </IconButton>
            )}
          </Box>
        </div>
      </FocusTrap>
    </Overlay>
  );
}

// ── Gallery tile ──────────────────────────────────────────────────────────────

function GalleryTile({
  mxcUrl,
  encInfo,
  mimeType,
  isVideo,
  body,
  sender,
  ts,
  useAuthentication,
  onClick,
  downloadUrl,
  downloadEncInfo,
  downloadMimeType,
  downloadFilename,
}: {
  mxcUrl: string;
  encInfo?: IEncryptedFile;
  mimeType?: string;
  isVideo: boolean;
  body: string;
  sender: string;
  ts: number;
  useAuthentication: boolean;
  onClick: () => void;
  downloadUrl?: string;
  downloadEncInfo?: IEncryptedFile;
  downloadMimeType?: string;
  downloadFilename: string;
}) {
  const mx = useMatrixClient();
  const tileRef = useRef<HTMLButtonElement>(null);
  const nearViewport = useNearViewport(tileRef, 300);
  const media = useDecryptedMediaUrl(
    mx,
    mxcUrl,
    encInfo,
    useAuthentication,
    mimeType,
    nearViewport,
  );
  const relDate = formatRelativeDate(ts);

  return (
    <div className={css.GalleryTileWrap}>
      <button
        ref={tileRef}
        type="button"
        aria-label={body || (isVideo ? 'Video' : 'Image')}
        onClick={onClick}
        className={css.GalleryTile}
      >
        {media.status === 'loading' && <Spinner size="200" />}
        {media.status === 'error' && (
          <Box
            direction="Column"
            alignItems="Center"
            gap="100"
            style={{ padding: config.space.S100 }}
          >
            <Icon src={isVideo ? Icons.Play : Icons.Photo} size="300" />
            <Text
              size="T200"
              truncate
              priority="300"
              style={{ maxWidth: '100%', textAlign: 'center' }}
            >
              {body}
            </Text>
          </Box>
        )}
        {media.status === 'ok' && <img src={media.url} alt={body} className={css.GalleryTileImg} />}

        {/* Video play badge */}
        {isVideo && media.status === 'ok' && (
          <div className={css.GalleryVideoBadge}>
            <Icon src={Icons.Play} size="200" />
          </div>
        )}

        {/* Hover/focus caption overlay (CSS-driven) */}
        {media.status === 'ok' && (
          <div className={css.GalleryTileOverlay}>
            <div className={css.GalleryTileCaption}>
              <Text
                size="T200"
                truncate
                style={{ color: '#fff', display: 'block', lineHeight: 1.3 }}
              >
                {sender}
              </Text>
              <Text size="T200" style={{ color: 'rgba(255,255,255,0.65)' }}>
                {relDate}
              </Text>
            </div>
          </div>
        )}
      </button>
      {downloadUrl && (
        <div className={css.GalleryTileDownload}>
          <FileDownloadButton
            filename={downloadFilename}
            url={downloadUrl}
            mimeType={downloadMimeType ?? 'application/octet-stream'}
            encInfo={downloadEncInfo}
          />
        </div>
      )}
    </div>
  );
}

// ── Month separator ───────────────────────────────────────────────────────────

// ── Tab button ────────────────────────────────────────────────────────────────

function TabButton({
  label,
  count,
  active,
  onClick,
}: {
  label: string;
  count: number;
  active: boolean;
  onClick: () => void;
}) {
  return (
    <Button
      size="300"
      variant={active ? 'Primary' : 'Secondary'}
      fill={active ? 'Solid' : 'Soft'}
      radii="300"
      onClick={onClick}
    >
      <Text size="B300">
        {label}
        {count > 0 ? ` (${count})` : ''}
      </Text>
    </Button>
  );
}

// ── Main component ────────────────────────────────────────────────────────────

type MediaGalleryProps = {
  room: Room;
  onClose: () => void;
};

export function MediaGallery({ room, onClose }: MediaGalleryProps) {
  const mx = useMatrixClient();
  const useAuthentication = useMediaAuthentication();
  const { navigateRoom } = useRoomNavigate();

  // Close the drawer and land the timeline on the source message.
  const jumpToMessage = useCallback(
    (eventId: string) => {
      onClose();
      navigateRoom(room.roomId, eventId);
    },
    [onClose, navigateRoom, room.roomId],
  );

  const [tab, setTab] = useState<GalleryTab>('image');
  const [loading, setLoading] = useState(false);
  const [hasLoadedOnce, setHasLoadedOnce] = useState(false);
  const [canLoadMore, setCanLoadMore] = useState(true);
  const [loadError, setLoadError] = useState(false);
  const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
  const sentinelRef = useRef<HTMLDivElement>(null);

  const handleTabChange = useCallback((t: GalleryTab) => {
    setTab(t);
    setLightboxIndex(null); // stale index would open wrong item in new tab's lightboxItems
  }, []);

  // Escape closes the drawer — but only when the lightbox isn't open, since the
  // lightbox has its own Escape handler that should take precedence.
  useEffect(() => {
    if (lightboxIndex !== null) return undefined;
    const handleKeyDown = (evt: KeyboardEvent) => {
      if (evt.key === 'Escape') {
        stopPropagation(evt);
        onClose();
      }
    };
    document.addEventListener('keydown', handleKeyDown);
    return () => document.removeEventListener('keydown', handleKeyDown);
  }, [lightboxIndex, onClose]);

  const msgtype = TAB_MSGTYPES[tab];

  const getFilteredEvents = useCallback(
    (): MatrixEvent[] =>
      room
        .getLiveTimeline()
        .getEvents()
        .filter((ev) => {
          if (ev.isRedacted()) return false;
          const c = ev.getContent();
          return ev.getType() === EventType.RoomMessage && c.msgtype === msgtype;
        })
        .slice()
        .reverse(),
    [room, msgtype],
  );

  const [events, setEvents] = useState<MatrixEvent[]>(() => getFilteredEvents());

  useEffect(() => {
    setEvents(getFilteredEvents());
    setCanLoadMore(true);
    setHasLoadedOnce(false);
    setLoadError(false);
  }, [getFilteredEvents]);

  const handleLoadMore = useCallback(async () => {
    if (loading || !canLoadMore) return;
    setLoading(true);
    setLoadError(false);
    try {
      const hasMore = await mx.paginateEventTimeline(room.getLiveTimeline(), {
        backwards: true,
        limit: 100,
      });
      setEvents(getFilteredEvents());
      setCanLoadMore(hasMore);
      setHasLoadedOnce(true);
    } catch {
      // Stop auto-retry: the sentinel would keep firing on every render otherwise.
      // The user can retry manually via the button shown in the error state.
      setLoadError(true);
    } finally {
      setLoading(false);
    }
  }, [loading, canLoadMore, mx, room, getFilteredEvents]);

  // Auto-load when sentinel scrolls into view
  useEffect(() => {
    const sentinel = sentinelRef.current;
    if (!sentinel || !canLoadMore || loading) return;

    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry?.isIntersecting) handleLoadMore();
      },
      { threshold: 0.1 },
    );
    observer.observe(sentinel);
    return () => observer.disconnect();
  }, [canLoadMore, loading, handleLoadMore]);

  // Lightbox items (images + videos, flat)
  const lightboxItems: LightboxItem[] = events
    .filter((ev) => {
      const c = ev.getContent();
      if (c.msgtype !== MsgType.Image && c.msgtype !== MsgType.Video) return false;
      // Match the grid's guard exactly: tiles without a thumb are not rendered,
      // so they must not occupy a lightbox slot either.
      return !!getThumbMxc(ev);
    })
    .map((ev) => {
      const c = ev.getContent();
      const isEnc = !!c.file;
      return {
        mxcUrl: c.file?.url ?? c.url ?? '',
        encInfo: isEnc ? c.file : undefined,
        mimeType: c.info?.mimetype,
        msgtype: c.msgtype as MsgType.Image | MsgType.Video,
        body: c.body ?? '',
        sender: getSenderName(room, ev.getSender() ?? ''),
        ts: ev.getTs(),
        eventId: ev.getId() ?? '',
      };
    });

  // Per-tab counts for the tab labels (single pass over loaded timeline)
  const tabCounts = useMemo(() => {
    const counts: Record<GalleryTab, number> = { image: 0, video: 0, audio: 0, file: 0 };
    room
      .getLiveTimeline()
      .getEvents()
      .forEach((ev) => {
        if (ev.isRedacted() || ev.getType() !== EventType.RoomMessage) return;
        const mt = ev.getContent().msgtype;
        if (mt === MsgType.Image) counts.image += 1;
        else if (mt === MsgType.Video) counts.video += 1;
        else if (mt === MsgType.Audio) counts.audio += 1;
        else if (mt === MsgType.File) counts.file += 1;
      });
    return counts;
    // `events` is intentional: it changes when more history is paginated in, so
    // the counts stay in sync with the loaded window (it isn't read directly).
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [room, events]);

  // Group image/video events by month for the grid
  type MonthGroup = { label: string; events: MatrixEvent[] };
  const monthGroups: MonthGroup[] = [];
  let currentLabel = '';
  for (const ev of events) {
    const label = monthLabel(ev.getTs());
    if (label !== currentLabel) {
      currentLabel = label;
      monthGroups.push({ label, events: [] });
    }
    monthGroups[monthGroups.length - 1]!.events.push(ev);
  }

  return (
    <>
      <Box
        className={classNames(css.MediaGalleryDrawer, ContainerColor({ variant: 'Background' }))}
        shrink="No"
        direction="Column"
        role="region"
        aria-labelledby="media-gallery-title"
      >
        {/* Header */}
        <Header variant="Background" size="600" className={css.MediaGalleryHeader}>
          <Box grow="Yes" alignItems="Center" gap="200">
            <Icon size="200" src={Icons.Photo} />
            <Box grow="Yes">
              <Text id="media-gallery-title" size="H4" truncate>
                Media Gallery
              </Text>
            </Box>
            <IconButton size="300" radii="300" aria-label="Close media gallery" onClick={onClose}>
              <Icon src={Icons.Cross} />
            </IconButton>
          </Box>
        </Header>

        {/* Tabs */}
        <Box className={css.MediaGalleryTabs} shrink="No">
          {(Object.keys(TAB_LABELS) as GalleryTab[]).map((t) => (
            <TabButton
              key={t}
              label={TAB_LABELS[t]}
              count={tabCounts[t]}
              active={tab === t}
              onClick={() => handleTabChange(t)}
            />
          ))}
        </Box>

        {/* Content */}
        <Box grow="Yes" style={{ position: 'relative', overflow: 'hidden' }}>
          <Scroll variant="Background" size="300" visibility="Hover" hideTrack>
            <Box className={css.MediaGalleryContent} direction="Column">
              {/* ── Image / video grid ── */}
              {(tab === 'image' || tab === 'video') && (
                <>
                  {events.length === 0 && !loading && (
                    <Box
                      direction="Column"
                      alignItems="Center"
                      gap="200"
                      style={{ padding: config.space.S400 }}
                    >
                      <Icon src={tab === 'video' ? Icons.Play : Icons.Photo} size="600" />
                      <Text size="T300" priority="300" align="Center">
                        {hasLoadedOnce
                          ? `No ${TAB_LABELS[tab].toLowerCase()} found.`
                          : `No ${TAB_LABELS[tab].toLowerCase()} in recent history.`}
                      </Text>
                    </Box>
                  )}

                  {/* Month groups */}
                  {(() => {
                    let flatIdx = 0;
                    return monthGroups.map((group) => (
                      <Box key={group.label} direction="Column" className={css.MediaGalleryGroup}>
                        {/* Month label — only shown when there are multiple groups */}
                        {monthGroups.length > 1 && (
                          <Text className={css.MediaGalleryGroupLabel} size="L400" priority="300">
                            {group.label}
                          </Text>
                        )}
                        <div className={css.MediaGalleryGrid}>
                          {group.events.map((mEvent) => {
                            const c = mEvent.getContent();
                            const isEnc = !!c.file;
                            const isVideo = c.msgtype === MsgType.Video;
                            const info: (IImageInfo & IThumbnailContent) | undefined = c.info;

                            // Prefer thumbnail_file (encrypted thumb) > file > thumbnail_url > url
                            const thumbMxc: string | undefined = getThumbMxc(mEvent);
                            const thumbEnc: IEncryptedFile | undefined = isEnc
                              ? (info?.thumbnail_file ?? c.file)
                              : undefined;
                            const thumbMime: string | undefined =
                              info?.thumbnail_file != null
                                ? (info.thumbnail_info?.mimetype ?? 'image/jpeg')
                                : (info?.mimetype ?? 'image/jpeg');

                            // Guard before incrementing: skipped tiles must not consume a slot
                            if (!thumbMxc) return null;
                            const idx = flatIdx++;
                            // Full-resolution source for download (not the thumb).
                            const fullMxc: string | undefined = c.file?.url ?? c.url;
                            const bodyStr: string = c.body ?? '';
                            return (
                              <GalleryTile
                                key={mEvent.getId()}
                                mxcUrl={thumbMxc}
                                encInfo={thumbEnc}
                                mimeType={thumbMime}
                                isVideo={isVideo}
                                body={bodyStr}
                                sender={getSenderName(room, mEvent.getSender() ?? '')}
                                ts={mEvent.getTs()}
                                useAuthentication={useAuthentication}
                                onClick={() => setLightboxIndex(idx)}
                                downloadUrl={fullMxc}
                                downloadEncInfo={isEnc ? c.file : undefined}
                                downloadMimeType={info?.mimetype}
                                downloadFilename={mediaFilename(bodyStr, info?.mimetype)}
                              />
                            );
                          })}
                        </div>
                      </Box>
                    ));
                  })()}
                </>
              )}

              {/* ── File list ── */}
              {tab === 'file' && (
                <>
                  {events.length === 0 && !loading && (
                    <Box
                      direction="Column"
                      alignItems="Center"
                      gap="200"
                      style={{ padding: config.space.S400 }}
                    >
                      <Icon src={Icons.File} size="600" />
                      <Text size="T300" priority="300" align="Center">
                        {hasLoadedOnce ? 'No files found.' : 'No files in recent history.'}
                      </Text>
                    </Box>
                  )}
                  <Box direction="Column" gap="100">
                    {events.map((mEvent) => {
                      const c = mEvent.getContent();
                      const mxcUrl: string | undefined = c.file?.url ?? c.url;
                      const body: string = c.body ?? 'Unnamed file';
                      const size: number | undefined = c.info?.size;
                      const sender = getSenderName(room, mEvent.getSender() ?? '');
                      return (
                        <Box
                          key={mEvent.getId()}
                          alignItems="Center"
                          gap="200"
                          style={{
                            padding: `${config.space.S200} ${config.space.S300}`,
                            borderRadius: config.radii.R300,
                            background: color.SurfaceVariant.Container,
                          }}
                        >
                          <Icon size="300" src={Icons.File} />
                          <Box
                            grow="Yes"
                            direction="Column"
                            style={{ overflow: 'hidden', gap: '2px' }}
                          >
                            <Text size="T300" truncate title={body}>
                              {body}
                            </Text>
                            <Text size="T200" priority="300">
                              {sender}
                              {size != null ? ` · ${formatBytes(size)}` : ''}
                            </Text>
                          </Box>
                          <IconButton
                            variant="SurfaceVariant"
                            size="300"
                            radii="300"
                            aria-label="Go to message"
                            onClick={() => {
                              const id = mEvent.getId();
                              if (id) jumpToMessage(id);
                            }}
                          >
                            <Icon size="200" src={Icons.Message} />
                          </IconButton>
                          {mxcUrl && (
                            <FileDownloadButton
                              filename={body}
                              url={mxcUrl}
                              mimeType={c.info?.mimetype ?? 'application/octet-stream'}
                              encInfo={c.file}
                            />
                          )}
                        </Box>
                      );
                    })}
                  </Box>
                </>
              )}

              {/* ── Audio / voice list ── */}
              {tab === 'audio' && (
                <>
                  {events.length === 0 && !loading && (
                    <Box
                      direction="Column"
                      alignItems="Center"
                      gap="200"
                      style={{ padding: config.space.S400 }}
                    >
                      <Icon src={Icons.VolumeHigh} size="600" />
                      <Text size="T300" priority="300" align="Center">
                        {hasLoadedOnce ? 'No audio found.' : 'No audio in recent history.'}
                      </Text>
                    </Box>
                  )}
                  <Box direction="Column" gap="200">
                    {events.map((mEvent) => {
                      const c = mEvent.getContent();
                      const url: string | undefined = c.file?.url ?? c.url;
                      if (!url) return null;
                      const body: string = c.body || 'Voice message';
                      const sender = getSenderName(room, mEvent.getSender() ?? '');
                      const relDate = formatRelativeDate(mEvent.getTs());
                      // Sanitize the mimetype the way MAudio does (e.g. application/ogg →
                      // audio/ogg) so the decrypted blob actually plays.
                      const mimeType = getBlobSafeMimeType(c.info?.mimetype ?? 'audio/ogg');
                      const filename = body.includes('.')
                        ? body
                        : `${body}.${mimeTypeToExt(mimeType)}`;
                      const waveform = (
                        c as unknown as { 'org.matrix.msc1767.audio'?: { waveform?: number[] } }
                      )['org.matrix.msc1767.audio']?.waveform;
                      return (
                        <Box
                          key={mEvent.getId()}
                          direction="Column"
                          gap="100"
                          style={{
                            padding: `${config.space.S200} ${config.space.S300}`,
                            borderRadius: config.radii.R300,
                            background: color.SurfaceVariant.Container,
                          }}
                        >
                          <Box alignItems="Center" gap="200">
                            <Box
                              grow="Yes"
                              direction="Column"
                              style={{ overflow: 'hidden', gap: '2px' }}
                            >
                              <Text size="T300" truncate title={body}>
                                {body}
                              </Text>
                              <Text size="T200" priority="300">
                                {sender} · {relDate}
                              </Text>
                            </Box>
                            <IconButton
                              variant="SurfaceVariant"
                              size="300"
                              radii="300"
                              aria-label="Go to message"
                              onClick={() => {
                                const id = mEvent.getId();
                                if (id) jumpToMessage(id);
                              }}
                            >
                              <Icon size="200" src={Icons.Message} />
                            </IconButton>
                            <FileDownloadButton
                              filename={filename}
                              url={url}
                              mimeType={mimeType}
                              encInfo={c.file}
                            />
                          </Box>
                          <AudioContent
                            mimeType={mimeType}
                            url={url}
                            info={c.info ?? {}}
                            encInfo={c.file}
                            waveform={waveform}
                            renderMediaControl={(p) => <MediaControl {...p} />}
                          />
                        </Box>
                      );
                    })}
                  </Box>
                </>
              )}

              {/* ── Pagination status / sentinel ── */}
              {loading && (
                <Box justifyContent="Center" style={{ padding: config.space.S300 }}>
                  <Spinner />
                </Box>
              )}
              {loadError && !loading && (
                <Box
                  direction="Column"
                  alignItems="Center"
                  gap="200"
                  style={{ padding: config.space.S300 }}
                >
                  <Text size="T200" priority="300" align="Center">
                    Failed to load history.
                  </Text>
                  <Button
                    size="300"
                    variant="Secondary"
                    fill="Soft"
                    radii="300"
                    onClick={handleLoadMore}
                  >
                    <Text size="B300">Retry</Text>
                  </Button>
                </Box>
              )}
              {!loading && !loadError && !canLoadMore && hasLoadedOnce && events.length > 0 && (
                <Text
                  size="T200"
                  priority="300"
                  align="Center"
                  style={{ padding: `${config.space.S200} 0` }}
                >
                  Beginning of history
                </Text>
              )}
              {/* IntersectionObserver sentinel — only rendered when safe to auto-trigger */}
              {canLoadMore && !loading && !loadError && (
                <div ref={sentinelRef} style={{ height: 1 }} />
              )}
            </Box>
          </Scroll>
        </Box>
      </Box>

      {/* Lightbox */}
      {lightboxIndex !== null && (
        <Lightbox
          items={lightboxItems}
          initialIndex={lightboxIndex}
          useAuthentication={useAuthentication}
          onClose={() => setLightboxIndex(null)}
          onJump={jumpToMessage}
        />
      )}
    </>
  );
}
