// Aperture — gallery + lightbox. Fetches photos from /api/photos.

const { useState, useEffect, useLayoutEffect, useRef, useMemo, useCallback } = React;

function useIsMobile() {
  const query = '(max-width: 760px), (pointer: coarse)';
  const [mobile, setMobile] = useState(() =>
    typeof window !== 'undefined' && window.matchMedia(query).matches
  );
  useEffect(() => {
    const mq = window.matchMedia(query);
    const handler = (e) => setMobile(e.matches);
    mq.addEventListener('change', handler);
    return () => mq.removeEventListener('change', handler);
  }, []);
  return mobile;
}

const HOME_COUNT_MIN = 20;
const HOME_COUNT_MAX = 24;
const HOME_CACHE_KEY = 'ap-home-shuffle-v3';

function fisherYates(arr) {
  const a = [...arr];
  for (let i = a.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [a[i], a[j]] = [a[j], a[i]];
  }
  return a;
}

// Round-robin items across groups so two photos with the same group key
// never sit next to each other (unless one group is more than half of items).
function spreadByGroup(items, keyFn) {
  const groups = new Map();
  for (const it of items) {
    const k = keyFn(it) || `__solo_${it.id}`;
    if (!groups.has(k)) groups.set(k, []);
    groups.get(k).push(it);
  }
  const buckets = [...groups.values()].sort((a, b) => b.length - a.length);
  const out = [];
  while (buckets.some((g) => g.length > 0)) {
    for (const g of buckets) if (g.length > 0) out.push(g.shift());
  }
  return out;
}

function pickStableCollectionOrder(name, photos) {
  if (!Array.isArray(photos) || photos.length === 0) return photos || [];
  const key = `ap-coll-shuffle-${name}-v1`;
  const fingerprint = [...photos.map((p) => p.id)].sort().join(',');
  let cached = null;
  try { cached = JSON.parse(sessionStorage.getItem(key) || 'null'); } catch {}
  if (cached && cached.fingerprint === fingerprint && Array.isArray(cached.order) && cached.order.length === photos.length) {
    const byId = new Map(photos.map((p) => [p.id, p]));
    const picked = cached.order.map((id) => byId.get(id)).filter(Boolean);
    if (picked.length === photos.length) return picked;
  }
  const shuffled = fisherYates(photos);
  try {
    sessionStorage.setItem(key, JSON.stringify({
      fingerprint,
      order: shuffled.map((p) => p.id),
    }));
  } catch {}
  return shuffled;
}

function pickStableHomeShuffle(photos, minN, maxN) {
  if (!Array.isArray(photos) || photos.length === 0) return [];
  const fingerprint = [...photos.map((p) => p.id)].sort().join(',');
  const lo = Math.min(minN, photos.length);
  const hi = Math.min(maxN, photos.length);
  let cached = null;
  try { cached = JSON.parse(sessionStorage.getItem(HOME_CACHE_KEY) || 'null'); } catch {}
  if (
    cached
    && cached.fingerprint === fingerprint
    && Array.isArray(cached.order)
    && cached.order.length >= lo
    && cached.order.length <= hi
  ) {
    const byId = new Map(photos.map((p) => [p.id, p]));
    const picked = cached.order.map((id) => byId.get(id)).filter(Boolean);
    if (picked.length === cached.order.length) return picked;
  }
  // Pick a fresh random count in [lo, hi] inclusive
  const want = lo + Math.floor(Math.random() * (hi - lo + 1));
  const subset = fisherYates(photos).slice(0, want);
  // Spread by collection, falling back to date — keeps visually similar
  // photos (same shoot / same album) from sitting next to each other.
  const picked = spreadByGroup(subset, (p) => p.collection || p.date || '');
  try {
    sessionStorage.setItem(HOME_CACHE_KEY, JSON.stringify({
      fingerprint,
      order: picked.map((p) => p.id),
    }));
  } catch {}
  return picked;
}

