// SitPage — slide-up overlay (microdot reel pattern).
//
// MOUNT
//   Mounted by App when openSit is set. The underlying route (feed or
//   history) stays mounted underneath. body.has-sit-overlay is set by
//   App, locking underlying scroll and hiding the site nav.
//
// ENTRY ANIMATION (state machine on .sit-overlay[data-phase="..."])
//   slideup    — empty BLACK overlay slides up from the bottom. The
//                heavy content (backdrop, cover, grid) is NOT yet
//                mounted, so the slide is instant — no React render lag.
//                520ms.
//   loading    — slideup done. Cover element mounts; we wait for its
//                image/video to actually be ready (onLoad/onCanPlay).
//                Background stays black during this period.
//   reveal     — cover fades in to fullscreen. 320ms.
//   immersive  — cover holds fullscreen. 480ms.
//   squish     — cover animates fullscreen → cover-tile target rect. 620ms.
//                Other tiles get data-entry="reveal" on the grid root and
//                fly in from below with a stagger delay per --i.
//   done       — cover layer fades out, in-grid cover tile becomes opaque.
//
// DISMISS
//   X button or Esc → phase becomes 'closing' → overlay translates back
//   down → after 520ms → onClose() unmounts.
//
// LAYOUT
//   12-col CSS grid. Tile slots from a deterministic rotation (see
//   gridSlotForIndex). Cover always pinned to the wide-centered slot
//   (2 / span 8) so its target rect is predictable.

const {
  useEffect: useSitEffect,
  useRef: useSitRef,
  useState: useSitState,
  useLayoutEffect: useSitLayoutEffect,
  useCallback: useSitCallback,
  useMemo: useSitMemo,
} = React;

// Deterministic 12-col slot rotation. Two tracks: wider slots for
// landscape media and narrower for portraits. Orientation comes from
// the actual aspect ratio (width/height), NOT the legacy `span` field
// which referred to feed-era column widths.
// Asymmetric column spans — microdot pattern. Each tile picks a different
// starting column + width so whitespace-between-tiles becomes composition,
// not gap. The sequence must not settle into a rhythm; adjacent indices
// pick spans that shift left/right by different amounts.
const LANDSCAPE_SLOTS = [
  '3 / span 8', '5 / span 8', '1 / span 6', '6 / span 7',
  '2 / span 9', '4 / span 7', '7 / span 6', '2 / span 6',
  '4 / span 9', '1 / span 7', '6 / span 6', '3 / span 7',
];
const PORTRAIT_SLOTS = [
  '4 / span 4', '8 / span 4', '2 / span 4', '7 / span 3',
  '5 / span 4', '1 / span 3', '9 / span 3', '3 / span 4',
  '6 / span 3', '2 / span 3', '8 / span 3', '5 / span 3',
];

const arRatio = (ar) => {
  const [w, h] = (ar || '3/2').split('/').map(Number);
  return (w && h) ? w / h : 1.5;
};
const isPortrait = (m) => arRatio(m.ar) < 1;

const gridSlotForIndex = (i, m) => {
  const slots = isPortrait(m) ? PORTRAIT_SLOTS : LANDSCAPE_SLOTS;
  return slots[i % slots.length];
};
// Cover slot depends on cover orientation. Wide-centered for landscape;
// portrait gets a centered narrow slot so it reads as a magazine hero.
const coverSlotFor = (m) => isPortrait(m) ? '5 / span 4' : '2 / span 8';

