// Body for the room media viewer — mounted inside both the mobile
// `MobileMediaViewerHorseshoe` panel-body and the desktop floating
// `RoomViewMediaWindow`. Owns:
//   • Resolving + decrypting the media URL for the current entry,
//     including revoke-on-unmount for blob URLs we create.
//   • Zoom for images (button row + `+`/`-`/`0` keys).
//   • Mouse-drag pan when zoomed, with bounds clamping so the
//     image can't be dragged off the stage.
//   • Multi-media navigation: prev / next via on-screen chevrons,
//     keyboard arrows, and horizontal swipe (image only — video
//     keeps its native seekbar drag).
//   • Programmatic `<video>` play on each entry change.
//   • Download via `FileSaver`.

import React, { MouseEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Box, Icon, IconButton, Icons, Spinner, Text } from 'folds';
import classNames from 'classnames';
import FileSaver from 'file-saver';
import { MatrixEvent, MatrixEventEvent, MsgType, RoomEvent } from 'matrix-js-sdk';
import { useTranslation } from 'react-i18next';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import { PageHeader } from '../../components/page';
import { ContainerColor } from '../../styles/ContainerColor.css';
import { MediaViewerEntry } from '../../state/mediaViewer';
import { useOpenMediaViewer } from '../../state/hooks/mediaViewer';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useRoom } from '../../hooks/useRoom';
import { useZoom } from '../../hooks/useZoom';
import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../utils/matrix';
import { FALLBACK_MIMETYPE } from '../../utils/mimeTypes';
import {
  IImageContent,
  IImageInfo,
  IThumbnailContent,
  IVideoContent,
  IVideoInfo,
} from '../../../types/matrix/common';
import * as css from './MediaViewerBody.css';

// Distance past which a horizontal pointer drag commits to prev/next
// when zoom = 1 (matches the sheet's vertical close threshold).
const SWIPE_COMMIT_PX = 80;
// Horizontal must dominate vertical by at least this factor before
// the gesture is interpreted as a swipe — keeps accidental diagonal
// drags from committing.
const SWIPE_DOMINANCE = 1.5;
// Duration of the slide animation when a prev/next navigation commits
// (swipe release past threshold, chevron tap, arrow key).
const SWIPE_ANIM_MS = 280;
// Gutter between adjacent images in the swipe filmstrip, so consecutive
// pictures don't read as glued edge-to-edge while sliding. The panel
// «pitch» (centre-to-centre travel) is therefore stageWidth + this.
const SWIPE_GAP_PX = 24;

// Walk the room's loaded timeline events and build the ordered set
// of viewable image + video events. Out-of-window media (older than
// the loaded scrollback) is intentionally not surfaced for phase 1;
// `step()` resistance-snaps at the boundary.
function mediaKindFor(ev: MatrixEvent): 'image' | 'video' | null {
  if (ev.getType() !== 'm.room.message') return null;
  if (ev.isRedacted()) return null;
  const content = ev.getContent() as { msgtype?: string; url?: string; file?: { url?: string } };
  if (!content) return null;
  const hasMedia = !!content.url || !!content.file?.url;
  if (!hasMedia) return null;
  if (content.msgtype === MsgType.Image) return 'image';
  if (content.msgtype === MsgType.Video) return 'video';
  return null;
}

function eventToEntry(roomId: string, ev: MatrixEvent): MediaViewerEntry | null {
  const kind = mediaKindFor(ev);
  if (!kind) return null;
  const content = ev.getContent() as (IImageContent | IVideoContent) | undefined;
  if (!content) return null;
  const eventId = ev.getId();
  if (!eventId) return null;
  const { file } = content;
  const url = file?.url ?? content.url;
  if (!url) return null;
  const encInfo: EncryptedAttachmentInfo | undefined = file
    ? (() => {
        // Strip the `url` field — `EncryptedAttachmentInfo` doesn't
        // include it (the wire shape `IEncryptedFile` does).
        // eslint-disable-next-line @typescript-eslint/no-unused-vars
        const { url: _, ...rest } = file;
        return rest as EncryptedAttachmentInfo;
      })()
    : undefined;
  return {
    roomId,
    eventId,
    kind,
    url,
    body: content.filename ?? content.body ?? eventId,
    info: content.info as ((IImageInfo | IVideoInfo) & IThumbnailContent) | undefined,
    encInfo,
    mimeType: content.info?.mimetype,
  };
}

// A non-interactive neighbour image for the swipe «filmstrip». It resolves
// (and shares, via the parent's `resolveSrc` cache) its own decrypted src so
// the prev / next image is already on-screen as the user drags, then slides
// to centre on commit. Image-only — videos keep instant navigation. Sits
// BEHIND the interactive current image (`pointer-events: none`).
function SwipePreview({
  entry,
  resolveSrc,
  offsetPx,
  transition,
}: {
  entry: MediaViewerEntry;
  resolveSrc: (e: MediaViewerEntry) => Promise<string>;
  offsetPx: number;
  transition: string;
}) {
  const [src, setSrc] = useState<string | undefined>(undefined);
  useEffect(() => {
    let alive = true;
    resolveSrc(entry)
      .then((s) => {
        if (alive) setSrc(s);
      })
      .catch(() => {
        /* neighbour preview is best-effort */
      });
    return () => {
      alive = false;
    };
  }, [entry, resolveSrc]);
  return (
    <div className={css.previewLayer} aria-hidden="true">
      {src && (
        <img
          src={src}
          alt=""
          className={css.image}
          draggable={false}
          style={{ transform: `translate3d(${offsetPx}px, 0, 0)`, transition }}
        />
      )}
    </div>
  );
}

type MediaViewerBodyProps = {
  entry: MediaViewerEntry;
  requestClose: () => void;
  // Drop the title/zoom/download/close toolbar entirely. The desktop
  // floating window owns its own minimal chrome (drag bar + close), so
  // it passes this; zoom is wheel-only and nav is keyboard/chevron
  // there. The mobile horseshoe + (legacy) side pane leave it false to
  // keep the full toolbar.
  hideHeader?: boolean;
};