function parseHashRoute() {
  const h = (typeof window !== 'undefined' ? window.location.hash : '').replace(/^#\/?/, '');
  if (!h) return { view: 'all' };
  const parts = h.split('/').filter(Boolean);
  if (parts[0] === 'collections') {
    if (parts.length === 1) return { view: 'collections' };
    return { view: 'collection', name: decodeURIComponent(parts[1]).toLowerCase() };
  }
  return { view: 'all' };
}

function useHashRoute() {
  const [route, setRoute] = useState(parseHashRoute);
  useEffect(() => {
    const onChange = () => setRoute(parseHashRoute());
    window.addEventListener('hashchange', onChange);
    return () => window.removeEventListener('hashchange', onChange);
  }, []);
  return route;
}

function useScrollDirection() {
  const [hidden, setHidden] = useState(false);
  useEffect(() => {
    let lastY = window.scrollY || 0;
    const onScroll = () => {
      const y = window.scrollY || 0;
      const dy = y - lastY;
      if (Math.abs(dy) < 4) return;
      if (y < 80) setHidden(false);
      else if (dy > 0) setHidden(true);
      else setHidden(false);
      lastY = y;
    };
    window.addEventListener('scroll', onScroll, { passive: true });
    return () => window.removeEventListener('scroll', onScroll);
  }, []);
  return hidden;
}

function Wordmark({ href = '#/' }) {
  return (
    <a className="ap-wordmark" href={href}>
      <span className="ap-wordmark-text">aperture</span>
      <svg className="ap-wordmark-iris" viewBox="-5 -5 10 10" width="18" height="18" aria-hidden>
        <circle cx="0" cy="0" r="3.6" fill="none" stroke="currentColor" strokeWidth="0.6" />
        {[0, 60, 120, 180, 240, 300].map((a) => (
          <line key={a} x1="3" y1="0" x2="1" y2="0" stroke="currentColor" strokeWidth="0.6" strokeLinecap="round" transform={`rotate(${a})`} />
        ))}
        <circle cx="0" cy="0" r="0.7" fill="currentColor" />
      </svg>
    </a>
  );
}

function Masonry({ photos, cols, gutter, onOpen, tailHref, tailLabel }) {
  const { columns, tailColumn, tailAspect } = useMemo(() => {
    const heights = Array(cols).fill(0);
    const buckets = Array.from({ length: cols }, () => []);
    for (const p of photos) {
      let shortest = 0;
      for (let i = 1; i < cols; i++) if (heights[i] < heights[shortest]) shortest = i;
      buckets[shortest].push(p);
      heights[shortest] += (p.h || 1) / (p.w || 1);
    }
    let shortIdx = 0;
    for (let i = 1; i < cols; i++) if (heights[i] < heights[shortIdx]) shortIdx = i;
    const maxH = Math.max(...heights);
    // Aspect ratio (height / width) sized to fill the gap in the shortest
    // column. Clamp so the CTA tile never feels squashed or absurdly tall.
    const rawAspect = Math.max(0, maxH - heights[shortIdx]);
    const tailAspect = Math.min(2.4, Math.max(1.05, rawAspect || 1.2));
    return { columns: buckets, tailColumn: shortIdx, tailAspect };
  }, [photos, cols]);

  return (
    <div className="ap-masonry" style={{ gap: gutter }}>
      {columns.map((col, i) => (
        <div key={i} className="ap-mcol" style={{ gap: gutter }}>
          {col.map((p, idx) => (
            <Tile key={p.id} photo={p} onOpen={onOpen} delay={idx * 40} />
          ))}
          {tailHref && i === tailColumn && (
            <ArchiveBreakTile href={tailHref} label={tailLabel || 'explore the archive'} aspect={tailAspect} />
          )}
        </div>
      ))}
    </div>
  );
}

function ArchiveBreakTile({ href, label, aspect }) {
  const ref = useRef(null);
  const [visible, setVisible] = useState(false);
  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const io = new IntersectionObserver((entries) => {
      for (const e of entries) if (e.isIntersecting) { setVisible(true); io.disconnect(); break; }
    }, { rootMargin: '120% 0px' });
    io.observe(el);
    return () => io.disconnect();
  }, []);
  return (
    <a
      ref={ref}
      className={`ap-archive-tile${visible ? ' is-in' : ''}`}
      href={href}
      style={{ '--tail-aspect': aspect }}
      aria-label={label}
    >
      <span className="ap-archive-tile-rule" aria-hidden />
      <span className="ap-archive-tile-text">
        <span className="ap-archive-tile-label">{label}</span>
        <span className="ap-archive-tile-arrow" aria-hidden>→</span>
      </span>
      <span className="ap-archive-tile-rule" aria-hidden />
    </a>
  );
}

function Tile({ photo, onOpen, delay = 0 }) {
  const ref = useRef(null);
  const [visible, setVisible] = useState(false);
  const [inView, setInView] = useState(false);

  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const io = new IntersectionObserver((entries) => {
      for (const e of entries) {
        if (e.isIntersecting) { setInView(true); io.disconnect(); break; }
      }
    }, { rootMargin: '400px 0px' });
    io.observe(el);
    return () => io.disconnect();
  }, []);

  return (
    <button
      ref={ref}
      className="ap-tile"
      onClick={() => onOpen(photo, ref.current?.getBoundingClientRect())}
      aria-label={`Open ${photo.title}`}
      data-loaded={visible}
      data-photo-id={photo.id}
      style={{
        aspectRatio: `${photo.w || 3} / ${photo.h || 2}`,
        transitionDelay: visible ? '0ms' : `${delay}ms`,
      }}
    >
      {inView && (
        <img
          src={photo.thumb || photo.src}
          alt=""
          loading="lazy"
          onLoad={() => setTimeout(() => setVisible(true), delay)}
          draggable={false}
        />
      )}
    </button>
  );
}