const SitPage = ({ slug, onClose }) => {
  const sit = window.SIT_BY_SLUG && window.SIT_BY_SLUG[slug];

  const cover = useSitMemo(() => {
    if (!sit) return null;
    return sit.media.find(m => m.feed) || sit.media[0] || null;
  }, [sit]);
  const coverIdx = useSitMemo(() => {
    if (!sit || !cover) return -1;
    return sit.media.indexOf(cover);
  }, [sit, cover]);

  const [phase, setPhase] = useSitState('slideup');
  const overlayRef = useSitRef(null);
  const coverLayerRef = useSitRef(null);
  const coverTileRef = useSitRef(null);
  // Pre-explore preview layer — inspired by aristide. Opens first with
  // stylized location + dates + review. User clicks "Explore" to dismiss
  // it and reveal the media gallery underneath. Per-sit theme-coloured.
  const [previewDismissed, setPreviewDismissed] = useSitState(false);
  // Lightbox: click any tile to expand its media fullscreen. null = closed;
  // number = media index within sit.media. ESC closes lightbox first (not
  // the whole sit overlay); ← → cycle through siblings.
  const [lightboxIdx, setLightboxIdx] = useSitState(null);

  // Slide-up + slide-down animation. Driven by adding/removing 'is-open'
  // class on the overlay. We add it on the next animation frame after
  // mount so the browser sees the initial translateY(100%) → animates to 0.
  // On 'closing', remove the class → translates back down → onClose fires
  // when the timeout completes.
  useSitLayoutEffect(() => {
    const el = overlayRef.current;
    if (!el) return;
    if (phase === 'closing') {
      el.classList.remove('is-open');
      return;
    }
    // Two rAFs: first commits initial styles, second flips the class.
    const r1 = requestAnimationFrame(() => {
      const r2 = requestAnimationFrame(() => {
        el.classList.add('is-open');
      });
      el.dataset.r2 = String(r2);
    });
    return () => {
      cancelAnimationFrame(r1);
      const r2 = parseInt(el.dataset.r2 || '0', 10);
      if (r2) cancelAnimationFrame(r2);
    };
  }, [phase === 'closing']);

  // Phase progression timers — single useEffect that switches on phase.
  // 'loading' is event-driven (onLoad/onCanPlay), not timer-driven.
  // While the preview is visible we pause at 'slideup' so the cover
  // reveal + immersive + squish animations run AFTER the user hits
  // Explore, not hidden behind the preview.
  useSitEffect(() => {
    let id = 0;
    if (phase === 'slideup')        id = setTimeout(() => setPhase('loading'), 580);
    else if (phase === 'reveal')    id = setTimeout(() => setPhase('immersive'), 800);
    else if (phase === 'immersive') id = setTimeout(() => setPhase('squish'), 1000);
    else if (phase === 'squish')    id = setTimeout(() => setPhase('done'), 580);
    else if (phase === 'closing')   id = setTimeout(() => onClose && onClose(), 580);
    // Hold at 'loading' until the preview is dismissed — we don't want
    // the cover to reveal/immersive/squish behind the preview layer.
    if (phase === 'loading' && !previewDismissed) id = 0;
    return () => { if (id) clearTimeout(id); };
  }, [phase, onClose, previewDismissed]);

  // (Failsafe reveal-timer removed — the Explore click now jumps straight
  // to 'done' via the next effect, so we never wait for cover decode.)

  const handleCoverReady = useSitCallback(() => {
    // No-op: the preview supplies the cover image directly, and clicking
    // Explore bypasses reveal/immersive/squish entirely.
  }, []);

  // When the user hits Explore: skip the reveal/immersive/squish cover
  // fullscreen re-display entirely and go straight to 'done' (gallery
  // shown directly). User already saw the cover through the preview's
  // scrim — re-displaying it fullscreen feels like a flicker.
  useSitEffect(() => {
    if (!previewDismissed || phase !== 'loading') return;
    const t = setTimeout(() => setPhase(p => p === 'loading' ? 'done' : p), 60);
    return () => clearTimeout(t);
  }, [previewDismissed, phase]);

  // Squish: when phase becomes 'squish', measure the cover tile's target
  // rect and animate the cover layer's dimensions directly to match.
  // Animating width/height/top/left (vs transform: scale) preserves the
  // image's aspect ratio throughout — `background-size: cover` and
  // `object-fit: cover` re-frame the image as the box reshapes.
  useSitLayoutEffect(() => {
    if (phase !== 'squish') return;
    const layer = coverLayerRef.current;
    const tile = coverTileRef.current;
    if (!layer || !tile) return;
    const tr = tile.getBoundingClientRect();
    layer.style.top = `${tr.top}px`;
    layer.style.left = `${tr.left}px`;
    layer.style.width = `${tr.width}px`;
    layer.style.height = `${tr.height}px`;
  }, [phase]);

  // Esc to close, and ← → to navigate inside the lightbox.
  // When the lightbox is open, Esc closes the lightbox first (not the
  // whole sit overlay). Re-registers whenever lightboxIdx changes so
  // the closure reads the latest index.
  useSitEffect(() => {
    const mediaCount = sit?.media?.length || 0;
    const onKey = (e) => {
      if (e.key === 'Escape') {
        e.preventDefault();
        if (lightboxIdx !== null) { setLightboxIdx(null); return; }
        setPhase(p => p === 'closing' ? p : 'closing');
        return;
      }
      if (lightboxIdx !== null && mediaCount > 1) {
        if (e.key === 'ArrowLeft') {
          e.preventDefault();
          setLightboxIdx((lightboxIdx + mediaCount - 1) % mediaCount);
        } else if (e.key === 'ArrowRight') {
          e.preventDefault();
          setLightboxIdx((lightboxIdx + 1) % mediaCount);
        }
      }
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [lightboxIdx, sit]);

  const triggerClose = useSitCallback(() => {
    setPhase(p => p === 'closing' ? p : 'closing');
  }, []);

  if (!sit) return null;

  const d1 = new Date(sit.start_date + 'T00:00:00');
  const d2 = new Date(sit.end_date + 'T00:00:00');
  const fmtDate = (d) => `${d.toLocaleString('en',{month:'short'}).toLowerCase()} ${d.getDate()}`;
  const dateRange = `${fmtDate(d1)} – ${fmtDate(d2)}, ${d2.getFullYear()}`;
  const review = window.REVIEW_BY_SLUG && window.REVIEW_BY_SLUG[sit.slug];

  // Reveal flag — flip on at the start of squish so other tiles begin
  // their fly-in just as the cover starts shrinking.
  const reveal = phase === 'squish' || phase === 'done';

  // Defer the heavy render (backdrop, cover, grid) until the slideup
  // animation has had a chance to start. While in 'slideup', only the
  // empty black overlay shell is rendered — slideup runs without React
  // render lag fighting it.
  const showContent = phase !== 'slideup';

  return (
    <div
      ref={overlayRef}
      className="sit-overlay"
      data-phase={phase}
      role="dialog"
      aria-modal="true"
      aria-label={`${sit.location} sit detail`}
    >
      <button
        type="button"
        className="sit-overlay__close"
        onClick={triggerClose}
        aria-label="close sit"
      >
        ×
      </button>

      {/* Aristide-style pre-explore hero. Opens with the slideup and
          covers everything below (backdrop + cover + grid are all
          already loading behind it). Dismisses on Explore click. */}
      {!previewDismissed && (
        <SitPreview
          sit={sit}
          dateRange={dateRange}
          review={review}
          ready={phase !== 'slideup'}
          onExplore={() => setPreviewDismissed(true)}
          onClose={triggerClose}
        />
      )}

      {showContent && (
        <>
          <SitBackdrop media={cover} />

          {cover && (
            <CoverLayer
              ref={coverLayerRef}
              media={cover}
              onReady={handleCoverReady}
            />
          )}

          <div className="sit-overlay__scroll">
            <SitGridHeader sit={sit} dateRange={dateRange} review={review} />

            <div className="sit-grid" data-entry={reveal ? 'reveal' : ''}>
              {sit.media.map((m, i) => {
                const isThisCover = i === coverIdx;
                const slot = isThisCover ? coverSlotFor(m) : gridSlotForIndex(i, m);
                // Eager-load: cover + first 3 above-fold tiles. Rest lazy.
                const eager = isThisCover || i < 4;
                return (
                  <SitGridTile
                    key={i}
                    ref={isThisCover ? coverTileRef : null}
                    sit={sit}
                    media={m}
                    isCover={isThisCover}
                    forceDeveloped={isThisCover && (phase === 'squish' || phase === 'done')}
                    slot={slot}
                    indexForStagger={i}
                    eager={eager}
                    onExpand={() => setLightboxIdx(i)}
                  />
                );
              })}
            </div>
          </div>
          {lightboxIdx !== null && sit.media[lightboxIdx] && (
            <SitLightbox
              media={sit.media[lightboxIdx]}
              label={mediaFileLabel(sit.media[lightboxIdx])}
              onClose={() => setLightboxIdx(null)}
              onPrev={sit.media.length > 1 ? () => setLightboxIdx((lightboxIdx + sit.media.length - 1) % sit.media.length) : null}
              onNext={sit.media.length > 1 ? () => setLightboxIdx((lightboxIdx + 1) % sit.media.length) : null}
            />
          )}
        </>
      )}
    </div>
  );
};

// ---- Cover layer ---------------------------------------------------
// Full-bleed div carrying the cover image/video. Starts at opacity 0
// while loading, fades in once media reports ready (onLoad/onCanPlay).
// Inline `transform` is applied by JS during 'squish' to animate to the
// cover tile's target rect.
const CoverLayer = React.forwardRef(({ media, onReady }, ref) => {
  const isVideo = media.type === 'video' && media.src;
  const plate = media.plate || 'linear-gradient(180deg, #2e2b25, #0c0b09)';
  const isGradient = plate.startsWith('linear-') || plate.startsWith('radial-');
  const hasImage = media.src && media.type !== 'video' && !media.src.startsWith('linear-');
  const bg = hasImage ? `url(${media.src})` : (isGradient ? plate : `url(${plate})`);

  // For images, preload and call onReady on completion. For videos,
  // onCanPlay does the same. For plate-only media (no src), call onReady
  // on the next frame.
  useSitEffect(() => {
    if (!onReady) return;
    if (hasImage) {
      const img = new Image();
      const done = () => onReady();
      img.onload = done;
      img.onerror = done; // still proceed on error so we don't get stuck
      img.src = media.src;
      // If the image is already cached, the load may have fired before
      // we attached the handler.
      if (img.complete) done();
    } else if (!isVideo) {
      const r = requestAnimationFrame(() => onReady());
      return () => cancelAnimationFrame(r);
    }
    // Videos signal via the onCanPlay handler on the <video> element below.
  }, [hasImage, isVideo, media.src, onReady]);

  return (
    <div ref={ref} className="sit-overlay__cover" style={{ backgroundImage: bg }}>
      {isVideo && (
        <video
          className="sit-overlay__cover-video"
          src={media.src}
          muted loop playsInline autoPlay preload="auto"
          disablePictureInPicture disableRemotePlayback controls={false}
          onCanPlay={() => onReady && onReady()}
          onLoadedData={() => onReady && onReady()}
        />
      )}
    </div>
  );
});

// ---- Header --------------------------------------------------------
const SitGridHeader = ({ sit, dateRange, review }) => (
  <header className="sit-grid__header">
    <span className="sit-grid__kicker">{sit.location_short}</span>
    <h1 className="sit-grid__title">{sit.location}</h1>
    <p className="sit-grid__meta">
      {dateRange}
      <span className="sep">·</span>
      {sit.dog}
    </p>
    {sit.blurb && <p className="sit-grid__blurb">{sit.blurb}</p>}
    {review && <ReviewQuote review={review} variant="dark" />}
  </header>
);

// ---- Tile ----------------------------------------------------------
// One media item in the floating grid. Aspect ratio comes from
// media.ar (declared) but is overridden by the actual image dimensions
// once it loads — sits.js has stale `ar: '3/2'` defaults for many
// portrait photos. After load we also re-slot so portrait images land
// in portrait grid slots instead of being cropped into landscape ones.
// Build the media reference label shown under each tile on the sit page.
// For photos the web-delivered file is a .jpg, but the print source is
// the .arw raw — we surface the raw extension so the label doubles as
// the canonical print-file reference. Videos show their actual .mp4.
// Format: `DMA02817.ARW` or `CIMEWEBSITE1080.MP4`.
// Returns null if we can't derive a sensible reference (plates, gradients).
const mediaFileLabel = (media) => {
  if (!media || !media.src) return null;
  if (media.src.startsWith('linear-') || media.src.startsWith('radial-')) return null;
  const noQuery = media.src.split('?')[0];
  const file = noQuery.split('/').pop() || '';
  const stem = file.replace(/\.[^.]+$/, '');
  if (!/[A-Z0-9]/i.test(stem)) return null;
  // Strip the production-only "website" substring from video filenames
  // so `cimewebsite1080.2` → `cime1080.2`. Catalog names should read
  // like source files, not delivery artifacts.
  const cleanStem = stem.replace(/website/gi, '').replace(/[-_.]+$/, '');
  const ext = media.type === 'photo' ? 'arw'
            : media.type === 'video' ? (file.split('.').pop() || 'mp4')
            : null;
  return ext ? `${cleanStem}.${ext}`.toUpperCase() : cleanStem.toUpperCase();
};

const SitGridTile = React.forwardRef(({ sit, media, isCover, slot, indexForStagger, eager, forceDeveloped, onExpand }, fwdRef) => {
  const innerRef = useSitRef(null);
  const videoRef = useSitRef(null);
  const developedRef = useSitRef(false);
  const [videoReady, setVideoReady] = useSitState(false);
  // null = use declared media.ar; { w, h } once the image/video reports natural dims.
  const [actualDims, setActualDims] = useSitState(null);

  const setRef = useSitCallback((el) => {
    innerRef.current = el;
    if (typeof fwdRef === 'function') fwdRef(el);
    else if (fwdRef) fwdRef.current = el;
  }, [fwdRef]);

  const onMediaDims = useSitCallback((w, h) => {
    if (w && h) setActualDims({ w, h });
  }, []);

  // Per-tile IO: (1) play/pause video on scroll, (2) "film developing"
  // entry effect — fires once when the tile first crosses ≥50% in view
  // (per user spec: should not happen until at least half of the media
  // is in view). The reveal is a center-out clip-path animation handled
  // in CSS; this just toggles data-developed.
  useSitEffect(() => {
    const root = innerRef.current;
    if (!root) return;
    const v = videoRef.current;
    const io = new IntersectionObserver(
      (entries) => {
        for (const e of entries) {
          if (!developedRef.current && e.isIntersecting && e.intersectionRatio >= 0.5) {
            developedRef.current = true;
            root.setAttribute('data-developed', 'true');
          }
          // Video play/pause keys off the same threshold.
          if (v) {
            if (e.isIntersecting && e.intersectionRatio >= 0.5) {
              v.play().catch(() => {});
            } else {
              try { v.pause(); } catch {}
            }
          }
        }
      },
      { threshold: [0, 0.5, 1] }
    );
    io.observe(root);
    return () => io.disconnect();
  }, []);

  // Cover tile is "force-developed" by SitPage at squish/done phase
  // (it lands in view via the squish, not by user scrolling).
  useSitEffect(() => {
    if (!forceDeveloped || developedRef.current) return;
    const root = innerRef.current;
    if (!root) return;
    developedRef.current = true;
    root.setAttribute('data-developed', 'true');
  }, [forceDeveloped]);

  const declaredAr = media.ar || '3/2';
  // Effective aspect = actual image dims if we've measured them, else declared.
  const effectiveAr = actualDims ? `${actualDims.w}/${actualDims.h}` : declaredAr;
  // If actual orientation differs from declared, re-slot so portraits get
  // narrower portrait slots instead of wide landscape ones.
  let effectiveSlot = slot;
  if (actualDims) {
    const actualPortrait = actualDims.w / actualDims.h < 1;
    const declaredPortrait = isPortrait(media);
    if (actualPortrait !== declaredPortrait) {
      effectiveSlot = isCover
        ? coverSlotFor({ ar: effectiveAr })
        : (actualPortrait
            ? PORTRAIT_SLOTS[indexForStagger % PORTRAIT_SLOTS.length]
            : LANDSCAPE_SLOTS[indexForStagger % LANDSCAPE_SLOTS.length]);
    }
  }
  const isVideo = media.type === 'video' && media.src;
  const plate = media.plate || 'linear-gradient(180deg, #2e2b25, #0c0b09)';
  const isGradient = plate.startsWith('linear-') || plate.startsWith('radial-');
  const hasImage = media.src && media.type !== 'video' && !media.src.startsWith('linear-');
  const bg = hasImage ? `url(${media.src})` : (isGradient ? plate : `url(${plate})`);

  const cls = `sit-grid__tile ${isCover ? 'sit-grid__tile--cover' : 'sit-grid__tile--regular'}`;
  const style = {
    '--ar': effectiveAr,
    '--i': indexForStagger,
    gridColumn: effectiveSlot,
  };

  const handleTileClick = (e) => {
    // DevArchive buttons live as siblings inside the tile; they call
    // stopPropagation on their own clicks. Any click that reaches the
    // tile itself should open the lightbox — as long as we have a real
    // media source to show (skip gradient-only plates).
    if (!onExpand) return;
    if (!media || !media.src || media.src.startsWith('linear-')) return;
    onExpand();
  };

  return (
    <div
      ref={setRef}
      className={cls}
      style={style}
      onClick={handleTileClick}
      role={onExpand ? 'button' : undefined}
      tabIndex={onExpand ? 0 : undefined}
      onKeyDown={(e) => {
        if (!onExpand) return;
        if (e.key === 'Enter' || e.key === ' ') {
          e.preventDefault();
          handleTileClick(e);
        }
      }}
      aria-label={onExpand ? `Expand media ${mediaFileLabel(media) || ''}`.trim() : undefined}
    >
      <div className="sit-grid__tile-media">
        {isVideo ? (
          <>
            {/* Plate (poster) underneath; video fades in over it once ready. */}
            <div className="sit-grid__tile-plate" style={{ backgroundImage: bg }} />
            <video
              ref={videoRef}
              src={media.src}
              muted playsInline
              preload={eager ? 'auto' : 'metadata'}
              disablePictureInPicture disableRemotePlayback controls={false}
              data-ready={videoReady ? 'true' : 'false'}
              onCanPlay={() => setVideoReady(true)}
              onLoadedData={() => setVideoReady(true)}
              onLoadedMetadata={(e) => onMediaDims(e.currentTarget.videoWidth, e.currentTarget.videoHeight)}
            />
          </>
        ) : hasImage ? (
          <img
            src={media.src}
            alt=""
            loading={eager ? 'eager' : 'lazy'}
            decoding="async"
            onLoad={(e) => onMediaDims(e.currentTarget.naturalWidth, e.currentTarget.naturalHeight)}
          />
        ) : (
          <div className="sit-grid__tile-plate" style={{ backgroundImage: bg }} />
        )}
      </div>
      {window.DevArchiveButton && React.createElement(window.DevArchiveButton, { sit, media })}
      {(() => {
        const label = mediaFileLabel(media);
        return label ? <span className="sit-grid__tile-code" aria-hidden="true">{label}</span> : null;
      })()}
    </div>
  );
});

// ---- Backdrop (kept from old SitPage) ------------------------------
// Fixed, heavily blurred copy of the cover. No crossfade — locked to
// cover for the lifetime of the overlay.
const SitBackdrop = ({ media }) => {
  if (!media) return null;
  return (
    <div className="sit-backdrop">
      <BackdropLayer media={media} opacity={1} />
      <div className="sit-backdrop__veil" />
    </div>
  );
};

const BackdropLayer = ({ media, opacity }) => {
  const isVideo = media.type === 'video' && media.src;
  const plate = media.plate || 'linear-gradient(180deg, #2e2b25, #0c0b09)';
  const isGradient = plate.startsWith('linear-') || plate.startsWith('radial-');
  const hasImage = media.src && media.type !== 'video' && !media.src.startsWith('linear-');
  const bg = hasImage ? `url(${media.src})` : (isGradient ? plate : `url(${plate})`);

  return (
    <div className="sit-backdrop__layer" style={{ opacity }}>
      <div className="sit-backdrop__img" style={{ backgroundImage: bg }} />
      {isVideo && (
        <video className="sit-backdrop__video" src={media.src} muted loop playsInline autoPlay preload="auto" />
      )}
    </div>
  );
};

// ---- Lightbox ------------------------------------------------------
// Fullscreen media viewer layered above the SitPage grid. Click backdrop
// or X to close; click the media itself is absorbed (stopPropagation);
// ← → cycle siblings (handled in SitPage's key handler). The label
// carries through in the bottom-right corner so the print reference
// survives the expand.
const SitLightbox = ({ media, label, onClose, onPrev, onNext }) => {
  const isVideo = media.type === 'video' && media.src;
  const hasImage = media.src && !isVideo && !media.src.startsWith('linear-');

  // Fade-in on mount (two-rAF pattern matches the overlay's slideup).
  const rootRef = useSitRef(null);
  useSitLayoutEffect(() => {
    const el = rootRef.current;
    if (!el) return;
    const r1 = requestAnimationFrame(() => {
      const r2 = requestAnimationFrame(() => el.classList.add('is-open'));
      el.dataset.r2 = String(r2);
    });
    return () => {
      cancelAnimationFrame(r1);
      if (el.dataset.r2) cancelAnimationFrame(Number(el.dataset.r2));
    };
  }, []);

  return (
    <div
      ref={rootRef}
      className="sit-lightbox"
      role="dialog"
      aria-modal="true"
      aria-label={label ? `Expanded ${label}` : 'Expanded media'}
      onClick={onClose}
    >
      <button
        type="button"
        className="sit-lightbox__close"
        onClick={(e) => { e.stopPropagation(); onClose(); }}
        aria-label="Close expanded view"
      >×</button>
      {onPrev && (
        <button
          type="button"
          className="sit-lightbox__nav sit-lightbox__nav--prev"
          onClick={(e) => { e.stopPropagation(); onPrev(); }}
          aria-label="Previous media"
        >‹</button>
      )}
      {onNext && (
        <button
          type="button"
          className="sit-lightbox__nav sit-lightbox__nav--next"
          onClick={(e) => { e.stopPropagation(); onNext(); }}
          aria-label="Next media"
        >›</button>
      )}
      <figure className="sit-lightbox__figure" onClick={(e) => e.stopPropagation()}>
        {isVideo ? (
          <video
            src={media.src}
            autoPlay muted loop playsInline controls
            className="sit-lightbox__media"
          />
        ) : hasImage ? (
          <img src={media.src} alt="" className="sit-lightbox__media" />
        ) : null}
        {label && <figcaption className="sit-lightbox__code">{label}</figcaption>}
      </figure>
    </div>
  );
};

// ---- SitPreview ----------------------------------------------------
// Aristide-style quick-look layer. Opens on top of the sit overlay and
// shows a stylized preview: location in massive display type, date
// range + review + "Explore" link below. Each sit has its own theme
// colour derived from the cover's plate gradient so the overlay feels
// bespoke per project.
//
// Structure mirrors aristide's detail-open:
//   Top center   — small "SIT" label (like their "PROJECTS")
//   Middle band  — LOCATION in two giant lines, letters stagger-fade-in
//   Bottom band  — dates on the left, "Explore ↗" centered, review on
//                  the right; all small mono-caps.
//
// Dismiss by clicking Explore (reveals the gallery below) or by
// pressing the overlay × to close the sit entirely.
const SitPreview = ({ sit, dateRange, review, ready, onExplore, onClose }) => {
  // Theme bg — pick the first hex from the sit's plate gradient, falling
  // back to a dark neutral. Dylan can override per-sit later by adding
  // a `theme.bg` field on the sit record.
  const cover = sit.media.find(m => m.feed) || sit.media[0] || {};
  const plateStr = String(sit.theme?.bg || cover.plate || 'linear-gradient(180deg, #2e2b25, #0c0b09)');
  const hexes = plateStr.match(/#[0-9a-f]{3,8}/gi) || ['#1a1714'];
  const bg = sit.theme?.bg && sit.theme.bg.startsWith('#') ? sit.theme.bg : (hexes[0] || '#1a1714');
  const txt = sit.theme?.txt || '#fafaf7';

  // Split the location into two rows for the stylized two-line display
  // (e.g. "BOULDER, COLORADO" → ["BOULDER,", "COLORADO"]). City only if
  // there's a single word.
  const loc = (sit.location || 'Boulder, Colorado').toUpperCase();
  const parts = loc.split(/,\s*/).filter(Boolean);
  const line1 = parts[0] || loc;
  const line2 = parts.slice(1).join(' ') || '';

  // Stagger each letter's fade-in per aristide's 25ms-per-letter pattern.
  const renderLine = (text, lineIdx) => (
    <span className="sit-detail-preview__line">
      {Array.from(text).map((ch, i) => (
        <span
          key={i}
          className="sit-detail-preview__letter"
          style={{ '--letter-delay': `${(lineIdx * text.length + i) * 22}ms` }}
        >
          {ch === ' ' ? ' ' : ch}
        </span>
      ))}
    </span>
  );

  // Cover media rendered behind the text. Photo → <img>, video → <video>.
  const coverSrc = cover.src && !cover.src.startsWith('linear-') ? cover.src : null;
  const isVideoCover = cover.type === 'video' && coverSrc;

  // Info-left blocks — label + value pairs. Empty values are filtered
  // out so the block layout doesn't sprout holes. (A/B/C/D letter-keys
  // were removed per design refinement; React `key` field retained.)
  const days = Math.max(1, Math.round(
    (new Date(sit.end_date + 'T00:00:00') - new Date(sit.start_date + 'T00:00:00')) / 86400000
  ));
  const infoBlocks = [
    { key: 'A', label: 'Dates',    value: dateRange },
    { key: 'B', label: 'Duration', value: `${days} day${days === 1 ? '' : 's'}` },
    { key: 'C', label: 'Pet',      value: sit.dog ? String(sit.dog) : null },
    { key: 'D', label: 'Where',    value: sit.location_short || sit.location },
  ].filter(b => b.value);

  // Counter: this sit's position within the media-having sits (like
  // aristide's "100" counter). Simple 3-digit pad.
  const allMediaSits = (window.SITS || []).filter(s =>
    s.status !== 'available' && (s.media || []).some(m => m && m.src && !m.src.startsWith('linear-'))
  ).sort((a, b) => a.start_date < b.start_date ? 1 : -1);
  const idx = Math.max(0, allMediaSits.findIndex(s => s.slug === sit.slug));
  const counter = String(idx + 1).padStart(3, '0');
  const counterTotal = String(allMediaSits.length).padStart(3, '0');

  return (
    <div
      className={`sit-detail-preview${ready ? ' is-ready' : ''}`}
      style={{ '--preview-bg': bg, '--preview-txt': txt }}
      role="region"
      aria-label={`${sit.location} preview`}
    >
      <div className="sit-detail-preview__cover" aria-hidden="true">
        {isVideoCover && (
          <video src={coverSrc} autoPlay muted loop playsInline
            disablePictureInPicture disableRemotePlayback />
        )}
        {!isVideoCover && coverSrc && (
          <img src={coverSrc} alt="" decoding="async" />
        )}
      </div>
      <div className="sit-detail-preview__scrim" aria-hidden="true" />

      {/* Top chrome — aristide triad: counter left, back-link center */}
      <div className="sit-detail-preview__chrome">
        <span className="sit-detail-preview__counter">{counter}</span>
        <button
          type="button"
          className="sit-detail-preview__back mono-caps"
          onClick={onClose}
          aria-label="close sit"
        >
          Sits
          <span className="sit-detail-preview__back-total">{counterTotal}</span>
        </button>
      </div>

      <div className="sit-detail-preview__hero">
        {renderLine(line1, 0)}
        {line2 && renderLine(line2, 1)}
      </div>

      {/* Bottom band: info-left (A/B/C/D blocks), vertical EXPLORE
          centered, info-right (review blurb) on the right. */}
      <div className="sit-detail-preview__foot">
        <dl className="sit-detail-preview__info mono-caps">
          {infoBlocks.map(b => (
            <div key={b.key} className="sit-detail-preview__info-block">
              <dt className="sit-detail-preview__info-label">{b.label}</dt>
              <dd className="sit-detail-preview__info-value">{b.value}</dd>
            </div>
          ))}
        </dl>

        <button
          type="button"
          className="sit-detail-preview__explore"
          onClick={onExplore}
          aria-label="explore media"
        >
          <span className="mono-caps">Explore</span>
          <span className="sit-detail-preview__explore-arrow" aria-hidden>↗</span>
        </button>

        <div className="sit-detail-preview__review mono-caps">
          {review?.quote ? (
            <>
              <span className="sit-detail-preview__quote">&ldquo;{review.quote.slice(0, 180)}{review.quote.length > 180 ? '…' : ''}&rdquo;</span>
              {review.reviewer_first && (
                <span className="sit-detail-preview__attrib">
                  — {review.reviewer_first}
                  {review.reviewer_city ? `, ${review.reviewer_city}` : ''}
                </span>
              )}
            </>
          ) : null}
        </div>
      </div>
    </div>
  );
};

window.SitPage = SitPage;