export function MediaViewerBody({ entry, requestClose, hideHeader }: MediaViewerBodyProps) {
  const { t } = useTranslation();
  const mx = useMatrixClient();
  const useAuthentication = useMediaAuthentication();
  const room = useRoom();
  const openMediaViewer = useOpenMediaViewer();

  // `useZoom` gives us `zoom` + `setZoom`; `zoomIn`/`zoomOut` from
  // it use a flat max=5, but we want per-image dynamic ceiling.
  // Override with custom +/− callbacks that read `maxZoomRef`.
  const { zoom, setZoom } = useZoom(0.25);

  // Pan is local (not the shared `usePan` hook) because we need to
  // clamp against the image bounds so the user can't drag a zoomed
  // image past its edges — `usePan` accumulates raw `movementX/Y`
  // without bounds, which lets the picture escape off-screen.
  // Clamp formula: with `transform: translate3d(X, Y) scale(zoom)`,
  // the translate is applied AFTER scaling, so X/Y are in stage
  // pixels. Max pan in each axis = (rendered_size * zoom -
  // stage_size) / 2. Image fits within stage at zoom=1 (object-fit:
  // contain), so clamp is zero there and pan auto-resets via the
  // `useEffect` below that watches `zoom`.
  const stageRef = useRef<HTMLDivElement>(null);
  const imgRef = useRef<HTMLImageElement>(null);
  const videoRef = useRef<HTMLVideoElement>(null);
  const [pan, setPan] = useState<{ x: number; y: number }>({ x: 0, y: 0 });
  const [panCursor, setPanCursor] = useState<'grab' | 'grabbing' | 'initial'>('initial');

  // Horizontal swipe / slide state (declared here, before the
  // navigation + keyboard handlers that read it). `swipeOffset` is the
  // live drag translate; `swipeAnimating` is true while a commit slide
  // is settling (so the transition is on for the ±stageWidth glide);
  // `stageWidth` feeds the neighbour-panel positions and the commit
  // target; `commitTimerRef` defers the entry swap to the end of the
  // slide. Gesture detail is in the big stage-listener effect below.
  const [swipeOffset, setSwipeOffset] = useState(0);
  const [swipeAnimating, setSwipeAnimating] = useState(false);
  const [stageWidth, setStageWidth] = useState(0);
  const commitTimerRef = useRef<number | null>(null);
  const prefersReducedMotion = useMemo(
    () =>
      typeof window !== 'undefined' &&
      window.matchMedia('(prefers-reduced-motion: reduce)').matches,
    []
  );

  // Mirror `zoom` and `pan` into refs so callbacks installed in
  // long-lived listeners can read the latest values without
  // re-binding, and so the inlined anchor-aware zoom math in
  // pinch / wheel can read current zoom/pan synchronously.
  const zoomRef = useRef(zoom);
  zoomRef.current = zoom;
  const panRef = useRef(pan);
  panRef.current = pan;

  // Per-image zoom bounds. `minZoom` is fixed at 1.0 — with
  // `object-fit: contain` the image already fills the stage to fit
  // at zoom=1, and going smaller just leaves dead space. `maxZoom`
  // is computed from the natural image size in `onImageLoad` so a
  // hi-res screenshot can zoom up to 1:1 pixels (~ natural /
  // displayed) and a small avatar gets a friendlier 2× ceiling.
  // Falls back to a flat 5× for the brief window before load.
  const MIN_ZOOM = 1;
  const ZOOM_CEILING_FALLBACK = 5;
  const ZOOM_CEILING_HARD_CAP = 8;
  const [maxZoom, setMaxZoom] = useState(ZOOM_CEILING_FALLBACK);
  // `maxZoom` mirrored into a ref for the long-lived gesture
  // listeners installed once with `deps=[]` below (they need the
  // latest ceiling, not the value at install time).
  const maxZoomRef = useRef(maxZoom);
  maxZoomRef.current = maxZoom;

  // Custom zoom +/− that respect the dynamic per-image ceiling
  // (`useZoom`'s built-in `zoomIn`/`zoomOut` would cap at the
  // flat default of 5). Step size 0.25 matches the original hook
  // config.
  const zoomIn = useCallback(() => {
    setZoom((z) => Math.min(maxZoomRef.current, z + 0.25));
  }, [setZoom]);
  const zoomOut = useCallback(() => {
    setZoom((z) => Math.max(MIN_ZOOM, z - 0.25));
  }, [setZoom]);

  const onImageLoad = useCallback(() => {
    const img = imgRef.current;
    if (!img) return;
    if (img.naturalWidth === 0 || img.offsetWidth === 0) return;
    // Ratio to reach 1:1 pixels — how far we'd need to scale the
    // displayed (object-fit-contained) image to match its native
    // resolution. For images naturally smaller than the display
    // (avatars), this is <1; floor to 2 so the user can still
    // inspect detail.
    const oneToOne = img.naturalWidth / img.offsetWidth;
    const computed = Math.max(2, oneToOne);
    setMaxZoom(Math.min(ZOOM_CEILING_HARD_CAP, computed));
  }, []);

  const clampPan = useCallback((raw: { x: number; y: number }, currentZoom: number) => {
    const stage = stageRef.current;
    const img = imgRef.current;
    if (!stage || !img) return raw;
    const stageRect = stage.getBoundingClientRect();
    const maxX = Math.max(0, (img.offsetWidth * currentZoom - stageRect.width) / 2);
    const maxY = Math.max(0, (img.offsetHeight * currentZoom - stageRect.height) / 2);
    return {
      x: Math.max(-maxX, Math.min(maxX, raw.x)),
      y: Math.max(-maxY, Math.min(maxY, raw.y)),
    };
  }, []);

  // Anchor-aware zoom math (image-local point under anchor stays
  // under the anchor after zoom change) is inlined in the pinch
  // and wheel handlers below — they each need to construct the
  // anchor from their own gesture state, so a shared helper would
  // just be plumbing.

  // Reset zoom, pan, and max-zoom-ceiling whenever the active
  // entry changes (swipe / arrow / programmatic). Without
  // resetting `maxZoom`, navigating from a low-res image (where
  // `onImageLoad` set a small ceiling) to a high-res one would
  // briefly use the stale ceiling until the new image's `onLoad`
  // fires. Gesture state is reset in a separate effect below.
  // Also cancel any in-flight commit slide (e.g. an external open
  // mid-animation) so its deferred entry swap can't fire stale.
  useEffect(() => {
    setZoom(1);
    setPan({ x: 0, y: 0 });
    setMaxZoom(ZOOM_CEILING_FALLBACK);
    if (commitTimerRef.current !== null) {
      window.clearTimeout(commitTimerRef.current);
      commitTimerRef.current = null;
    }
    setSwipeAnimating(false);
  }, [entry.eventId, setZoom]);

  // Track the stage width so the swipe filmstrip can position the
  // prev/next panels exactly one stage-width to either side and the
  // commit slide knows how far to glide.
  useEffect(() => {
    const el = stageRef.current;
    if (!el) return undefined;
    setStageWidth(el.clientWidth);
    const ro = new ResizeObserver(() => setStageWidth(el.clientWidth));
    ro.observe(el);
    return () => ro.disconnect();
  }, []);

  // Re-clamp the existing pan against the new bounds when zoom
  // changes (e.g. zooming out from 3× to 1× should snap a panned
  // image back to centre since there's nothing to pan into).
  useEffect(() => {
    setPan((prev) => {
      const next = clampPan(prev, zoom);
      if (next.x === prev.x && next.y === prev.y) return prev;
      return next;
    });
    setPanCursor(zoom === 1 ? 'initial' : 'grab');
  }, [zoom, clampPan]);

  // Mouse-drag pan. Touch is handled by the stage-listener block
  // below (which routes touch to swipe when zoom=1, ignores when
  // zoom > 1 — pinch + touch-pan is a follow-up).
  const onMouseDown: MouseEventHandler<HTMLImageElement> = useCallback(
    (evt) => {
      if (zoomRef.current === 1) return;
      evt.preventDefault();
      setPanCursor('grabbing');

      const onMove = (mEvt: MouseEvent) => {
        mEvt.preventDefault();
        mEvt.stopPropagation();
        const raw = {
          x: panRef.current.x + mEvt.movementX,
          y: panRef.current.y + mEvt.movementY,
        };
        const clamped = clampPan(raw, zoomRef.current);
        panRef.current = clamped;
        setPan(clamped);
      };
      const onUp = (mEvt: MouseEvent) => {
        mEvt.preventDefault();
        setPanCursor('grab');
        document.removeEventListener('mousemove', onMove);
        document.removeEventListener('mouseup', onUp);
      };

      document.addEventListener('mousemove', onMove);
      document.addEventListener('mouseup', onUp);
    },
    [clampPan]
  );

  // Resolve + decrypt the media URL into a displayable src, CACHED by
  // eventId so the current image and its swipe neighbours share one
  // decrypted blob — that's what lets a swipe slide between two
  // already-decrypted images with no reload gap. Blob URLs (encrypted
  // media) are tracked in `blobUrlsRef` so we revoke exactly those on
  // eviction / unmount; plain http(s) srcs need no revoke. The
  // concurrent-resolve re-check below means a rapidly-replaced load (or
  // a preview + main racing on the same event) never leaks a duplicate
  // blob.
  const srcCacheRef = useRef<Map<string, string>>(new Map());
  const blobUrlsRef = useRef<Set<string>>(new Set());

  const resolveMediaSrc = useCallback(
    async (e: MediaViewerEntry): Promise<string> => {
      const cached = srcCacheRef.current.get(e.eventId);
      if (cached) return cached;
      const mediaUrl = mxcUrlToHttp(mx, e.url, useAuthentication);
      if (!mediaUrl) throw new Error('Invalid media URL');
      const enc = e.encInfo;
      if (!enc) {
        srcCacheRef.current.set(e.eventId, mediaUrl);
        return mediaUrl;
      }
      const fileContent = await downloadEncryptedMedia(mediaUrl, (encBuf) =>
        decryptFile(encBuf, e.mimeType ?? FALLBACK_MIMETYPE, enc)
      );
      // A concurrent resolve for the same event may have won the cache
      // while we decrypted — drop our duplicate blob and use the winner.
      const winner = srcCacheRef.current.get(e.eventId);
      if (winner) return winner;
      const blob = URL.createObjectURL(fileContent);
      blobUrlsRef.current.add(blob);
      srcCacheRef.current.set(e.eventId, blob);
      return blob;
    },
    [mx, useAuthentication]
  );

  const [srcState, loadSrc] = useAsyncCallback(
    useCallback(() => resolveMediaSrc(entry), [resolveMediaSrc, entry])
  );

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

  // Revoke every blob URL we created (and cancel a pending commit
  // slide) when the viewer body unmounts — sheet closed, room change.
  useEffect(
    () => () => {
      blobUrlsRef.current.forEach((u) => URL.revokeObjectURL(u));
      blobUrlsRef.current.clear();
      srcCacheRef.current.clear();
      if (commitTimerRef.current !== null) {
        window.clearTimeout(commitTimerRef.current);
        commitTimerRef.current = null;
      }
    },
    []
  );

  // Build the room's image set. Recomputed on each render — cheap
  // (timeline events are already in memory). Not subscribed to new
  // events for phase 1; the user navigates among the images that
  // were visible when they opened the viewer.
  // Bump a version counter on every live-timeline mutation in this
  // room so `mediaSet` rebuilds when a new image / video event
  // arrives mid-viewing — without this the user navigates a frozen
  // snapshot. Mirror of Element-Web's FilePanel pattern. Redaction
  // is also subscribed so a remotely-deleted image disappears from
  // the prev/next chain.
  //
  // Per-event `MatrixEventEvent.Decrypted` subscription is deliberately
  // skipped (cost of attaching N listeners). Late-arriving decrypts
  // of pre-existing events are an accepted phase-1 limitation —
  // documented in the comment on `mediaKindFor`.
  const [timelineVersion, setTimelineVersion] = useState(0);
  useEffect(() => {
    const bump = () => setTimelineVersion((v) => v + 1);
    // Filter Timeline events to media kinds so the mediaSet
    // recompute doesn't fire on every reaction / edit / receipt /
    // sync rollback (busy rooms produce dozens per second). Each
    // recompute does an O(n) walk of `getLiveTimeline().getEvents()`.
    const onTimeline = (ev: MatrixEvent) => {
      if (mediaKindFor(ev) !== null) bump();
    };
    // Encrypted rooms (Vojo's primary surface): events arrive as
    // `m.room.encrypted` and decrypt asynchronously after the
    // Timeline event has fired — `mediaKindFor` returns null at
    // that moment, so `onTimeline` above doesn't bump. When the
    // event decrypts, `MatrixEventEvent.Decrypted` fires on the
    // event itself. We attach via the MatrixClient so we catch
    // every decrypt without subscribing to N per-event listeners,
    // and filter to this room. Mirror of Element-Web's `FilePanel`
    // pattern.
    const onDecrypted = (ev: MatrixEvent) => {
      if (ev.getRoomId() !== room.roomId) return;
      if (mediaKindFor(ev) !== null) bump();
    };
    // Redactions: bump unconditionally — the redacted event may
    // have been a media we're currently navigating, and we can't
    // re-derive its kind once redacted.
    room.on(RoomEvent.Timeline, onTimeline);
    room.on(RoomEvent.Redaction, bump);
    mx.on(MatrixEventEvent.Decrypted, onDecrypted);
    return () => {
      room.removeListener(RoomEvent.Timeline, onTimeline);
      room.removeListener(RoomEvent.Redaction, bump);
      mx.removeListener(MatrixEventEvent.Decrypted, onDecrypted);
    };
  }, [mx, room]);

  const mediaSet = useMemo(() => {
    const events = room.getLiveTimeline().getEvents();
    const entries: MediaViewerEntry[] = [];
    events.forEach((ev) => {
      const e = eventToEntry(room.roomId, ev);
      if (e) entries.push(e);
    });
    return entries;
    // `timelineVersion` is the actual refresh trigger; `entry.eventId`
    // is intentionally NOT a dep — stepping doesn't change the set.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [room, timelineVersion]);

  const currentIndex = useMemo(
    () => mediaSet.findIndex((e) => e.eventId === entry.eventId),
    [mediaSet, entry.eventId]
  );

  // Navigate prev/next with a slide. For image→image (and motion
  // allowed, stage measured) we glide the current image out and the
  // neighbour panel in over `SWIPE_ANIM_MS`, THEN swap the atom entry —
  // because the neighbour was prefetched into the shared src cache the
  // new current paints instantly at centre, so the swap is seamless.
  // Anything else (video involved, reduced motion, stage not measured)
  // falls back to an instant switch. Guarded against re-entry while a
  // commit slide is already in flight.
  const animatedStep = useCallback(
    (delta: -1 | 1) => {
      if (commitTimerRef.current !== null) return;
      if (currentIndex < 0) return;
      const targetIndex = currentIndex + delta;
      if (targetIndex < 0 || targetIndex >= mediaSet.length) return;
      const target = mediaSet[targetIndex];
      if (!target) return;
      const stageW = stageRef.current?.clientWidth ?? 0;
      // Slide only at rest zoom (a zoomed image has no neighbour panels
      // and shouldn't glide while scaled — chevron/arrow nav while zoomed
      // switches instantly). `zoomRef` keeps this out of the deps.
      const canSlide =
        !prefersReducedMotion &&
        stageW > 0 &&
        zoomRef.current === 1 &&
        entry.kind === 'image' &&
        target.kind === 'image';
      if (!canSlide) {
        setSwipeOffset(0);
        openMediaViewer(target);
        return;
      }
      setSwipeAnimating(true);
      // delta +1 (next) slides content LEFT by one panel pitch (stage +
      // gutter) → current glides off-screen while the prefetched next
      // panel (sitting one pitch to the right) glides to centre.
      const pitch = stageW + SWIPE_GAP_PX;
      setSwipeOffset(delta === 1 ? -pitch : pitch);
      commitTimerRef.current = window.setTimeout(() => {
        commitTimerRef.current = null;
        setSwipeAnimating(false);
        setSwipeOffset(0);
        openMediaViewer(target);
      }, SWIPE_ANIM_MS);
    },
    [currentIndex, mediaSet, entry.kind, openMediaViewer, prefersReducedMotion]
  );

  const canPrev = currentIndex > 0;
  const canNext = currentIndex >= 0 && currentIndex < mediaSet.length - 1;

  // Prefetch the immediate image neighbours so a swipe slides into an
  // already-decrypted picture, and prune cached srcs outside a small
  // window around the current index (revoking their blobs) to bound
  // memory. The current entry and its ±1 neighbours are always kept, so
  // we never revoke a src a live <img> is using.
  useEffect(() => {
    if (currentIndex < 0) return;
    [mediaSet[currentIndex - 1], mediaSet[currentIndex + 1]].forEach((e) => {
      if (e && e.kind === 'image') resolveMediaSrc(e).catch(() => undefined);
    });
    const keep = new Set<string>([entry.eventId]);
    for (let d = -2; d <= 2; d += 1) {
      const e = mediaSet[currentIndex + d];
      if (e) keep.add(e.eventId);
    }
    srcCacheRef.current.forEach((src, eid) => {
      if (keep.has(eid)) return;
      if (blobUrlsRef.current.has(src)) {
        URL.revokeObjectURL(src);
        blobUrlsRef.current.delete(src);
      }
      srcCacheRef.current.delete(eid);
    });
  }, [currentIndex, mediaSet, entry.eventId, resolveMediaSrc]);

  // Keyboard — ArrowLeft / Right step, +/- / 0 manage zoom. Esc is
  // handled by the parent shell via an explicit window keydown listener
  // (both the mobile horseshoe and the desktop floating window).
  useEffect(() => {
    const onKeyDown = (e: KeyboardEvent) => {
      if (e.defaultPrevented) return;
      const target = e.target as HTMLElement | null;
      if (
        target &&
        (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
      ) {
        return;
      }
      if (e.key === 'ArrowLeft') {
        e.preventDefault();
        animatedStep(-1);
      } else if (e.key === 'ArrowRight') {
        e.preventDefault();
        animatedStep(1);
      } else if (e.key === '+' || e.key === '=') {
        e.preventDefault();
        zoomIn();
      } else if (e.key === '-' || e.key === '_') {
        e.preventDefault();
        zoomOut();
      } else if (e.key === '0') {
        e.preventDefault();
        setZoom(1);
      }
    };
    window.addEventListener('keydown', onKeyDown);
    return () => window.removeEventListener('keydown', onKeyDown);
  }, [animatedStep, zoomIn, zoomOut, setZoom]);

  // Multi-touch gesture state on the image stage. Three modes:
  //   • zoom=1, 1 finger → horizontal-swipe to prev/next image.
  //   • zoom>1, 1 finger → touch pan (clamped to image bounds; same
  //     formula as the mouse pan path).
  //   • 2 fingers → pinch zoom (distance ratio drives zoom; pan
  //     resets to 0 so the image stays centered while pinching).
  // Listeners are installed ONCE and read the latest closure values
  // via refs — without that, a zoom-button press would re-bind every
  // listener mid-gesture. PointerEvents only (no TouchEvents) — on
  // Chromium both fire for touch, which double-fires every handler.
  // The MDN reference pattern that drove this implementation:
  // https://developer.mozilla.org/en-US/docs/Web/API/Pointer_events/Pinch_zoom_gestures
  // (`swipeOffset` / `swipeAnimating` are declared up top with the rest
  // of the slide state.)

  const swipeStateRef = useRef({
    active: false,
    startX: 0,
    startY: 0,
    lastDx: 0,
    claimed: 'none' as 'none' | 'swipe' | 'reject',
  });

  // Active touch pointers keyed by `pointerId`. Map (not array)
  // because PointerEvents can arrive interleaved across multiple
  // pointers and we need to update the per-id entry on each move.
  const pointerCacheRef = useRef<Map<number, { x: number; y: number }>>(new Map());

  // Pinch state. `null` when not pinching; set on the 2nd pointer
  // down. `initialDistance` is the finger spread at pinch start;
  // `initialZoom` and `initialPan` snapshot the state we'll scale
  // from. `anchorImageX/Y` is the image-local point under the
  // pinch midpoint at the start — held constant under the user's
  // fingers as zoom changes (anchor-aware pinch, mirrors what
  // Apple Photos / Google Photos / Element-Web's wheel-zoom do).
  const pinchStateRef = useRef<{
    initialDistance: number;
    initialZoom: number;
    initialPan: { x: number; y: number };
    anchorImageX: number;
    anchorImageY: number;
  } | null>(null);

  // Touch pan state. `null` when not panning. Stores the pan offset
  // at the start of the gesture so we can compute the delta off the
  // current pointer position (matches Element-Web's
  // absolute-delta-from-anchor approach, more reliable than
  // accumulating `movementX/Y`).
  const touchPanStateRef = useRef<{
    startX: number;
    startY: number;
    panAtStartX: number;
    panAtStartY: number;
  } | null>(null);

  // Refs mirror the latest values for the always-installed listeners
  // below. Without these, the listeners would close over the values
  // from the render that installed them.
  const canPrevRef = useRef(canPrev);
  canPrevRef.current = canPrev;
  const canNextRef = useRef(canNext);
  canNextRef.current = canNext;
  // Whether the prev/next neighbour is an IMAGE (i.e. slides). A video
  // neighbour (or a true edge) instead rubber-bands the drag — there's no
  // slide panel to follow the finger into, it switches instantly on
  // release. Mirrored for the once-installed gesture listeners.
  const prevIsImageRef = useRef(false);
  prevIsImageRef.current = currentIndex > 0 && mediaSet[currentIndex - 1]?.kind === 'image';
  const nextIsImageRef = useRef(false);
  nextIsImageRef.current =
    currentIndex >= 0 &&
    currentIndex < mediaSet.length - 1 &&
    mediaSet[currentIndex + 1]?.kind === 'image';
  const animatedStepRef = useRef(animatedStep);
  animatedStepRef.current = animatedStep;
  const entryKindRef = useRef(entry.kind);
  entryKindRef.current = entry.kind;
  // `zoomRef` + `panRef` are already mirrored in the pan section
  // above; reusing them here keeps the source of truth singular.
  const clampPanRef = useRef(clampPan);
  clampPanRef.current = clampPan;
  const setZoomRef = useRef(setZoom);
  setZoomRef.current = setZoom;
  // `maxZoomRef` + `zoomIn` / `zoomOut` are declared higher up
  // (right after the `maxZoom` state) because the keyboard
  // useEffect above also depends on them.

  // Reset gesture state on entry change. A finger that lifted
  // off-stage (no `pointerup` delivered) leaves residue in
  // `pointerCacheRef`, so without this the next entry's first
  // `pointerdown` sees `count === 2` from nothing and starts a
  // fake pinch on a stale midpoint. Similar concern for swipe /
  // touch-pan state. `setPointerCapture` (added in the listeners
  // below) should normally guarantee a pointerup delivery; this
  // reset is the defence-in-depth.
  useEffect(() => {
    pointerCacheRef.current.clear();
    pinchStateRef.current = null;
    touchPanStateRef.current = null;
    swipeStateRef.current.active = false;
    swipeStateRef.current.claimed = 'none';
    setSwipeOffset(0);
  }, [entry.eventId]);

  useEffect(() => {
    const stageEl = stageRef.current;
    if (!stageEl) return undefined;

    const distanceBetween = (a: { x: number; y: number }, b: { x: number; y: number }) =>
      Math.hypot(a.x - b.x, a.y - b.y);

    const onPointerDown = (e: PointerEvent) => {
      // Mouse drag at zoom>1 is handled by the image's `onMouseDown`
      // → pan; mouse drag at zoom=1 has no useful gesture. Skip
      // mouse here for everything.
      if (e.pointerType === 'mouse') return;
      if (entryKindRef.current !== 'image') return;
      // Ignore new touches while a commit slide is gliding (the entry is
      // about to swap). Without this, a fresh pointerdown mid-glide would
      // run `setSwipeOffset(0)` while `swipeAnimating` is still on and
      // animate the outgoing image BACK toward centre — a reverse/stutter
      // on fast double-swipes. Mirrors the same guard in `animatedStep`.
      if (commitTimerRef.current !== null) return;

      // Capture the pointer so subsequent `pointermove`/`pointerup`
      // events are dispatched to the stage even when the finger
      // leaves the stage rect (e.g. user pans onto the header).
      // Without this, the gesture can be stranded mid-flight with
      // a finger off-stage, leaving `pointerCacheRef` populated and
      // breaking the next gesture. `try/catch` because some
      // browsers reject capture on non-trusted events under
      // automation harnesses.
      try {
        stageEl.setPointerCapture(e.pointerId);
      } catch {
        // ignore — capture is best-effort
      }

      pointerCacheRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
      const count = pointerCacheRef.current.size;

      if (count === 2) {
        // 2nd finger arrived — switch to pinch mode. Cancel any
        // in-flight single-finger gesture (swipe / touch pan) so
        // it doesn't leak state into the pinch.
        const pts = Array.from(pointerCacheRef.current.values());
        // Pinch midpoint → stage-center-relative anchor → derive
        // the image-local point that's under the midpoint right
        // now. Holding this point under the moving midpoint is
        // what makes the zoom feel anchored.
        const midClientX = (pts[0].x + pts[1].x) / 2;
        const midClientY = (pts[0].y + pts[1].y) / 2;
        const stageRect = stageEl.getBoundingClientRect();
        const anchorX = midClientX - (stageRect.left + stageRect.width / 2);
        const anchorY = midClientY - (stageRect.top + stageRect.height / 2);
        const z0 = zoomRef.current;
        const p0 = panRef.current;
        pinchStateRef.current = {
          initialDistance: distanceBetween(pts[0], pts[1]),
          initialZoom: z0,
          initialPan: { x: p0.x, y: p0.y },
          anchorImageX: (anchorX - p0.x) / z0,
          anchorImageY: (anchorY - p0.y) / z0,
        };
        if (swipeStateRef.current.active) {
          swipeStateRef.current.active = false;
          setSwipeOffset(0);
        }
        touchPanStateRef.current = null;
        return;
      }

      if (count === 1) {
        if (zoomRef.current === 1) {
          // 1 finger + zoom=1 → swipe
          const s = swipeStateRef.current;
          s.active = true;
          s.startX = e.clientX;
          s.startY = e.clientY;
          s.lastDx = 0;
          s.claimed = 'none';
          setSwipeOffset(0);
        } else {
          // 1 finger + zoom>1 → touch pan
          touchPanStateRef.current = {
            startX: e.clientX,
            startY: e.clientY,
            panAtStartX: panRef.current.x,
            panAtStartY: panRef.current.y,
          };
        }
      }
    };

    const onPointerMove = (e: PointerEvent) => {
      if (e.pointerType === 'mouse') return;
      const cached = pointerCacheRef.current.get(e.pointerId);
      if (!cached) return;
      pointerCacheRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY });

      // Pinch always wins when 2 fingers are down.
      if (pinchStateRef.current && pointerCacheRef.current.size === 2) {
        if (e.cancelable) e.preventDefault();
        const ps = pinchStateRef.current;
        const pts = Array.from(pointerCacheRef.current.values());
        const dist = distanceBetween(pts[0], pts[1]);
        const ratio = dist / ps.initialDistance;
        const nextZoom = Math.max(MIN_ZOOM, Math.min(maxZoomRef.current, ps.initialZoom * ratio));
        // Anchor-aware: hold `anchorImage*` (image-local point
        // captured at pinch start) under the moving midpoint.
        const midClientX = (pts[0].x + pts[1].x) / 2;
        const midClientY = (pts[0].y + pts[1].y) / 2;
        const stageRect = stageEl.getBoundingClientRect();
        const anchorStageX = midClientX - (stageRect.left + stageRect.width / 2);
        const anchorStageY = midClientY - (stageRect.top + stageRect.height / 2);
        const rawPan = {
          x: anchorStageX - ps.anchorImageX * nextZoom,
          y: anchorStageY - ps.anchorImageY * nextZoom,
        };
        const clamped = clampPanRef.current(rawPan, nextZoom);
        panRef.current = clamped;
        setPan(clamped);
        setZoomRef.current(nextZoom);
        return;
      }

      // Single-finger paths run only if pinch isn't active.
      if (pinchStateRef.current) return;

      // Swipe path (zoom=1).
      const s = swipeStateRef.current;
      if (s.active) {
        const dx = e.clientX - s.startX;
        const dy = e.clientY - s.startY;
        s.lastDx = dx;
        if (s.claimed === 'none') {
          if (Math.abs(dx) > 8 || Math.abs(dy) > 8) {
            if (Math.abs(dx) > Math.abs(dy) * SWIPE_DOMINANCE) s.claimed = 'swipe';
            else s.claimed = 'reject';
          }
        }
        if (s.claimed !== 'swipe') return;
        if (e.cancelable) e.preventDefault();
        // Rubber-band (1/3 travel) when the swipe direction has nothing to
        // slide into — a true list edge OR a video neighbour (videos switch
        // instantly on release rather than gliding). A real image neighbour
        // follows the finger 1:1.
        const resisted = (dx > 0 && !prevIsImageRef.current) || (dx < 0 && !nextIsImageRef.current);
        setSwipeOffset(resisted ? dx / 3 : dx);
        return;
      }

      // Touch-pan path (zoom>1).
      const tp = touchPanStateRef.current;
      if (tp) {
        if (e.cancelable) e.preventDefault();
        const raw = {
          x: tp.panAtStartX + (e.clientX - tp.startX),
          y: tp.panAtStartY + (e.clientY - tp.startY),
        };
        const clamped = clampPanRef.current(raw, zoomRef.current);
        panRef.current = clamped;
        setPan(clamped);
      }
    };

    const onPointerEnd = (e: PointerEvent) => {
      if (e.pointerType === 'mouse') return;
      // Release the pointer capture taken on `pointerdown`. Browsers
      // auto-release on `pointerup` / `pointercancel`, but
      // explicitly calling it on a non-captured pointer is a no-op
      // and the explicit release pairs the API call for clarity.
      try {
        if (stageEl.hasPointerCapture(e.pointerId)) {
          stageEl.releasePointerCapture(e.pointerId);
        }
      } catch {
        // ignore
      }
      pointerCacheRef.current.delete(e.pointerId);
      const count = pointerCacheRef.current.size;

      if (pinchStateRef.current && count < 2) {
        // Pinch ended. Reset pinch state but DON'T immediately
        // switch back to swipe / pan — the remaining finger (if
        // any) needs to lift first and the user starts a fresh
        // gesture cleanly.
        pinchStateRef.current = null;
        return;
      }

      if (count === 0) {
        const s = swipeStateRef.current;
        if (s.active) {
          s.active = false;
          let committed = false;
          if (s.claimed === 'swipe') {
            if (s.lastDx > SWIPE_COMMIT_PX && canPrevRef.current) {
              animatedStepRef.current(-1);
              committed = true;
            } else if (s.lastDx < -SWIPE_COMMIT_PX && canNextRef.current) {
              animatedStepRef.current(1);
              committed = true;
            }
          }
          // A commit lets `animatedStep` own the offset (glide to
          // ±stageWidth); a non-committing release snaps back to centre.
          if (!committed) setSwipeOffset(0);
        }
        touchPanStateRef.current = null;
      }
    };

    // Desktop wheel zoom — anchored to the cursor (the same point
    // under the cursor stays under the cursor as zoom changes).
    // Element-Web's idiom: step factor proportional to `deltaY`,
    // sign inverted so wheel-up zooms IN. `passive: false` so we
    // can preventDefault the browser's native page scroll.
    //
    // Browsers send wheel events with very different `deltaMode`
    // values (pixel vs line vs page). We normalize to pixels.
    const onWheel = (e: WheelEvent) => {
      if (entryKindRef.current !== 'image') return;
      e.preventDefault();
      let dyPx: number;
      if (e.deltaMode === 1) {
        dyPx = e.deltaY * 16; // line mode → ~16px / line
      } else if (e.deltaMode === 2) {
        dyPx = e.deltaY * stageEl.clientHeight; // page mode
      } else {
        dyPx = e.deltaY;
      }
      const factor = Math.exp(-dyPx * 0.0025);
      const oldZoom = zoomRef.current;
      const nextZoomRaw = oldZoom * factor;
      const nextZoom = Math.max(MIN_ZOOM, Math.min(maxZoomRef.current, nextZoomRaw));
      if (nextZoom === oldZoom) return;
      // Anchor-aware: hold the image-local point under the cursor
      // fixed across the zoom change.
      const stageRect = stageEl.getBoundingClientRect();
      const anchorX = e.clientX - (stageRect.left + stageRect.width / 2);
      const anchorY = e.clientY - (stageRect.top + stageRect.height / 2);
      const p0 = panRef.current;
      const imageX = (anchorX - p0.x) / oldZoom;
      const imageY = (anchorY - p0.y) / oldZoom;
      const rawPan = {
        x: anchorX - imageX * nextZoom,
        y: anchorY - imageY * nextZoom,
      };
      const clamped = clampPanRef.current(rawPan, nextZoom);
      panRef.current = clamped;
      setPan(clamped);
      setZoomRef.current(nextZoom);
    };

    stageEl.addEventListener('pointerdown', onPointerDown);
    stageEl.addEventListener('pointermove', onPointerMove);
    stageEl.addEventListener('pointerup', onPointerEnd);
    stageEl.addEventListener('pointercancel', onPointerEnd);
    stageEl.addEventListener('wheel', onWheel, { passive: false });

    return () => {
      stageEl.removeEventListener('pointerdown', onPointerDown);
      stageEl.removeEventListener('pointermove', onPointerMove);
      stageEl.removeEventListener('pointerup', onPointerEnd);
      stageEl.removeEventListener('pointercancel', onPointerEnd);
      stageEl.removeEventListener('wheel', onWheel);
    };
  }, []);

  // Prefer the shared cache so a just-committed (prefetched) entry paints
  // instantly with no `useAsyncCallback` Loading flash; fall back to the
  // loader's result for the first, uncached open.
  const cachedSrc = srcCacheRef.current.get(entry.eventId);
  const effectiveSrc =
    cachedSrc ?? (srcState.status === AsyncStatus.Success ? srcState.data : undefined);
  const isLoading =
    !effectiveSrc &&
    (srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Idle);
  const isError = !effectiveSrc && srcState.status === AsyncStatus.Error;
  const isReady = !!effectiveSrc;

  const handleDownload = useCallback(async () => {
    if (!effectiveSrc) return;
    FileSaver.saveAs(effectiveSrc, entry.body);
  }, [effectiveSrc, entry.body]);

  // Video autoplay: the `<video>` element below ships with the
  // `muted` + `autoPlay` HTML attributes. Muted autoplay is
  // universally allowed (no gesture-activation needed) across
  // Chrome / Firefox / WebKit / Android Capacitor / iOS Safari —
  // see the MDN Autoplay guide. Unmuted autoplay would require a
  // fresh user-gesture per video, which fails on iOS Safari
  // after the first step in this viewer. Element-Web takes the
  // same `muted` stance for the same reason. The user can unmute
  // via the native `<video>` controls; subsequent videos in the
  // same viewer session keep their unmuted state inherited
  // through the element remount (key={entry.eventId} forces a
  // fresh element, so unmute does NOT persist — accepted UX
  // trade for "playing always works").

  const transform = isReady
    ? `translate3d(${swipeOffset + pan.x}px, ${pan.y}px, 0) scale(${zoom})`
    : undefined;
  // Transition ON while a commit slide settles (swipeAnimating) and at
  // rest (offset 0 — smooth zoom + snap-back); OFF during an active drag
  // so the image tracks the finger 1:1.
  let imageTransition = 'none';
  if (swipeAnimating) {
    imageTransition = `transform ${SWIPE_ANIM_MS}ms cubic-bezier(0.32, 0.72, 0, 1)`;
  } else if (swipeOffset === 0) {
    imageTransition = 'transform 200ms cubic-bezier(0.32, 0.72, 0, 1)';
  }

  // Swipe filmstrip neighbours — only at rest zoom and for images (swipe
  // is image-only). They slide WITH the current image (same offset, one
  // stage-width to either side) so the prev/next picture is visible as
  // the user drags and glides to centre on commit.
  const prevEntry = currentIndex > 0 ? mediaSet[currentIndex - 1] : undefined;
  const nextEntry =
    currentIndex >= 0 && currentIndex < mediaSet.length - 1
      ? mediaSet[currentIndex + 1]
      : undefined;
  const showSwipePreviews = entry.kind === 'image' && zoom === 1 && stageWidth > 0;

  return (
    <div className={css.root}>
      {!hideHeader && (
        <PageHeader
          // `PageHeader` (= folds `Header size="600"`) so the header's
          // bottom rule aligns to the pixel with the chat header to the
          // left across the 12px void gap — same trick the profile
          // side pane uses. A plain `<div height: 48px>` was visibly
          // shorter and broke the cross-pane seam.
          className={`${ContainerColor({ variant: 'Surface' })} ${css.header}`}
        >
          <Box grow="Yes" alignItems="Center" gap="200">
            <Text className={css.title} size="H4" truncate>
              {entry.body}
            </Text>
            {mediaSet.length > 1 && (
              <span className={css.indexPill} aria-hidden="true">
                {currentIndex >= 0 ? currentIndex + 1 : '?'} / {mediaSet.length}
              </span>
            )}
          </Box>
          <Box shrink="No" alignItems="Center" gap="100">
            {entry.kind === 'image' && (
              <>
                <IconButton
                  variant="Background"
                  fill="None"
                  radii="300"
                  size="300"
                  onClick={zoomOut}
                  aria-label={t('MediaViewer.zoom_out', 'Zoom out')}
                >
                  <Icon size="100" src={Icons.Minus} />
                </IconButton>
                <Text size="B300" as="span" style={{ minWidth: '3em', textAlign: 'center' }}>
                  {Math.round(zoom * 100)}%
                </Text>
                <IconButton
                  variant="Background"
                  fill="None"
                  radii="300"
                  size="300"
                  onClick={zoomIn}
                  aria-label={t('MediaViewer.zoom_in', 'Zoom in')}
                >
                  <Icon size="100" src={Icons.Plus} />
                </IconButton>
              </>
            )}
            <IconButton
              variant="Background"
              fill="None"
              radii="300"
              size="300"
              onClick={handleDownload}
              disabled={!isReady}
              aria-label={t('MediaViewer.download', 'Download')}
            >
              <Icon size="100" src={Icons.Download} />
            </IconButton>
            {entry.kind === 'image' && <div className={css.headerSeparator} aria-hidden="true" />}
            <IconButton
              variant="Background"
              fill="None"
              radii="300"
              size="300"
              onClick={requestClose}
              aria-label={t('Common.close', 'Close')}
            >
              <Icon size="100" src={Icons.Cross} />
            </IconButton>
          </Box>
        </PageHeader>
      )}

      <div className={css.stage} ref={stageRef}>
        {showSwipePreviews && prevEntry?.kind === 'image' && (
          <SwipePreview
            key={prevEntry.eventId}
            entry={prevEntry}
            resolveSrc={resolveMediaSrc}
            offsetPx={swipeOffset - stageWidth - SWIPE_GAP_PX}
            transition={imageTransition}
          />
        )}
        {showSwipePreviews && nextEntry?.kind === 'image' && (
          <SwipePreview
            key={nextEntry.eventId}
            entry={nextEntry}
            resolveSrc={resolveMediaSrc}
            offsetPx={swipeOffset + stageWidth + SWIPE_GAP_PX}
            transition={imageTransition}
          />
        )}
        {isReady && effectiveSrc && entry.kind === 'image' && (
          // `onMouseDown` drives drag-pan when the image is zoomed in.
          // Keyboard pan is wired separately at the stage level. Keyed by
          // eventId (like the video) so a commit mounts a fresh element
          // centred at offset 0 — no slide-back from the committed edge.
          // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
          <img
            ref={imgRef}
            key={entry.eventId}
            src={effectiveSrc}
            alt={entry.body}
            className={css.image}
            draggable={false}
            style={{
              transform,
              cursor: panCursor,
              transition: imageTransition,
            }}
            onMouseDown={onMouseDown}
            onLoad={onImageLoad}
          />
        )}
        {isReady && effectiveSrc && entry.kind === 'video' && (
          <video
            ref={videoRef}
            key={entry.eventId}
            src={effectiveSrc}
            title={entry.body}
            className={css.image}
            controls
            // Muted + autoPlay: see the rationale comment above
            // — muted autoplay is the only policy-compliant way
            // to start every video automatically including on iOS
            // Safari after a swipe / chevron step. User unmutes
            // via native controls.
            muted
            autoPlay
            playsInline
            style={{
              transform: `translate3d(${swipeOffset}px, 0, 0)`,
              transition: imageTransition,
            }}
          />
        )}

        {isLoading && (
          <div className={css.stateOverlay}>
            <Spinner variant="Secondary" size="600" />
          </div>
        )}
        {isError && (
          <div className={css.stateOverlay} style={{ pointerEvents: 'auto' }}>
            <IconButton
              variant="Critical"
              fill="Soft"
              radii="300"
              size="400"
              onClick={() => loadSrc()}
              aria-label={t('Common.retry', 'Retry')}
            >
              <Icon size="200" src={Icons.Warning} />
            </IconButton>
          </div>
        )}

        {mediaSet.length > 1 && (
          <>
            <button
              type="button"
              className={classNames(css.chevron, css.chevronLeft)}
              aria-label={t('MediaViewer.previous', 'Previous')}
              // `aria-disabled` (not `disabled`) so the CSS dimmed-
              // state selector still matches; the onClick already
              // bails internally via `animatedStep`'s bounds check.
              aria-disabled={!canPrev}
              onClick={() => animatedStep(-1)}
            >
              <Icon size="200" src={Icons.ChevronLeft} />
            </button>
            <button
              type="button"
              className={classNames(css.chevron, css.chevronRight)}
              aria-label={t('MediaViewer.next', 'Next')}
              aria-disabled={!canNext}
              onClick={() => animatedStep(1)}
            >
              <Icon size="200" src={Icons.ChevronRight} />
            </button>
          </>
        )}
      </div>
    </div>
  );
}