function Placard({ photo }) {
  const rows = [
    ['Camera', photo.camera],
    ['Lens', photo.lens],
    ['Focal', photo.focal],
    ['Aperture', photo.fstop],
    ['Shutter', photo.shutter],
    ['ISO', (photo.iso || '').replace('ISO ', '')],
  ].filter(([, v]) => v);
  return (
    <div className="ap-placard">
      <div className="ap-placard-head">
        <div className="ap-placard-title">{photo.title}</div>
      </div>
      <dl className="ap-placard-grid">
        {rows.map(([k, v]) => (
          <div key={k} className="ap-placard-row">
            <dt>{k}</dt>
            <dd className="ap-mono">{v}</dd>
          </div>
        ))}
      </dl>
    </div>
  );
}

function sampleDominantColor(src, cb) {
  const img = new Image();
  img.crossOrigin = 'anonymous';
  img.onload = () => {
    try {
      const canvas = document.createElement('canvas');
      canvas.width = canvas.height = 8;
      const ctx = canvas.getContext('2d');
      if (!ctx) return;
      ctx.drawImage(img, 0, 0, 8, 8);
      const data = ctx.getImageData(0, 0, 8, 8).data;
      let r = 0, g = 0, b = 0, n = 0;
      for (let i = 0; i < data.length; i += 4) { r += data[i]; g += data[i + 1]; b += data[i + 2]; n++; }
      cb(`rgb(${Math.round(r / n)}, ${Math.round(g / n)}, ${Math.round(b / n)})`);
    } catch {}
  };
  img.src = src;
}

function Lightbox({ photo, photos, originRect, onClose, onNav }) {
  const figRef = useRef(null);
  const [closing, setClosing] = useState(false);
  const [navDir, setNavDir] = useState(0);
  const [glow, setGlow] = useState(null);
  const idx = photos.findIndex((p) => p.id === photo.id);

  // FLIP entry from the clicked tile (only on initial mount)
  useLayoutEffect(() => {
    const fig = figRef.current;
    if (!fig || !originRect) return;
    fig.style.animation = 'none';
    const last = fig.getBoundingClientRect();
    const dx = originRect.left + originRect.width / 2 - (last.left + last.width / 2);
    const dy = originRect.top + originRect.height / 2 - (last.top + last.height / 2);
    const s = Math.min(originRect.width / last.width, originRect.height / last.height);
    fig.style.transformOrigin = 'center';
    fig.style.transition = 'none';
    fig.style.transform = `translate(${dx}px, ${dy}px) scale(${s})`;
    fig.style.opacity = '0.5';
    void fig.offsetWidth;
    fig.style.transition = 'transform 480ms cubic-bezier(0.2, 0.7, 0.2, 1), opacity 480ms ease-out';
    fig.style.transform = 'translate(0, 0) scale(1)';
    fig.style.opacity = '1';
  }, []);

  // Sample dominant color → edge glow
  useEffect(() => {
    sampleDominantColor(photo.thumb || photo.src, setGlow);
  }, [photo.id]);

  const requestClose = useCallback(() => {
    if (closing) return;
    setClosing(true);
    const fig = figRef.current;
    const tile = document.querySelector(`.ap-tile[data-photo-id="${photo.id}"]`);
    const rect = tile?.getBoundingClientRect();
    if (fig && rect) {
      const last = fig.getBoundingClientRect();
      const dx = rect.left + rect.width / 2 - (last.left + last.width / 2);
      const dy = rect.top + rect.height / 2 - (last.top + last.height / 2);
      const s = Math.min(rect.width / last.width, rect.height / last.height);
      fig.style.transition = 'transform 320ms cubic-bezier(0.4, 0.0, 0.2, 1), opacity 320ms ease-in';
      fig.style.transform = `translate(${dx}px, ${dy}px) scale(${s})`;
      fig.style.opacity = '0.4';
    } else if (fig) {
      fig.style.transition = 'transform 240ms ease-in, opacity 240ms ease-in';
      fig.style.transform = 'scale(0.96)';
      fig.style.opacity = '0';
    }
    setTimeout(onClose, 320);
  }, [closing, onClose, photo.id]);

  const navWithDir = useCallback((d) => {
    setNavDir(d);
    onNav(d);
  }, [onNav]);

  useEffect(() => {
    const onKey = (e) => {
      if (e.key === 'Escape') requestClose();
      else if (e.key === 'ArrowRight') navWithDir(1);
      else if (e.key === 'ArrowLeft') navWithDir(-1);
    };
    window.addEventListener('keydown', onKey);
    const prev = document.body.style.overflow;
    document.body.style.overflow = 'hidden';
    return () => {
      window.removeEventListener('keydown', onKey);
      document.body.style.overflow = prev;
    };
  }, [requestClose, navWithDir]);

  const figClass =
    'ap-lb-figure'
    + (navDir > 0 ? ' is-from-right' : navDir < 0 ? ' is-from-left' : '');

  return (
    <div
      className={'ap-lb' + (closing ? ' is-closing' : '')}
      role="dialog"
      aria-modal="true"
      style={{ '--glow': glow || 'rgba(255,255,255,0.04)' }}
    >
      <div className="ap-lb-scrim" onClick={requestClose} />

      <div className="ap-lb-top">
        <div className="ap-lb-counter">
          <span className="ap-mono">{String(idx + 1).padStart(3, '0')}</span>
          <span className="ap-lb-sep">/</span>
          <span className="ap-mono ap-muted">{String(photos.length).padStart(3, '0')}</span>
        </div>
        <button className="ap-lb-close" onClick={requestClose} aria-label="Close">
          <svg width="14" height="14" viewBox="0 0 14 14"><path d="M1 1l12 12M13 1L1 13" stroke="currentColor" strokeWidth="1" fill="none" /></svg>
          <span className="ap-kbd">Esc</span>
        </button>
      </div>

      <div className="ap-lb-stage">
        <button className="ap-lb-nav ap-lb-prev" onClick={() => navWithDir(-1)} aria-label="Previous">
          <svg width="18" height="18" viewBox="0 0 18 18"><path d="M11 2L4 9l7 7" stroke="currentColor" strokeWidth="1" fill="none" strokeLinecap="square" /></svg>
        </button>

        <div className="ap-lb-framewrap">
          <div className="ap-lb-glow" aria-hidden />
          <figure className={figClass} key={photo.id} ref={figRef}>
            <img src={photo.src} alt={photo.title} draggable={false} />
          </figure>
          <aside className="ap-lb-side" key={`side-${photo.id}`}>
            <Placard photo={photo} />
          </aside>
        </div>

        <button className="ap-lb-nav ap-lb-next" onClick={() => navWithDir(1)} aria-label="Next">
          <svg width="18" height="18" viewBox="0 0 18 18"><path d="M7 2l7 7-7 7" stroke="currentColor" strokeWidth="1" fill="none" strokeLinecap="square" /></svg>
        </button>
      </div>

      <div className="ap-lb-hints">
        <span><span className="ap-kbd">←</span> <span className="ap-kbd">→</span> navigate</span>
        <span><span className="ap-kbd">Esc</span> close</span>
      </div>
    </div>
  );
}

function ReelItem({ photo, index, eager, itemRef }) {
  const ref = useRef(null);
  const [inView, setInView] = useState(eager);

  useEffect(() => {
    if (itemRef) itemRef(ref.current);
  }, [itemRef]);

  useEffect(() => {
    if (inView) return;
    const el = ref.current;
    if (!el) return;
    const io = new IntersectionObserver((entries) => {
      for (const e of entries) {
        if (e.isIntersecting) { setInView(true); io.disconnect(); break; }
      }
    }, { rootMargin: '150% 0px' });
    io.observe(el);
    return () => io.disconnect();
  }, [inView]);

  return (
    <section ref={ref} className="ap-reel-item" data-idx={index}>
      {inView && (
        <img
          className="ap-reel-photo"
          src={photo.thumbSm || photo.thumb || photo.src}
          alt={photo.title}
          draggable={false}
          decoding="async"
          fetchpriority={eager ? 'high' : 'auto'}
        />
      )}
    </section>
  );
}

function MobileReel({ photos, route, archiveTotal, onRefetch }) {
  const showTrailer = route?.view === 'all' && typeof archiveTotal === 'number' && archiveTotal > photos.length;
  const [activeIdx, setActiveIdx] = useState(0);
  const itemsRef = useRef([]);
  const reelRef = useRef(null);
  const [pull, setPull] = useState(0);
  const [slots, setSlots] = useState(() => ({
    a: { photo: photos[0] ?? null, on: !!photos[0] },
    b: { photo: null, on: false },
  }));

  useEffect(() => {
    const items = itemsRef.current.filter(Boolean);
    if (!items.length) return;
    const visibility = new Map();
    const io = new IntersectionObserver((entries) => {
      for (const e of entries) visibility.set(e.target, e.intersectionRatio);
      let best = -1, bestIdx = 0;
      for (const [el, r] of visibility) {
        if (r > best) { best = r; bestIdx = Number(el.dataset.idx); }
      }
      setActiveIdx(bestIdx);
    }, { threshold: [0, 0.25, 0.5, 0.75, 1] });
    items.forEach((el) => io.observe(el));
    return () => io.disconnect();
  }, [photos.length, showTrailer]);

  useEffect(() => {
    const reel = reelRef.current;
    if (!reel) return;
    let startY = 0, dy = 0, pulling = false;
    const onStart = (e) => {
      if (reel.scrollTop > 0) return;
      startY = e.touches[0].clientY;
      pulling = true;
    };
    const onMove = (e) => {
      if (!pulling) return;
      dy = e.touches[0].clientY - startY;
      if (dy < 0) { setPull(0); pulling = false; return; }
      setPull(Math.min(1.2, dy / 110));
    };
    const onEnd = () => {
      if (pulling && dy > 80 && onRefetch) onRefetch();
      setPull(0);
      pulling = false;
      dy = 0;
    };
    reel.addEventListener('touchstart', onStart, { passive: true });
    reel.addEventListener('touchmove', onMove, { passive: true });
    reel.addEventListener('touchend', onEnd);
    reel.addEventListener('touchcancel', onEnd);
    return () => {
      reel.removeEventListener('touchstart', onStart);
      reel.removeEventListener('touchmove', onMove);
      reel.removeEventListener('touchend', onEnd);
      reel.removeEventListener('touchcancel', onEnd);
    };
  }, [onRefetch]);

  const current = photos[activeIdx];

  useEffect(() => {
    if (!current) return;
    setSlots((s) => {
      if (s.a.on && s.a.photo?.id === current.id) return s;
      if (s.b.on && s.b.photo?.id === current.id) return s;
      if (s.a.on) {
        return { a: { ...s.a, on: false }, b: { photo: current, on: true } };
      }
      return { a: { photo: current, on: true }, b: { ...s.b, on: false } };
    });
  }, [current?.id]);

  const onTrailer = showTrailer && activeIdx === photos.length;
  const exif = onTrailer
    ? ''
    : [current?.camera, current?.focal, current?.fstop, current?.shutter, current?.iso]
        .filter(Boolean).join(' · ');

  const isLast = !showTrailer && activeIdx === photos.length - 1;

  return (
    <>
      <div className="ap-reel-bloom-stage" aria-hidden="true">
        {['a', 'b'].map((k) => slots[k].photo && (
          <img
            key={`${k}-${slots[k].photo.id}`}
            className="ap-reel-bloom"
            src={slots[k].photo.thumbSm || slots[k].photo.thumb || slots[k].photo.src}
            data-on={slots[k].on}
            draggable={false}
            decoding="async"
          />
        ))}
      </div>

      {pull > 0 && (
        <div
          className="ap-reel-pull"
          style={{
            '--p': pull,
            opacity: Math.min(1, pull),
            transform: `translate(-50%, ${pull * 60 - 30}px) rotate(${pull * 360}deg)`,
          }}
          aria-hidden
        >
          <svg viewBox="-12 -12 24 24" width="22" height="22">
            <circle cx="0" cy="0" r="9" fill="none" stroke="currentColor" strokeWidth="1" />
            {[0, 60, 120, 180, 240, 300].map((a) => (
              <line key={a} x1="7" y1="0" x2="3" y2="0" stroke="currentColor" strokeWidth="1" strokeLinecap="round" transform={`rotate(${a})`} />
            ))}
            <circle cx="0" cy="0" r="1.4" fill="currentColor" />
          </svg>
        </div>
      )}

      <div className="ap-reel-hud-top">
        <div className="ap-reel-hud-l">
          <a className="ap-reel-hud-mark" href="#/">aperture</a>
          {route?.view === 'collection' ? (
            <a className="ap-reel-hud-link ap-mono" href="#/collections">/ {route.name}</a>
          ) : (
            <a className="ap-reel-hud-link ap-mono" href="#/collections">collections</a>
          )}
        </div>
        {onTrailer ? (
          <span key="trailer" className="ap-mono ap-reel-hud-counter ap-muted">archive</span>
        ) : (
          <span key={activeIdx} className="ap-mono ap-reel-hud-counter">
            {String(activeIdx + 1).padStart(3, '0')}
            <span className="ap-muted"> / {String(photos.length).padStart(3, '0')}</span>
          </span>
        )}
      </div>

      <div className="ap-reel" ref={reelRef}>
        {photos.map((p, i) => (
          <ReelItem
            key={p.id}
            photo={p}
            index={i}
            eager={i < 2}
            itemRef={(el) => (itemsRef.current[i] = el)}
          />
        ))}
        {showTrailer && (
          <ReelArchiveTrailer
            index={photos.length}
            itemRef={(el) => (itemsRef.current[photos.length] = el)}
          />
        )}
      </div>

      <div className="ap-reel-hud-bottom">
        {!onTrailer && (
          <div className="ap-reel-hud-content" key={current?.id}>
            <div className="ap-reel-hud-title">{current?.title}</div>
            {exif && <div className="ap-reel-hud-exif ap-mono">{exif}</div>}
          </div>
        )}
        {isLast && <div className="ap-reel-end ap-mono">end of selection</div>}
      </div>
    </>
  );
}

function Header({ count, archiveTotal, route }) {
  const inCollection = route?.view === 'collection';
  const hidden = useScrollDirection();
  const showOf = !inCollection && typeof archiveTotal === 'number' && archiveTotal > count;
  return (
    <header className={'ap-header' + (hidden ? ' is-hidden' : '')}>
      <div className="ap-header-l">
        <Wordmark />
        {inCollection && <span className="ap-header-crumb ap-mono">/ {route.name}</span>}
      </div>
      <div className="ap-header-meta ap-mono">
        {inCollection ? (
          <a href="#/collections" className="ap-header-link">← collections</a>
        ) : showOf ? (
          <>
            <span>{count} of {archiveTotal} photographs</span>
            <span className="ap-header-sep"> · </span>
            <a href="#/collections" className="ap-header-link">collections</a>
          </>
        ) : (
          <>
            <span>{count} {count === 1 ? 'photograph' : 'photographs'}</span>
            <span className="ap-header-sep"> · </span>
            <a href="#/collections" className="ap-header-link">collections</a>
          </>
        )}
      </div>
    </header>
  );
}

function Footer() {
  return (
    <footer className="ap-footer ap-mono">
      <span>© aperture</span>
    </footer>
  );
}

function NowViewing({ photos }) {
  const [info, setInfo] = useState(null);
  useEffect(() => {
    const onMove = (e) => {
      const tile = e.target.closest && e.target.closest('.ap-tile');
      if (!tile) { setInfo(null); return; }
      const id = tile.getAttribute('data-photo-id');
      const p = photos.find((x) => x.id === id);
      if (!p) { setInfo(null); return; }
      setInfo((prev) => (prev && prev.p.id === p.id ? prev : {
        idx: photos.indexOf(p) + 1, p,
      }));
    };
    const onLeave = () => setInfo(null);
    window.addEventListener('mousemove', onMove);
    window.addEventListener('mouseleave', onLeave);
    return () => {
      window.removeEventListener('mousemove', onMove);
      window.removeEventListener('mouseleave', onLeave);
    };
  }, [photos]);
  if (!info) return null;
  const { p } = info;
  const exif = [p.camera, p.focal, p.fstop, p.shutter, p.iso]
    .filter(Boolean).join(' · ');
  return (
    <div className="ap-now" key={p.id}>
      <div className="ap-now-title">{p.title}</div>
      {exif && <div className="ap-now-exif ap-mono">{exif}</div>}
    </div>
  );
}

function CometCursor() {
  const N = 7;                          // 1 head + 6 trail dots
  const headRef = useRef(null);
  const tailRefs = useRef([]);
  const [over, setOver] = useState(false);
  const [overTile, setOverTile] = useState(false);

  useEffect(() => {
    let mx = 0, my = 0, raf = 0;
    const dots = Array.from({ length: N }, () => ({ x: 0, y: 0 }));
    const setT = (el, x, y) => {
      if (el) el.style.transform =
        `translate3d(${x}px, ${y}px, 0) translate(-50%, -50%)`;
    };
    const tick = () => {
      // Head eases toward the real cursor; each subsequent dot eases toward the previous.
      dots[0].x += (mx - dots[0].x) * 0.42;
      dots[0].y += (my - dots[0].y) * 0.42;
      for (let i = 1; i < N; i++) {
        dots[i].x += (dots[i - 1].x - dots[i].x) * 0.34;
        dots[i].y += (dots[i - 1].y - dots[i].y) * 0.34;
      }
      setT(headRef.current, dots[0].x, dots[0].y);
      for (let i = 1; i < N; i++) setT(tailRefs.current[i - 1], dots[i].x, dots[i].y);
      raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);

    const onMove = (e) => {
      mx = e.clientX; my = e.clientY;
      setOverTile(!!(e.target.closest && e.target.closest('.ap-tile')));
      setOver(!!(e.target.closest && e.target.closest('.ap-main')));
    };
    window.addEventListener('mousemove', onMove);
    return () => {
      window.removeEventListener('mousemove', onMove);
      cancelAnimationFrame(raf);
    };
  }, []);

  const tailCount = N - 1;
  return (
    <div className="ap-comet" data-on={over} data-tile={overTile} aria-hidden>
      {Array.from({ length: tailCount }).map((_, i) => (
        <div
          key={i}
          ref={(el) => (tailRefs.current[i] = el)}
          className="ap-comet-tail"
          style={{ '--i': i, '--n': tailCount }}
        />
      ))}
      <div ref={headRef} className="ap-comet-head" />
    </div>
  );
}

function EmptyState() {
  return (
    <div className="ap-empty">
      <div className="ap-empty-label">no photographs yet</div>
      <div className="ap-empty-sub ap-mono">add some from <a href="/admin">/admin</a></div>
    </div>
  );
}

function CursorSpotlight() {
  const ref = useRef(null);
  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    let raf = 0;
    const onMove = (e) => {
      cancelAnimationFrame(raf);
      raf = requestAnimationFrame(() => {
        el.style.setProperty('--mx', `${e.clientX}px`);
        el.style.setProperty('--my', `${e.clientY}px`);
        el.dataset.on = 'true';
      });
    };
    const onLeave = () => { el.dataset.on = 'false'; };
    window.addEventListener('mousemove', onMove);
    window.addEventListener('mouseleave', onLeave);
    return () => {
      cancelAnimationFrame(raf);
      window.removeEventListener('mousemove', onMove);
      window.removeEventListener('mouseleave', onLeave);
    };
  }, []);
  return <div ref={ref} className="ap-cursor-spot" data-on="false" aria-hidden />;
}

function ShufflePeek({ previews, startIdx, hovering, frameClass, baseClass }) {
  const [tick, setTick] = useState(0);
  useEffect(() => {
    if (!hovering || previews.length < 2) {
      setTick(0);
      return;
    }
    const id = setInterval(() => setTick((t) => t + 1), 1100);
    return () => clearInterval(id);
  }, [hovering, previews.length]);

  const current = previews.length ? previews[(startIdx + tick) % previews.length] : null;
  const [slots, setSlots] = useState(() => ({ a: current, b: null, active: 'a' }));
  useEffect(() => {
    if (!current) return;
    setSlots((s) => {
      const cur = s[s.active];
      if (cur && cur.thumb === current.thumb) return s;
      const other = s.active === 'a' ? 'b' : 'a';
      return { ...s, [other]: current, active: other };
    });
  }, [current?.thumb]);

  if (!current) return null;
  return (
    <span className={frameClass}>
      {slots.a && (
        <img
          key={`a-${slots.a.thumb}`}
          className={baseClass + (slots.active === 'a' ? ' is-on' : '')}
          src={slots.a.thumb}
          alt=""
          draggable={false}
        />
      )}
      {slots.b && (
        <img
          key={`b-${slots.b.thumb}`}
          className={baseClass + (slots.active === 'b' ? ' is-on' : '')}
          src={slots.b.thumb}
          alt=""
          draggable={false}
        />
      )}
    </span>
  );
}

function CollectionCard({ collection, index }) {
  const ref = useRef(null);
  const [hovering, setHovering] = useState(false);
  const [pressed, setPressed] = useState(false);
  const [inView, setInView] = useState(false);

  // Scroll-into-view stagger
  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const io = new IntersectionObserver((entries) => {
      for (const e of entries) {
        if (e.isIntersecting) {
          setInView(true);
          io.unobserve(e.target);
        }
      }
    }, { threshold: 0.15, rootMargin: '0px 0px -40px 0px' });
    io.observe(el);
    return () => io.disconnect();
  }, []);

  const onEnter = () => setHovering(true);
  const onLeave = () => setHovering(false);

  const onClick = (e) => {
    if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
    e.preventDefault();
    setPressed(true);
    setTimeout(() => {
      window.location.hash = `#/collections/${encodeURIComponent(collection.name)}`;
    }, 220);
  };

  const previews = collection.previews && collection.previews.length
    ? collection.previews
    : (collection.cover ? [collection.cover] : []);

  return (
    <a
      ref={ref}
      className={
        'ap-collection-card'
        + (inView ? ' is-in' : '')
        + (pressed ? ' ap-cc-pressed' : '')
      }
      href={`#/collections/${encodeURIComponent(collection.name)}`}
      onMouseEnter={onEnter}
      onMouseLeave={onLeave}
      onClick={onClick}
      style={{ '--i': index }}
    >
      <div className="ap-collection-tilt">
        {previews[0] && (
          <div className="ap-collection-bloom-wrap">
            <img className="ap-collection-bloom" src={previews[0].thumb} aria-hidden="true" draggable={false} />
          </div>
        )}
        <div className="ap-collection-deck">
          {previews.length >= 3 && (
            <ShufflePeek
              previews={previews}
              startIdx={2}
              hovering={hovering}
              frameClass="ap-collection-peek-frame ap-collection-peek-frame-2"
              baseClass="ap-collection-peek ap-collection-peek-2"
            />
          )}
          {previews.length >= 2 && (
            <ShufflePeek
              previews={previews}
              startIdx={1}
              hovering={hovering}
              frameClass="ap-collection-peek-frame ap-collection-peek-frame-1"
              baseClass="ap-collection-peek ap-collection-peek-1"
            />
          )}
          {previews[0] && (
            <img className="ap-collection-cover" src={previews[0].thumb} alt="" draggable={false} />
          )}
        </div>
        <div className="ap-collection-meta">
          <div className="ap-collection-name">{collection.name}</div>
          <div className="ap-collection-count ap-mono">
            {collection.count} {collection.count === 1 ? 'work' : 'works'}
          </div>
        </div>
      </div>
    </a>
  );
}

function ReelArchiveTrailer({ index, itemRef }) {
  const ref = useRef(null);
  useEffect(() => {
    if (itemRef) itemRef(ref.current);
  }, [itemRef]);
  const go = (e) => {
    e.preventDefault();
    window.location.hash = '#/collections';
  };
  return (
    <section
      ref={ref}
      className="ap-reel-item ap-reel-trailer"
      data-idx={index}
      data-trailer="true"
      onClick={go}
      role="link"
      tabIndex={0}
    >
      <div className="ap-reel-trailer-headline">explore the archive</div>
      <div className="ap-reel-trailer-arrow" aria-hidden>→</div>
    </section>
  );
}

function CollectionsIndex() {
  const [items, setItems] = useState(null);
  useEffect(() => {
    fetch('/api/collections').then((r) => r.json()).then(setItems).catch(() => setItems([]));
  }, []);

  return (
    <div className="ap-collections">
      <CursorSpotlight />

      <header className="ap-collections-head">
        <Wordmark />
        <a className="ap-header-link ap-mono" href="#/">← all photos</a>
      </header>

      <main className="ap-collections-main">
        <h1 className="ap-collections-title">collections</h1>

        {items === null ? null : items.length === 0 ? (
          <div className="ap-empty">
            <div className="ap-empty-label">no collections yet</div>
            <div className="ap-empty-sub ap-mono">tag photos with a collection in <a href="/admin">/admin</a></div>
          </div>
        ) : (
          <div className="ap-collections-grid">
            {items.map((c, i) => (
              <CollectionCard key={c.name} collection={c} index={i} />
            ))}
          </div>
        )}
      </main>
    </div>
  );
}

function App() {
  const [photos, setPhotos] = useState(null);
  const [archiveTotal, setArchiveTotal] = useState(null);
  const [collectionsMeta, setCollectionsMeta] = useState([]);
  const [open, setOpen] = useState(null);
  const isMobile = useIsMobile();
  const route = useHashRoute();

  const fetchPhotos = useCallback(() => {
    const url = route.view === 'collection'
      ? `/api/photos?collection=${encodeURIComponent(route.name)}`
      : '/api/photos';
    return fetch(url)
      .then((r) => r.json())
      .then((data) => {
        if (route.view === 'all') setPhotos(pickStableHomeShuffle(data, HOME_COUNT_MIN, HOME_COUNT_MAX));
        else setPhotos(data);
      })
      .catch(() => setPhotos([]));
  }, [route.view, route.name]);

  useEffect(() => {
    if (route.view === 'collections') return;
    setPhotos(null);
    setOpen(null);
    fetchPhotos();
  }, [route.view, route.name, fetchPhotos]);

  useEffect(() => {
    if (route.view !== 'all') return;
    fetch('/api/photos/count')
      .then((r) => r.json())
      .then((d) => setArchiveTotal(typeof d?.total === 'number' ? d.total : null))
      .catch(() => setArchiveTotal(null));
  }, [route.view]);

  useEffect(() => {
    if (route.view !== 'collection') return;
    fetch('/api/collections')
      .then((r) => r.json())
      .then((data) => setCollectionsMeta(Array.isArray(data) ? data : []))
      .catch(() => setCollectionsMeta([]));
  }, [route.view]);

  const displayPhotos = useMemo(() => {
    if (!photos) return photos;
    if (route.view !== 'collection') return photos;
    const meta = collectionsMeta.find((c) => c.name === route.name);
    if (meta?.displayOrder === 'random') {
      return pickStableCollectionOrder(route.name, photos);
    }
    return photos;
  }, [photos, route.view, route.name, collectionsMeta]);

  const openAt = useCallback((photo, originRect) => setOpen({ photo, originRect }), []);
  const closeLB = useCallback(() => setOpen(null), []);
  const navLB = useCallback((delta) => {
    setOpen((cur) => {
      if (!cur || !displayPhotos) return cur;
      const i = displayPhotos.findIndex((p) => p.id === cur.photo.id);
      const next = (i + delta + displayPhotos.length) % displayPhotos.length;
      return { photo: displayPhotos[next], originRect: null };
    });
  }, [displayPhotos]);

  const routeKey = route.view + (route.name || '');
  const rootClass = isMobile && route.view !== 'collections'
    ? 'ap-root ap-root-mobile'
    : 'ap-root';

  let body;
  if (route.view === 'collections') {
    body = <CollectionsIndex />;
  } else if (displayPhotos === null) {
    body = null;
  } else if (isMobile) {
    body = displayPhotos.length > 0
      ? <MobileReel photos={displayPhotos} route={route} archiveTotal={archiveTotal} onRefetch={fetchPhotos} />
      : <EmptyState />;
  } else {
    body = (
      <>
        <Header count={displayPhotos.length} archiveTotal={archiveTotal} route={route} />
        <main className="ap-main" style={{ paddingLeft: 8, paddingRight: 8 }}>
          {displayPhotos.length > 0
            ? <Masonry
                photos={displayPhotos}
                cols={3}
                gutter={6}
                onOpen={openAt}
                tailHref={route.view === 'all' ? '#/collections' : undefined}
                tailLabel="explore the archive"
              />
            : <EmptyState />}
          <Footer />
        </main>
        {displayPhotos.length > 0 && <NowViewing photos={displayPhotos} />}
        <CometCursor />
      </>
    );
  }

  return (
    <div className={rootClass}>
      <div className="ap-bg-bloom" aria-hidden />
      <div className="ap-route" key={routeKey}>
        {body}
      </div>
      {open && (
        <Lightbox
          photo={open.photo}
          originRect={open.originRect}
          photos={displayPhotos || []}
          onClose={closeLB}
          onNav={navLB}
        />
      )}
    </div>
  );
}

ReactDOM.createRoot(document.getElementById('root')).render(<App />);
