/* Animap SPA — React via CDN, in-browser Babel.
   Single-file app: router, screens, satellite map, upload + recognition flow. */

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

// ---------- design primitives (from design bundle) ----------

const Icon = ({ path, size = 18, stroke = 1.5, fill = 'none' }) => (
  <svg width={size} height={size} viewBox="0 0 24 24" fill={fill} stroke="currentColor"
       strokeWidth={stroke} strokeLinecap="round" strokeLinejoin="round">{path}</svg>
);
const I = {
  plus:    <Icon path={<><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></>} />,
  back:    <Icon path={<><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></>} />,
  close:   <Icon path={<><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></>} />,
  upload:  <Icon path={<><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></>} />,
  user:    <Icon path={<><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></>} />,
  google:  <svg width="18" height="18" viewBox="0 0 24 24"><path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/><path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/><path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/><path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/></svg>,
  sparkle: <Icon path={<path d="M12 3l2 5 5 2-5 2-2 5-2-5-5-2 5-2z"/>} />,
};

function project(lat, lng, bbox, w, h) {
  const { north, south, east, west } = bbox;
  return { x: ((lng - west) / (east - west)) * w, y: ((north - lat) / (north - south)) * h };
}
function fitBbox(points, pad = 0.35) {
  if (!points.length) return { north: 50, south: -50, east: 180, west: -180 };
  // pad may be a number (uniform) or { top, bottom, left, right }.
  // Top is intentionally larger by default so the popup (which opens upward
  // from a pin) has room above pins near the map's top edge.
  const p = typeof pad === 'number'
    ? { top: pad, bottom: pad, left: pad, right: pad }
    : { top: pad.top ?? 0.35, bottom: pad.bottom ?? 0.35, left: pad.left ?? 0.35, right: pad.right ?? 0.35 };
  const lats = points.map(p => p.lat), lngs = points.map(p => p.lng);
  let south = Math.min(...lats), north = Math.max(...lats);
  let west = Math.min(...lngs), east = Math.max(...lngs);
  const dLat = Math.max(0.02, north - south), dLng = Math.max(0.02, east - west);
  south -= dLat * p.bottom; north += dLat * p.top;
  west -= dLng * p.left;   east += dLng * p.right;
  return { north, south, east, west };
}
function bboxPaddingForPoints(points) {
  if (!points.length) return { top: 0.8, bottom: 0.4, left: 0.2, right: 0.2 };
  const lats = points.map((p) => p.lat);
  const lngs = points.map((p) => p.lng);
  const span = Math.max(
    Math.max(...lats) - Math.min(...lats),
    Math.max(...lngs) - Math.min(...lngs),
  );
  if (span < 0.02) return { top: 0.22, bottom: 0.14, left: 0.08, right: 0.08 };
  if (span < 0.08) return { top: 0.34, bottom: 0.2, left: 0.12, right: 0.12 };
  if (span < 0.3) return { top: 0.5, bottom: 0.28, left: 0.15, right: 0.15 };
  return { top: 0.8, bottom: 0.4, left: 0.2, right: 0.2 };
}
function fmtCoord(lat, lng) {
  return `${Math.abs(lat).toFixed(4)}° ${lat>=0?'N':'S'}, ${Math.abs(lng).toFixed(4)}° ${lng>=0?'E':'W'}`;
}
function fmtDate(epochSec) {
  if (!epochSec) return '';
  return new Date(epochSec * 1000).toLocaleDateString('en-US', { day:'2-digit', month:'short', year:'numeric' });
}

// ---------- Satellite map (from design) ----------

function lngLatToTile(lng, lat, z) {
  const n = Math.pow(2, z);
  const x = ((lng + 180) / 360) * n;
  const latRad = (lat * Math.PI) / 180;
  const y = ((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2) * n;
  return { x, y };
}
function tileUrl(z, x, y) {
  return `https://tile.openstreetmap.org/${z}/${x}/${y}.png`;
}
function wrapTileX(x, z) {
  const n = Math.pow(2, z);
  return ((x % n) + n) % n;
}
function zoomPenaltyForExtent(bbox) {
  const dLat = Math.abs(bbox.north - bbox.south);
  const dLng = Math.abs(bbox.east - bbox.west);
  const span = Math.max(dLat, dLng);
  if (span > 80) return 3;
  if (span > 30) return 2;
  if (span > 10) return 1;
  return 0;
}
function pickZoom(bbox, w, h) {
  const dpr = 1;
  const targetW = w * dpr, targetH = h * dpr;
  const penalty = zoomPenaltyForExtent(bbox);
  for (let z = 1; z <= 18; z++) {
    const a = lngLatToTile(bbox.west, bbox.north, z);
    const b = lngLatToTile(bbox.east, bbox.south, z);
    const pxW = (b.x - a.x) * 256, pxH = (b.y - a.y) * 256;
    if (pxW >= targetW && pxH >= targetH) return Math.max(1, z - penalty);
  }
  return 18;
}

function buildTileView(bbox, width, height) {
  const z = pickZoom(bbox, width, height);
  const n = Math.pow(2, z);
  const overscan = 3;
  const rawA = lngLatToTile(bbox.west, bbox.north, z);
  const rawB = lngLatToTile(bbox.east, bbox.south, z);
  let left = rawA.x;
  let right = rawB.x;
  let top = rawA.y;
  let bottom = rawB.y;

  const spanX = (right - left) * 256;
  const spanY = (bottom - top) * 256;
  const targetAspect = width / Math.max(height, 1);
  const bboxAspect = spanX / Math.max(spanY, 1);

  if (bboxAspect > targetAspect) {
    const desiredSpanY = spanX / targetAspect / 256;
    const extra = (desiredSpanY - (bottom - top)) / 2;
    top -= extra;
    bottom += extra;
  } else {
    const desiredSpanX = spanY * targetAspect / 256;
    const extra = (desiredSpanX - (right - left)) / 2;
    left -= extra;
    right += extra;
  }

  const a = { x: left, y: top };
  const b = { x: right, y: bottom };
  const xMin = Math.floor(a.x) - overscan;
  const xMax = Math.floor(b.x) + overscan;
  const yMin = Math.max(0, Math.floor(a.y) - overscan);
  const yMax = Math.min(n - 1, Math.floor(b.y) + overscan);
  const offsetX = -(a.x - xMin) * 256, offsetY = -(a.y - yMin) * 256;
  const bboxPxW = (b.x - a.x) * 256, bboxPxH = (b.y - a.y) * 256;
  const scale = Math.max(width / bboxPxW, height / bboxPxH);
  const extraX = (width - bboxPxW * scale) / 2;
  const extraY = (height - bboxPxH * scale) / 2;
  return { z, a, b, xMin, xMax, yMin, yMax, offsetX, offsetY, scale, extraX, extraY };
}

function projectToView(lat, lng, view) {
  const t = lngLatToTile(lng, lat, view.z);
  return {
    x: view.extraX + (t.x - view.a.x) * 256 * view.scale,
    y: view.extraY + (t.y - view.a.y) * 256 * view.scale,
  };
}

function SatelliteTiles({ view }) {
  // Tile list only depends on tile-range primitives — stable under pan inside the same z window.
  const { z, xMin, xMax, yMin, yMax } = view;
  const tiles = useMemo(() => {
    const all = [];
    for (let x = xMin; x <= xMax; x++) {
      for (let y = yMin; y <= yMax; y++) {
        all.push({ x, y, key: `${z}-${x}-${y}`, src: tileUrl(z, wrapTileX(x, z), y) });
      }
    }
    return all;
  }, [z, xMin, xMax, yMin, yMax]);

  // Transform changes each pan/zoom frame but is just a cheap GPU style update.
  const tx = view.extraX + view.offsetX * view.scale;
  const ty = view.extraY + view.offsetY * view.scale;
  return (
    <div style={{ position:'absolute', left:0, top:0, width:(xMax-xMin+1)*256, height:(yMax-yMin+1)*256,
                  transformOrigin:'0 0', transform:`translate3d(${tx}px, ${ty}px, 0) scale(${view.scale})`,
                  willChange:'transform', pointerEvents:'none' }}>
      {tiles.map((t) => (
        <img key={t.key} src={t.src} alt="" loading="eager" draggable={false}
             style={{ position:'absolute', left:(t.x-xMin)*256, top:(t.y-yMin)*256, width:256, height:256 }}
             onError={(e) => { e.target.style.opacity = 0; }} />
      ))}
    </div>
  );
}

// Grouping in tile-coordinate space — invariant under pan, only depends on (z, threshold).
function clusterPinsInTileSpace(pins, z, thresholdTiles) {
  const projected = pins.map((p) => {
    const t = lngLatToTile(p.lng, p.lat, z);
    return { pin: p, tileX: t.x, tileY: t.y };
  });
  const groups = [];
  const thr2 = thresholdTiles * thresholdTiles;
  for (const p of projected) {
    let added = false;
    for (const g of groups) {
      const dx = g.tileX - p.tileX, dy = g.tileY - p.tileY;
      if (dx * dx + dy * dy < thr2) {
        g.items.push(p);
        const n = g.items.length;
        g.tileX += (p.tileX - g.tileX) / n;
        g.tileY += (p.tileY - g.tileY) / n;
        added = true; break;
      }
    }
    if (!added) groups.push({ tileX: p.tileX, tileY: p.tileY, items: [p] });
  }
  return groups;
}

function clampBbox(b) {
  let { north, south, east, west } = b;
  north = Math.min(85, Math.max(-85, north));
  south = Math.min(85, Math.max(-85, south));
  if (north - south < 0.0005) {
    const c = (north + south) / 2; north = c + 0.00025; south = c - 0.00025;
  }
  if (east - west < 0.0005) {
    const c = (east + west) / 2; east = c + 0.00025; west = c - 0.00025;
  }
  if (north - south > 170) {
    const c = (north + south) / 2; north = c + 85; south = c - 85;
  }
  if (east - west > 360) {
    const c = (east + west) / 2; east = c + 180; west = c - 180;
  }
  return { north, south, east, west };
}

function SatelliteMap({ pins, selectedId, onSelect, onPreviewPhoto }) {
  const containerRef = useRef(null);
  const [size, setSize] = useState({ w: 800, h: 400 });
  const [hoverId, setHoverId] = useState(null);
  const [dragging, setDragging] = useState(false);

  useEffect(() => {
    if (!containerRef.current) return;
    const ro = new ResizeObserver(entries => {
      const r = entries[0].contentRect;
      setSize({ w: Math.max(200, r.width), h: Math.max(150, r.height) });
    });
    ro.observe(containerRef.current);
    return () => ro.disconnect();
  }, []);

  // Auto-fit to all pins, then let user pan/zoom from there.
  const autoBbox = useMemo(() => fitBbox(pins, bboxPaddingForPoints(pins)), [pins]);
  const [bbox, setBbox] = useState(autoBbox);
  const pinsKey = useMemo(() => pins.map((p) => `${p.id}:${p.lat}:${p.lng}`).join('|'), [pins]);
  // Refit whenever the set of pins changes (add/remove/move).
  useEffect(() => { setBbox(autoBbox); /* eslint-disable-next-line */ }, [pinsKey]);

  const view = useMemo(() => buildTileView(bbox, size.w, size.h), [bbox, size.w, size.h]);
  const viewRef = useRef(view); viewRef.current = view;
  const selRef = useRef({ selectedId, onSelect }); selRef.current = { selectedId, onSelect };

  // Pointer-based pan + pinch-zoom. Handles mouse, touch, and pen uniformly.
  // pin/popup pointerdown stops propagation so gestures only start on map background.
  // Moves are coalesced via rAF so at most one setBbox fires per frame.
  const pointersRef = useRef(new Map());
  const gestureRef = useRef(null);
  const bboxRef = useRef(bbox); bboxRef.current = bbox;

  function clientToLngLat(clientX, clientY) {
    const rect = containerRef.current.getBoundingClientRect();
    const v = viewRef.current;
    const cx = clientX - rect.left;
    const cy = clientY - rect.top;
    const tx = (cx - v.extraX) / (256 * v.scale) + v.a.x;
    const ty = (cy - v.extraY) / (256 * v.scale) + v.a.y;
    const n = Math.pow(2, v.z);
    const lng = (tx / n) * 360 - 180;
    const lat = Math.atan(Math.sinh(Math.PI * (1 - (2 * ty) / n))) * 180 / Math.PI;
    return { lat, lng };
  }

  function startPinch() {
    const pts = [...pointersRef.current.values()];
    const p1 = pts[0], p2 = pts[1];
    const midX = (p1.x + p2.x) / 2;
    const midY = (p1.y + p2.y) / 2;
    const { lat: anchorLat, lng: anchorLng } = clientToLngLat(midX, midY);
    gestureRef.current = {
      mode: 'pinch',
      initialBbox: bboxRef.current,
      initialDist: Math.max(1, Math.hypot(p1.x - p2.x, p1.y - p2.y)),
      initialMid: { x: midX, y: midY },
      anchorLat, anchorLng,
    };
    setDragging(true);
  }

  function makePanState(startX, startY, moved) {
    const v = viewRef.current;
    const b = bboxRef.current;
    return {
      mode: 'pan',
      startX, startY,
      moved,
      startZ: v.z, startScale: v.scale,
      startTA: lngLatToTile(b.west, b.north, v.z),
      startTB: lngLatToTile(b.east, b.south, v.z),
      pendingDx: 0, pendingDy: 0, raf: 0,
    };
  }

  function onMapPointerDown(e) {
    if (e.pointerType === 'mouse' && e.button !== 0) return;
    pointersRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
    if (pointersRef.current.size === 1) {
      gestureRef.current = makePanState(e.clientX, e.clientY, false);
    } else if (pointersRef.current.size === 2) {
      const g = gestureRef.current;
      if (g && g.mode === 'pan' && g.raf) cancelAnimationFrame(g.raf);
      startPinch();
    }
  }

  useEffect(() => {
    function commitPan() {
      const g = gestureRef.current;
      if (!g || g.mode !== 'pan') return;
      g.raf = 0;
      const { pendingDx: dx, pendingDy: dy } = g;
      if (!g.moved && Math.abs(dx) + Math.abs(dy) < 4) return;
      if (!g.moved) setDragging(true);
      g.moved = true;
      const n = Math.pow(2, g.startZ);
      const tileDx = -dx / (256 * g.startScale);
      const tileDy = -dy / (256 * g.startScale);
      const ax = g.startTA.x + tileDx, ay = g.startTA.y + tileDy;
      const bx = g.startTB.x + tileDx, by = g.startTB.y + tileDy;
      setBbox(clampBbox({
        west: (ax / n) * 360 - 180,
        east: (bx / n) * 360 - 180,
        north: Math.atan(Math.sinh(Math.PI * (1 - (2 * ay) / n))) * 180 / Math.PI,
        south: Math.atan(Math.sinh(Math.PI * (1 - (2 * by) / n))) * 180 / Math.PI,
      }));
    }
    function onMove(e) {
      if (!pointersRef.current.has(e.pointerId)) return;
      pointersRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
      const g = gestureRef.current;
      if (!g) return;
      if (g.mode === 'pan' && pointersRef.current.size === 1) {
        g.pendingDx = e.clientX - g.startX;
        g.pendingDy = e.clientY - g.startY;
        if (!g.raf) g.raf = requestAnimationFrame(commitPan);
      } else if (g.mode === 'pinch' && pointersRef.current.size >= 2) {
        const pts = [...pointersRef.current.values()];
        const p1 = pts[0], p2 = pts[1];
        const midX = (p1.x + p2.x) / 2;
        const midY = (p1.y + p2.y) / 2;
        const dist = Math.max(1, Math.hypot(p1.x - p2.x, p1.y - p2.y));
        const factor = g.initialDist / dist;
        const { anchorLng, anchorLat, initialBbox, initialMid } = g;
        const scaled = {
          west: anchorLng - (anchorLng - initialBbox.west) * factor,
          east: anchorLng + (initialBbox.east - anchorLng) * factor,
          north: anchorLat + (initialBbox.north - anchorLat) * factor,
          south: anchorLat - (anchorLat - initialBbox.south) * factor,
        };
        const rect = containerRef.current.getBoundingClientRect();
        const dxPx = midX - initialMid.x;
        const dyPx = midY - initialMid.y;
        const dLng = (-dxPx * (scaled.east - scaled.west)) / rect.width;
        const dLat = (dyPx * (scaled.north - scaled.south)) / rect.height;
        setBbox(clampBbox({
          west: scaled.west + dLng, east: scaled.east + dLng,
          north: scaled.north + dLat, south: scaled.south + dLat,
        }));
      }
    }
    function onUp(e) {
      if (!pointersRef.current.has(e.pointerId)) return;
      pointersRef.current.delete(e.pointerId);
      const g = gestureRef.current;
      if (!g) { setDragging(false); return; }
      if (g.mode === 'pan' && pointersRef.current.size === 0) {
        if (g.raf) cancelAnimationFrame(g.raf);
        setDragging(false);
        if (!g.moved && selRef.current.selectedId && selRef.current.onSelect) {
          selRef.current.onSelect(null);
        }
        gestureRef.current = null;
      } else if (g.mode === 'pinch') {
        if (pointersRef.current.size === 1) {
          const [only] = [...pointersRef.current.values()];
          gestureRef.current = makePanState(only.x, only.y, true);
        } else if (pointersRef.current.size === 0) {
          setDragging(false);
          gestureRef.current = null;
        }
      }
    }
    window.addEventListener('pointermove', onMove);
    window.addEventListener('pointerup', onUp);
    window.addEventListener('pointercancel', onUp);
    return () => {
      window.removeEventListener('pointermove', onMove);
      window.removeEventListener('pointerup', onUp);
      window.removeEventListener('pointercancel', onUp);
    };
  }, []);

  // Wheel-to-zoom around cursor. Attached non-passively so we can preventDefault page scroll.
  useEffect(() => {
    const el = containerRef.current;
    if (!el) return;
    function onWheel(e) {
      e.preventDefault();
      const v = viewRef.current;
      const rect = el.getBoundingClientRect();
      const cx = e.clientX - rect.left;
      const cy = e.clientY - rect.top;
      const tx = (cx - v.extraX) / (256 * v.scale) + v.a.x;
      const ty = (cy - v.extraY) / (256 * v.scale) + v.a.y;
      const nTiles = Math.pow(2, v.z);
      const lng = tx / nTiles * 360 - 180;
      const lat = Math.atan(Math.sinh(Math.PI * (1 - 2 * ty / nTiles))) * 180 / Math.PI;
      const factor = e.deltaY > 0 ? 1.2 : 1 / 1.2;
      setBbox((b) => clampBbox({
        west: lng - (lng - b.west) * factor,
        east: lng + (b.east - lng) * factor,
        north: lat + (b.north - lat) * factor,
        south: lat - (lat - b.south) * factor,
      }));
    }
    el.addEventListener('wheel', onWheel, { passive: false });
    return () => el.removeEventListener('wheel', onWheel);
  }, []);
  const clusterThreshold = view.z >= 15 ? 6 : view.z >= 13 ? 10 : view.z >= 11 ? 18 : 36;
  // Group once per (pins, z, scale, threshold). During pan these stay constant, so we skip the O(n²) work.
  const thresholdTiles = clusterThreshold / (256 * view.scale);
  const clusterGroups = useMemo(
    () => clusterPinsInTileSpace(pins, view.z, thresholdTiles),
    [pinsKey, view.z, thresholdTiles],
  );
  // Project groups to current screen coords on every render — cheap.
  const clusters = useMemo(
    () => clusterGroups.map((g) => ({
      items: g.items.map((it) => it.pin),
      x: view.extraX + (g.tileX - view.a.x) * 256 * view.scale,
      y: view.extraY + (g.tileY - view.a.y) * 256 * view.scale,
    })),
    [clusterGroups, view],
  );
  const selected = pins.find(p => p.id === selectedId);
  const selectedCluster = selectedId ? clusters.find((c) => c.items.some((it) => it.id === selectedId)) : null;
  const selectedClusterItems = selectedCluster?.items || [];
  const selectedIndex = selectedClusterItems.findIndex((it) => it.id === selectedId);
  const hasMultipleSelected = selectedClusterItems.length > 1;
  const selPos = selected ? projectToView(selected.lat, selected.lng, view) : null;
  const popupAnchor = selectedCluster ? { x: selectedCluster.x, y: selectedCluster.y } : selPos;
  const popupWidth = 240;
  const popupGap = 18;
  const popupHeight = 220;
  const showRight = popupAnchor ? popupAnchor.x + popupGap + popupWidth <= size.w - 14 : true;
  const popupLeft = popupAnchor ? (showRight ? popupAnchor.x + popupGap : popupAnchor.x - popupGap - popupWidth) : 0;
  const popupTop = popupAnchor ? Math.min(Math.max(popupAnchor.y - popupHeight / 2, 14), size.h - popupHeight - 14) : 0;

  const empty = pins.length === 0;

  return (
    <div
      ref={containerRef}
      onPointerDown={onMapPointerDown}
      style={{ position:'relative', width:'100%', height:'100%', background:'#e8e2d0',
               overflow:'hidden', border:'1px solid var(--rule)',
               cursor: dragging ? 'grabbing' : 'grab',
               touchAction: 'none', userSelect: 'none' }}
    >
      <SatelliteTiles view={view} />
      <div className="vignette" />

      {/* OSM attribution */}
      <div style={{ position:'absolute', top:14, left:14, background:'rgba(243,235,220,0.92)',
                    padding:'3px 8px', border:'1px solid var(--ink)',
                    fontFamily:'var(--mono)', fontSize:9, letterSpacing:'0.08em', color:'var(--ink-2)' }}>
        © <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noreferrer"
             style={{ color:'inherit', textDecoration:'underline' }}>OpenStreetMap</a> contributors
      </div>

      <div style={{ position:'absolute', top:14, right:14, width:52, height:52, borderRadius:'50%',
                    background:'rgba(243,235,220,0.92)', border:'1px solid var(--ink)',
                    display:'flex', alignItems:'center', justifyContent:'center', boxShadow:'var(--shadow-md)' }}>
        <svg width="36" height="36" viewBox="0 0 40 40">
          <circle cx="20" cy="20" r="14" fill="none" stroke="#1f1b16" strokeWidth="0.5" />
          <polygon points="20,4 22,20 20,36 18,20" fill="#a4442a" stroke="#1f1b16" strokeWidth="0.5" />
          <polygon points="20,4 22,20 18,20" fill="#1f1b16" />
          <text x="20" y="3" textAnchor="middle" fontSize="5" fontFamily="IBM Plex Mono" fill="#1f1b16" fontWeight="600">N</text>
        </svg>
      </div>

      <button
        type="button"
        title="Fit to all sightings"
        onPointerDown={(e) => e.stopPropagation()}
        onClick={() => setBbox(autoBbox)}
        style={{ position:'absolute', top:76, right:14, width:36, height:36,
                 background:'rgba(243,235,220,0.92)', border:'1px solid var(--ink)',
                 borderRadius:0, cursor:'pointer', padding:0,
                 fontFamily:'var(--mono)', fontSize:16, color:'var(--ink)',
                 display:'flex', alignItems:'center', justifyContent:'center',
                 boxShadow:'var(--shadow-md)' }}>
        ⤢
      </button>

      <div style={{ position:'absolute', bottom:14, left:14, background:'rgba(243,235,220,0.92)',
                    padding:'4px 8px', border:'1px solid var(--ink)',
                    fontFamily:'var(--mono)', fontSize:10, letterSpacing:'0.1em', color:'var(--ink)', textTransform:'uppercase' }}>
        <div style={{ display:'flex', alignItems:'center', gap:8 }}>
          <div style={{ width:40, height:6, background:'linear-gradient(to right, #1f1b16 50%, transparent 50%)', border:'1px solid #1f1b16' }} />
          <span>{(() => {
            const dLng = bbox.east - bbox.west;
            const km = dLng * 111 * Math.cos((bbox.north + bbox.south) / 2 * Math.PI / 180);
            if (km > 500) return `${Math.round(km/100)*10} km`;
            if (km > 50) return `${Math.round(km/10)*10} km`;
            return `${Math.max(1, Math.round(km/5))} km`;
          })()}</span>
        </div>
      </div>

      <div style={{ position:'absolute', bottom:14, right:14, background:'rgba(243,235,220,0.92)',
                    padding:'6px 10px', border:'1px solid var(--ink)',
                    fontFamily:'var(--mono)', fontSize:10, letterSpacing:'0.08em', color:'var(--ink)', lineHeight:1.45 }}>
        <div>LAT {bbox.south.toFixed(2)}° – {bbox.north.toFixed(2)}°</div>
        <div>LNG {bbox.west.toFixed(2)}° – {bbox.east.toFixed(2)}°</div>
      </div>

      {empty && (
        <div style={{ position:'absolute', inset:0, display:'flex', alignItems:'center', justifyContent:'center', zIndex:5 }}>
          <div style={{ background:'rgba(243,235,220,0.92)', border:'1px solid var(--ink)', padding:'14px 22px', fontFamily:'var(--mono)', fontSize:11, letterSpacing:'0.12em', textTransform:'uppercase', color:'var(--ink-2)' }}>
            No sightings yet — upload a photo to begin
          </div>
        </div>
      )}

      {clusters.map((c, i) => {
        const isCluster = c.items.length > 1;
        const firstId = c.items[0].id;
        const isSelected = c.items.some(it => it.id === selectedId);
        const isHover = c.items.some(it => it.id === hoverId);
        return (
          <div key={i}
               onPointerDown={(e) => e.stopPropagation()}
               onClick={() => onSelect && onSelect(firstId)}
               onMouseEnter={() => setHoverId(firstId)}
               onMouseLeave={() => setHoverId(null)}
               style={{ position:'absolute', left:c.x, top:c.y, transform:'translate(-50%, -50%)', cursor:'pointer', zIndex: isSelected ? 20 : 10 }}>
            {isSelected && (
              <div style={{ position:'absolute', left:'50%', top:'50%', transform:'translate(-50%, -50%)',
                            width:28, height:28, borderRadius:'50%', background:'rgba(58,90,64,0.5)', animation:'pinPulse 1.4s ease-out infinite' }} />
            )}
            {isCluster ? (
              <div style={{ width:36, height:36, borderRadius:'50%', background:'#3a5a40', color:'#f3ebdc',
                            border:'3px solid #f3ebdc', display:'flex', alignItems:'center', justifyContent:'center',
                            fontFamily:'var(--mono)', fontSize:13, fontWeight:600, boxShadow:'0 2px 6px rgba(0,0,0,0.4)',
                            transform:isHover?'scale(1.1)':'scale(1)', transition:'transform 0.15s' }}>
                {c.items.length}
              </div>
            ) : (
              <div style={{ width:24, height:24, borderRadius:'50%', background:'#3a5a40', border:'3px solid #f3ebdc',
                            boxShadow:'0 2px 6px rgba(0,0,0,0.45)',
                            transform: (isHover||isSelected)?'scale(1.15)':'scale(1)', transition:'transform 0.15s',
                            display:'flex', alignItems:'center', justifyContent:'center' }}>
                <div style={{ width:8, height:8, borderRadius:'50%', background:'#f3ebdc' }} />
              </div>
            )}
          </div>
        );
      })}

      {selected && popupAnchor && (
        <div onPointerDown={(e) => e.stopPropagation()} style={{ position:'absolute', left:popupLeft, top:popupTop,
                      width:240, background:'var(--paper)', border:'1px solid var(--ink)',
                      boxShadow:'var(--shadow-lg)', zIndex:30, animation:'fadeIn 0.2s ease', pointerEvents:'auto' }}>
          <div style={{ position:'relative' }}>
            <button
              type="button"
              onClick={() => onPreviewPhoto && onPreviewPhoto(selected)}
              style={{ display:'block', width:'100%', padding:0, border:0, background:'transparent', cursor:'zoom-in' }}
            >
              <img src={selected.url} alt={selected.speciesData.common} style={{ width:'100%', height:120, objectFit:'cover', display:'block' }} />
            </button>
            {selected.date && (
              <div style={{ position:'absolute', top:6, right:6, background:'var(--paper)', padding:'2px 6px',
                            fontFamily:'var(--mono)', fontSize:9, letterSpacing:'0.12em', border:'1px solid var(--ink)', textTransform:'uppercase' }}>
                {selected.date}
              </div>
            )}
            {hasMultipleSelected && (
              <div style={{ position:'absolute', left:6, right:6, bottom:6, display:'flex', alignItems:'center', justifyContent:'space-between', gap:6 }}>
                <button
                  type="button"
                  className="btn btn-ghost btn-sm"
                  onClick={(e) => {
                    e.stopPropagation();
                    const nextIndex = (selectedIndex - 1 + selectedClusterItems.length) % selectedClusterItems.length;
                    onSelect && onSelect(selectedClusterItems[nextIndex].id);
                  }}
                  style={{ background:'rgba(243,235,220,0.9)', padding:'4px 8px' }}
                >
                  Prev
                </button>
                <div style={{ background:'rgba(31,27,22,0.78)', color:'var(--paper)', padding:'3px 7px',
                              fontFamily:'var(--mono)', fontSize:9, letterSpacing:'0.12em', textTransform:'uppercase' }}>
                  {selectedIndex + 1} / {selectedClusterItems.length}
                </div>
                <button
                  type="button"
                  className="btn btn-ghost btn-sm"
                  onClick={(e) => {
                    e.stopPropagation();
                    const nextIndex = (selectedIndex + 1) % selectedClusterItems.length;
                    onSelect && onSelect(selectedClusterItems[nextIndex].id);
                  }}
                  style={{ background:'rgba(243,235,220,0.9)', padding:'4px 8px' }}
                >
                  Next
                </button>
              </div>
            )}
          </div>
          <div style={{ padding:'10px 12px 12px' }}>
            <div style={{ fontFamily:'var(--serif)', fontWeight:600, fontSize:16, lineHeight:1.2 }}>
              {selected.speciesData.common}
            </div>
            {selected.speciesData.latin && (
              <div style={{ fontFamily:'var(--serif)', fontStyle:'italic', color:'var(--ink-3)', fontSize:13, marginTop:2 }}>
                {selected.speciesData.latin}
              </div>
            )}
            <div className="mono" style={{ fontSize:10, color:'var(--ink-3)', marginTop:6, letterSpacing:'0.08em' }}>
              {fmtCoord(selected.lat, selected.lng)}
            </div>
          </div>
          <div style={{ position:'absolute', top:'50%', [showRight ? 'left' : 'right']:-8, transform:'translateY(-50%) rotate(45deg)',
                        width:14, height:14, background:'var(--paper)',
                        borderTop:'1px solid var(--ink)', borderLeft:'1px solid var(--ink)' }} />
        </div>
      )}
    </div>
  );
}

// ---------- API client ----------

async function api(path, init = {}) {
  const res = await fetch(path, { credentials: 'include', ...init });
  const data = await res.json().catch(() => ({}));
  if (!res.ok) throw new Error(data.error || `${res.status}`);
  return data;
}

// ---------- routing ----------

function useRoute() {
  const [loc, setLoc] = useState(() => ({
    path: window.location.pathname,
    search: window.location.search,
  }));
  useEffect(() => {
    const onPop = () => setLoc({ path: window.location.pathname, search: window.location.search });
    window.addEventListener('popstate', onPop);
    return () => window.removeEventListener('popstate', onPop);
  }, []);
  const navigate = useCallback((to) => {
    const url = new URL(to, window.location.origin);
    window.history.pushState({}, '', url);
    setLoc({ path: url.pathname, search: url.search });
  }, []);
  return [loc, navigate];
}

// ---------- top-level App ----------

function App() {
  const [me, setMe] = useState(undefined); // undefined = loading
  const [route, navigate] = useRoute();

  useEffect(() => {
    api('/api/me').then((d) => setMe(d.user)).catch(() => setMe(null));
  }, []);

  const onboard = new URLSearchParams(route.search).get('onboard');
  const usernameMatch = route.path.match(/^\/u\/(.+)$/) || route.path.match(/^\/@(.+)$/);
  const albumsMatch = route.path === '/albums';
  const albumMatch = route.path.match(/^\/albums\/([^/]+)$/);
  const squareMatch = route.path === '/' || route.path === '/square';

  // Public profile route (no auth needed)
  if (usernameMatch) {
    return (
      <ProfileScreen
        username={decodeURIComponent(usernameMatch[1])}
        me={me}
        navigate={navigate}
        onMeUpdate={setMe}
      />
    );
  }

  if (me && (!me.username || onboard === 'username')) {
    return (
      <UsernameScreen
        me={me}
        onDone={(u) => {
          setMe({ ...me, username: u });
          navigate('/albums');
        }}
      />
    );
  }

  if (squareMatch) {
    return <SquareScreen me={me ?? null} navigate={navigate} />;
  }

  if (albumsMatch) {
    if (me === undefined) return <CenterLoader />;
    if (!me) return <SignInScreen />;
    if (!me.username || onboard === 'username') {
      return (
        <UsernameScreen
          me={me}
          onDone={(u) => {
            setMe({ ...me, username: u });
            navigate('/albums');
          }}
        />
      );
    }
    return <AlbumsScreen me={me} navigate={navigate} />;
  }

  if (albumMatch) {
    return (
      <AlbumDetailScreen
        albumId={albumMatch[1]}
        me={me ?? null}
        navigate={navigate}
      />
    );
  }

  if (me === undefined) {
    return <CenterLoader />;
  }

  if (!me) {
    return <SignInScreen />;
  }
  return <SquareScreen me={me ?? null} navigate={navigate} />;
}

// ---------- screens ----------

function CenterLoader() {
  return (
    <div style={{ minHeight:'100vh', display:'grid', placeItems:'center' }}>
      <div className="loader" />
    </div>
  );
}

function TopBar({ me, navigate, right }) {
  return (
    <div className="topbar">
      <a href="/" onClick={(e) => { e.preventDefault(); navigate('/'); }}
         style={{ textDecoration:'none', color:'inherit', display:'inline-flex', alignItems:'center', gap:8 }}>
        <img src="/logo.svg" alt="" width="22" height="28" style={{ display:'block' }} />
        <span className="brand">animap<span className="dot">·</span></span>
      </a>
      <div className="topnav">
        <a href="/" onClick={(e) => { e.preventDefault(); navigate('/'); }}
           className="topnav-link">
          Square
        </a>
        {me && (
          <a href="/albums" onClick={(e) => { e.preventDefault(); navigate('/albums'); }}
             className="topnav-link">
            Albums
          </a>
        )}
        {right}
        {me && (
          <>
            <a href={`/u/${me.username}`} onClick={(e) => { e.preventDefault(); navigate(`/u/${me.username}`); }}
               className="topnav-link">
              @{me.username}
            </a>
            <button className="btn btn-ghost btn-sm"
                    onClick={async () => { await api('/auth/logout', { method:'POST' }); window.location.href = '/'; }}>
              Sign out
            </button>
          </>
        )}
      </div>
    </div>
  );
}

function SignInScreen() {
  return (
    <div className="signin-shell paper">
      <div className="signin-card fade-in">
        <span className="stamp moss">Field Journal · Est. 2026</span>
        <h2>Animap</h2>
        <p>A field journal of wildlife sightings, plotted on the world.</p>

        <hr className="rule-double" />

        <div style={{ display:'flex', flexDirection:'column', gap:14 }}>
          <a className="btn btn-primary" href="/auth/google" style={{ justifyContent:'center', textDecoration:'none' }}>
            {I.google} Continue with Google
          </a>
          <span className="label" style={{ textAlign:'center' }}>One account. Build maps for life.</span>
        </div>
      </div>
    </div>
  );
}

function UsernameScreen({ me, onDone }) {
  const [value, setValue] = useState('');
  const [status, setStatus] = useState({ state: 'idle', msg: '' });
  const [submitting, setSubmitting] = useState(false);

  useEffect(() => {
    const v = value.trim().toLowerCase();
    if (!v) { setStatus({ state: 'idle', msg: '' }); return; }
    const t = setTimeout(async () => {
      setStatus({ state: 'checking', msg: 'Checking…' });
      try {
        const r = await api('/api/username/check?u=' + encodeURIComponent(v));
        if (r.available) setStatus({ state: 'ok', msg: 'Available' });
        else if (r.reason === 'format') setStatus({ state: 'err', msg: '3–30 chars · a–z, 0–9, _ or -' });
        else setStatus({ state: 'err', msg: 'Already taken' });
      } catch {
        setStatus({ state: 'err', msg: 'Could not check' });
      }
    }, 250);
    return () => clearTimeout(t);
  }, [value]);

  async function submit(e) {
    e.preventDefault();
    if (status.state !== 'ok') return;
    setSubmitting(true);
    try {
      const r = await api('/api/username', {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({ username: value.trim().toLowerCase() }),
      });
      onDone(r.username);
    } catch (err) {
      setStatus({ state: 'err', msg: err.message });
      setSubmitting(false);
    }
  }

  return (
    <div className="signin-shell paper">
      <form className="signin-card fade-in" onSubmit={submit}>
        <span className="stamp">Step 2 of 2</span>
        <h2>Choose a username</h2>
        <p>This is how others will find your field journal — animap.co/@you</p>

        <hr className="rule-double" />

        <div className="field">
          <span className="label">Username</span>
          <div style={{ display:'flex', alignItems:'baseline', gap:6 }}>
            <span className="mono" style={{ color:'var(--ink-3)' }}>@</span>
            <input
              autoFocus
              className="input input-mono"
              value={value}
              onChange={(e) => setValue(e.target.value.replace(/[^A-Za-z0-9_-]/g, ''))}
              placeholder="naturalist_jay" />
          </div>
          <div className={`label ${status.state === 'err' ? '' : ''}`}
               style={{ color: status.state === 'ok' ? 'var(--moss-dark)' : status.state === 'err' ? 'var(--rust-dark)' : undefined }}>
            {status.msg || '·'}
          </div>
        </div>

        <button className="btn btn-moss" type="submit" disabled={status.state !== 'ok' || submitting}
                style={{ width:'100%', justifyContent:'center', marginTop:10 }}>
          {submitting ? 'Claiming…' : 'Claim & enter'}
        </button>
      </form>
    </div>
  );
}

function AlbumsScreen({ me, navigate }) {
  const [albums, setAlbums] = useState(null);
  const [creating, setCreating] = useState(false);

  const reload = useCallback(() => {
    api('/api/albums').then((d) => setAlbums(d.albums));
  }, []);
  useEffect(() => { reload(); }, [reload]);

  return (
    <div className="shell">
      <TopBar me={me} navigate={navigate} />
      <div className="masthead">
        <span className="label">A field journal</span>
        <h1>Animap</h1>
        <p style={{ color:'var(--ink-3)', fontStyle:'italic', margin:0 }}>
          Sightings, plotted. — keeping a quiet record of the wild.
        </p>
        <hr className="rule-dotted" style={{ maxWidth:380, margin:'18px auto' }} />
        <button className="btn btn-moss" onClick={() => setCreating(true)}>
          {I.plus} New album
        </button>
      </div>

      {albums === null ? (
        <div style={{ display:'grid', placeItems:'center', padding:'80px 0' }}>
          <div className="loader" />
        </div>
      ) : albums.length === 0 ? (
        <div className="sheet" style={{ padding:'40px 32px', marginTop:32, textAlign:'center' }}>
          <span className="label">No albums yet</span>
          <p style={{ marginTop:8, color:'var(--ink-3)', fontStyle:'italic' }}>
            Start a new album for a region, a trip, or a backyard.
          </p>
        </div>
      ) : (
        <div className="album-grid">
          {albums.map((a) => (
            <button key={a.id} className="album-card fade-in"
                    onClick={() => navigate(`/albums/${a.id}`)}>
              <div className="cover">
                {a.coverUrl
                  ? <img src={a.coverUrl} alt="" />
                  : <span className="empty">no photos yet</span>}
              </div>
              <div className="meta">
                <h3>{a.name}</h3>
                {a.subtitle && <div className="sub">{a.subtitle}</div>}
                <div className="row">
                  <span className="chip">{a.photoCount} sighting{a.photoCount === 1 ? '' : 's'}</span>
                  <span className={`chip ${a.isPublic ? 'blue' : 'rust'}`}>{a.isPublic ? 'Public' : 'Private'}</span>
                  <span className="chip moss">{fmtDate(a.createdAt)}</span>
                </div>
              </div>
            </button>
          ))}
        </div>
      )}

      {creating && (
        <NewAlbumModal
          onClose={() => setCreating(false)}
          onCreated={(album) => {
            setCreating(false);
            navigate(`/albums/${album.id}`);
          }}
        />
      )}
    </div>
  );
}

function SquareScreen({ me, navigate }) {
  const [albums, setAlbums] = useState(null);

  const reload = useCallback(() => {
    api('/api/square').then((d) => setAlbums(d.albums));
  }, []);
  useEffect(() => { reload(); }, [reload]);

  return (
    <div className="shell">
      <TopBar me={me} navigate={navigate} />
      <div className="masthead" style={{ textAlign:'left', paddingTop:0 }}>
        <span className="label">Public square</span>
        <h1 style={{ marginBottom:8 }}>Field journals, open to all</h1>
        <p style={{ color:'var(--ink-3)', fontStyle:'italic', margin:0 }}>
          Browse public albums from across the map.
        </p>
      </div>

      {!me && (
        <div className="sheet" style={{ padding:'24px 26px', marginTop:24, display:'flex', alignItems:'center', justifyContent:'space-between', gap:18, flexWrap:'wrap' }}>
          <div>
            <span className="label">Start your own journal</span>
            <div style={{ marginTop:6, color:'var(--ink-2)', fontStyle:'italic' }}>
              Sign in to create your own albums and map your sightings.
            </div>
          </div>
          <a className="btn btn-moss" href="/auth/google" style={{ textDecoration:'none' }}>
            {I.google} Sign in
          </a>
        </div>
      )}

      {albums === null ? (
        <div style={{ display:'grid', placeItems:'center', padding:'80px 0' }}>
          <div className="loader" />
        </div>
      ) : albums.length === 0 ? (
        <div className="sheet" style={{ padding:'40px 32px', marginTop:32, textAlign:'center' }}>
          <span className="label">Nothing public yet</span>
          <p style={{ marginTop:8, color:'var(--ink-3)', fontStyle:'italic' }}>
            Public albums will appear here once people open their journals.
          </p>
        </div>
      ) : (
        <div className="album-grid">
          {albums.map((a) => (
            <button key={a.id} className="album-card fade-in" onClick={() => navigate(`/albums/${a.id}`)}>
              <div className="cover">
                {a.coverUrl
                  ? <img src={a.coverUrl} alt="" />
                  : <span className="empty">no photos yet</span>}
              </div>
              <div className="meta">
                <h3>{a.name}</h3>
                {a.subtitle && <div className="sub">{a.subtitle}</div>}
                {a.owner?.displayName && (
                  <div style={{ marginTop:8, color:'var(--ink-3)', fontStyle:'italic', fontSize:14 }}>
                    {a.owner.displayName}
                  </div>
                )}
                <div className="row">
                  <span className="chip">{a.photoCount} sighting{a.photoCount === 1 ? '' : 's'}</span>
                  <span className="chip blue">Public</span>
                  <span className="chip moss">{fmtDate(a.createdAt)}</span>
                </div>
              </div>
            </button>
          ))}
        </div>
      )}
    </div>
  );
}

function NewAlbumModal({ onClose, onCreated }) {
  const [name, setName] = useState('');
  const [subtitle, setSubtitle] = useState('');
  const [isPublic, setIsPublic] = useState(true);
  const [busy, setBusy] = useState(false);
  const [err, setErr] = useState('');

  async function submit(e) {
    e.preventDefault();
    if (!name.trim()) return;
    setBusy(true); setErr('');
    try {
      const album = await api('/api/albums', {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({ name: name.trim(), subtitle: subtitle.trim(), isPublic }),
      });
      onCreated(album);
    } catch (e) { setErr(e.message); setBusy(false); }
  }

  return (
    <div className="modal-backdrop" onMouseDown={(e) => { if (e.target.classList.contains('modal-backdrop')) onClose(); }}>
      <form className="modal-card fade-in" onSubmit={submit}>
        <div className="head">
          <h3>New album</h3>
          <button type="button" className="btn btn-ghost btn-sm" onClick={onClose}>{I.close}</button>
        </div>
        <div className="body">
          <div className="field">
            <span className="label">Name</span>
            <input className="input" value={name} onChange={(e) => setName(e.target.value)}
                   placeholder="Kenya · Maasai Mara" autoFocus />
          </div>
          <div className="field">
            <span className="label">Subtitle (optional)</span>
            <input className="input" value={subtitle} onChange={(e) => setSubtitle(e.target.value)}
                   placeholder="Field notebook, August 2025" />
          </div>
          <div className="field">
            <span className="label">Visibility</span>
            <div className="segmented">
              <button type="button" className={`segmented-option${isPublic ? ' active' : ''}`} onClick={() => setIsPublic(true)}>Public</button>
              <button type="button" className={`segmented-option${!isPublic ? ' active' : ''}`} onClick={() => setIsPublic(false)}>Private</button>
            </div>
            <div className="label">{isPublic ? 'Visible to anyone, even without sign-in.' : 'Only you can open this album.'}</div>
          </div>
          {err && <div className="flash err">{err}</div>}
          <div style={{ display:'flex', justifyContent:'flex-end', marginTop:18 }}>
            <button className="btn btn-moss" type="submit" disabled={busy || !name.trim()}>
              {busy ? 'Creating…' : 'Create album'}
            </button>
          </div>
        </div>
      </form>
    </div>
  );
}

function AlbumDetailScreen({ albumId, me, navigate }) {
  const [data, setData] = useState(null);
  const [selectedId, setSelectedId] = useState(null);
  const [previewPhoto, setPreviewPhoto] = useState(null);
  const [uploading, setUploading] = useState(false);
  const [editing, setEditing] = useState(false);
  const [editingPhoto, setEditingPhoto] = useState(null);

  const reload = useCallback(() => {
    api(`/api/albums/${albumId}`).then(setData).catch(() => navigate('/'));
  }, [albumId, navigate]);
  useEffect(() => { reload(); }, [reload]);

  const photos = data?.photos || [];
  const pins = useMemo(() => photos.map((p) => ({
    id: p.id, lat: p.lat, lng: p.lng, url: p.url,
    date: p.takenAt ? fmtDate(p.takenAt) : '',
    speciesData: p.speciesData,
  })), [photos]);

  if (!data) return <CenterLoader />;
  const { album } = data;
  const speciesCount = new Set(photos.map((p) => p.speciesData.common)).size;

  return (
    <div className="shell">
      <TopBar me={me} navigate={navigate} />

      <div style={{ display:'flex', alignItems:'flex-start', justifyContent:'space-between', gap:24, flexWrap:'wrap' }}>
        <div>
          <span className="label">Album</span>
          <h1 style={{ margin:'4px 0 0', fontFamily:'var(--serif)', fontWeight:500, fontSize:38 }}>
            {album.name}
          </h1>
          {album.owner?.displayName && (
            <div style={{ display:'flex', alignItems:'center', gap:10, marginTop:8, color:'var(--ink-3)' }}>
              {album.owner.avatarUrl
                ? <img src={album.owner.avatarUrl} alt="" style={{ width:28, height:28, borderRadius:'50%', border:'1px solid var(--rule)' }} />
                : <div style={{ width:28, height:28, borderRadius:'50%', background:'var(--paper-3)', display:'grid', placeItems:'center', color:'var(--ink-3)' }}>{I.user}</div>}
              <div style={{ display:'flex', gap:6, alignItems:'baseline', flexWrap:'wrap' }}>
                <span className="label">By</span>
                <a
                  href={`/u/${album.owner.username}`}
                  onClick={(e) => { e.preventDefault(); navigate(`/u/${album.owner.username}`); }}
                  style={{ fontStyle:'italic', color:'inherit', textDecoration:'underline' }}
                >
                  {album.owner.displayName}
                </a>
              </div>
            </div>
          )}
          {album.subtitle && (
            <div style={{ fontStyle:'italic', color:'var(--ink-3)', marginTop:4 }}>{album.subtitle}</div>
          )}
          <div style={{ display:'flex', gap:8, marginTop:12 }}>
            <span className="chip">{photos.length} sighting{photos.length===1?'':'s'}</span>
            <span className="chip moss">{speciesCount} species</span>
            <span className={`chip ${album.isPublic ? 'blue' : 'rust'}`}>{album.isPublic ? 'Public' : 'Private'}</span>
          </div>
        </div>
        {album.canEdit && (
          <div style={{ display:'flex', gap:10, flexWrap:'wrap' }}>
            <button className="btn btn-ghost" onClick={() => setEditing(true)}>Edit album</button>
            <button className="btn btn-moss" onClick={() => setUploading(true)}>{I.upload} Add photo</button>
          </div>
        )}
      </div>

      <hr className="rule-double" />

      <div className="split">
        <div className="map-frame">
          <SatelliteMap
            pins={pins}
            selectedId={selectedId}
            onSelect={setSelectedId}
            onPreviewPhoto={setPreviewPhoto}
          />
        </div>

        {photos.length === 0 ? (
          <div className="sheet" style={{ padding:'30px', textAlign:'center', color:'var(--ink-3)', fontStyle:'italic' }}>
            No photos yet — add your first sighting.
          </div>
        ) : (
          <div className="photo-grid">
            {photos.map((p) => (
              <button
                key={p.id}
                type="button"
                className="photo-tile"
                onClick={() => setSelectedId(p.id)}
              >
                <img src={p.url} alt={p.speciesData.common} loading="lazy" />
                <div className="badge">
                  <div className="nm">{p.speciesData.common}</div>
                  {p.takenAt ? fmtDate(p.takenAt) : fmtDate(p.uploadedAt)}
                </div>
              </button>
            ))}
          </div>
        )}
      </div>

      {previewPhoto && (
        <PhotoLightbox
          photo={previewPhoto}
          canEdit={album.canEdit}
          onClose={() => setPreviewPhoto(null)}
          onEdit={() => setEditingPhoto(previewPhoto)}
        />
      )}

      {editing && album.canEdit && (
        <EditAlbumModal
          album={album}
          onClose={() => setEditing(false)}
          onSaved={(nextAlbum) => {
            setEditing(false);
            setData((prev) => prev ? { ...prev, album: nextAlbum } : prev);
          }}
        />
      )}

      {uploading && album.canEdit && (
        <UploadModal
          albumId={albumId}
          onClose={() => setUploading(false)}
          onUploaded={() => { setUploading(false); reload(); }}
        />
      )}

      {editingPhoto && album.canEdit && (
        <EditPhotoModal
          photo={editingPhoto}
          onClose={() => setEditingPhoto(null)}
          onSaved={(nextPhoto) => {
            setEditingPhoto(null);
            setPreviewPhoto(nextPhoto);
            setData((prev) => prev ? {
              ...prev,
              photos: prev.photos.map((p) => p.id === nextPhoto.id ? nextPhoto : p),
            } : prev);
          }}
          onDeleted={(photoId) => {
            setEditingPhoto(null);
            setPreviewPhoto(null);
            setSelectedId((prev) => prev === photoId ? null : prev);
            setData((prev) => prev ? {
              ...prev,
              photos: prev.photos.filter((p) => p.id !== photoId),
            } : prev);
          }}
        />
      )}
    </div>
  );
}

function EditAlbumModal({ album, onClose, onSaved }) {
  const [name, setName] = useState(album.name);
  const [subtitle, setSubtitle] = useState(album.subtitle || '');
  const [isPublic, setIsPublic] = useState(!!album.isPublic);
  const [busy, setBusy] = useState(false);
  const [err, setErr] = useState('');

  async function submit(e) {
    e.preventDefault();
    if (!name.trim()) return;
    setBusy(true); setErr('');
    try {
      const res = await api(`/api/albums/${album.id}`, {
        method: 'PATCH',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({ name: name.trim(), subtitle: subtitle.trim(), isPublic }),
      });
      onSaved(res.album);
    } catch (e) {
      setErr(e.message);
      setBusy(false);
    }
  }

  return (
    <div className="modal-backdrop" onMouseDown={(e) => { if (e.target.classList.contains('modal-backdrop')) onClose(); }}>
      <form className="modal-card fade-in" onSubmit={submit}>
        <div className="head">
          <h3>Edit album</h3>
          <button type="button" className="btn btn-ghost btn-sm" onClick={onClose}>{I.close}</button>
        </div>
        <div className="body">
          <div className="field">
            <span className="label">Name</span>
            <input className="input" value={name} onChange={(e) => setName(e.target.value)} autoFocus />
          </div>
          <div className="field">
            <span className="label">Subtitle (optional)</span>
            <input className="input" value={subtitle} onChange={(e) => setSubtitle(e.target.value)} />
          </div>
          <div className="field">
            <span className="label">Visibility</span>
            <div className="segmented">
              <button type="button" className={`segmented-option${isPublic ? ' active' : ''}`} onClick={() => setIsPublic(true)}>Public</button>
              <button type="button" className={`segmented-option${!isPublic ? ' active' : ''}`} onClick={() => setIsPublic(false)}>Private</button>
            </div>
            <div className="label">{isPublic ? 'Visible to anyone, even without sign-in.' : 'Only you can open this album.'}</div>
          </div>
          {err && <div className="flash err">{err}</div>}
          <div style={{ display:'flex', justifyContent:'flex-end', marginTop:18 }}>
            <button className="btn btn-moss" type="submit" disabled={busy || !name.trim()}>
              {busy ? 'Saving…' : 'Save changes'}
            </button>
          </div>
        </div>
      </form>
    </div>
  );
}

function PhotoLightbox({ photo, canEdit, onClose, onEdit }) {
  useEffect(() => {
    const onKeyDown = (e) => {
      if (e.key === 'Escape') onClose();
    };
    window.addEventListener('keydown', onKeyDown);
    return () => window.removeEventListener('keydown', onKeyDown);
  }, [onClose]);

  return (
    <div className="lightbox-backdrop fade-in" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
      <button type="button" className="lightbox-close btn btn-sm lightbox-action" onClick={onClose}>
        {I.close}
      </button>
      <figure className="lightbox-panel">
        <div className="lightbox-media">
          <img src={photo.url} alt={photo.speciesData.common} className="lightbox-image" />
        </div>
        <figcaption className="lightbox-sidebar">
          <div className="lightbox-header">
            <div style={{ display:'flex', alignItems:'flex-start', justifyContent:'space-between', gap:16 }}>
              <div>
                <div className="lightbox-title">{photo.speciesData.common}</div>
                <div className="lightbox-meta">
                  {photo.speciesData.latin || 'Unknown species'}
                  {' · '}
                  {photo.takenAt ? fmtDate(photo.takenAt) : fmtDate(photo.uploadedAt)}
                </div>
              </div>
              {canEdit && (
                <button type="button" className="btn btn-sm lightbox-action" onClick={onEdit}>Edit photo</button>
              )}
            </div>
            {photo.note && (
              <div className="lightbox-note">{photo.note}</div>
            )}
          </div>
          <div className="lightbox-body">
            <div className="lightbox-facts">
              <div className="lightbox-fact">
                <span className="label">Coordinates</span>
                <div className="mono">{fmtCoord(photo.lat, photo.lng)}</div>
              </div>
              {photo.speciesData.iucn && (
                <div className="lightbox-fact">
                  <span className="label">IUCN</span>
                  <div className={`mono iucn-${photo.speciesData.iucn}`}>
                    {photo.speciesData.iucn} · {photo.speciesData.iucnLabel || 'Status unknown'}
                  </div>
                </div>
              )}
            </div>
            {photo.speciesData.description ? (
              <div className="lightbox-description">{photo.speciesData.description}</div>
            ) : (
              <div className="lightbox-empty">No species description available.</div>
            )}
          </div>
        </figcaption>
      </figure>
    </div>
  );
}

function EditPhotoModal({ photo, onClose, onSaved, onDeleted }) {
  const [commonName, setCommonName] = useState(photo.speciesData.common || '');
  const [latin, setLatin] = useState(photo.speciesData.latin || '');
  const [family, setFamily] = useState(photo.speciesData.family || '');
  const [order, setOrder] = useState(photo.speciesData.order || '');
  const [iucn, setIucn] = useState(photo.speciesData.iucn || '');
  const [iucnLabel, setIucnLabel] = useState(photo.speciesData.iucnLabel || '');
  const [description, setDescription] = useState(photo.speciesData.description || '');
  const [note, setNote] = useState(photo.note || '');
  const [lat, setLat] = useState(String(photo.lat));
  const [lng, setLng] = useState(String(photo.lng));
  const [busy, setBusy] = useState(false);
  const [deleting, setDeleting] = useState(false);
  const [err, setErr] = useState('');

  async function submit(e) {
    e.preventDefault();
    const latNum = parseFloat(lat);
    const lngNum = parseFloat(lng);
    if (!commonName.trim()) { setErr('Please name the animal'); return; }
    if (!Number.isFinite(latNum) || latNum < -90 || latNum > 90) { setErr('Latitude must be between −90 and 90'); return; }
    if (!Number.isFinite(lngNum) || lngNum < -180 || lngNum > 180) { setErr('Longitude must be between −180 and 180'); return; }

    setBusy(true); setErr('');
    try {
      const res = await api(`/api/photos/${photo.id}`, {
        method: 'PATCH',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({
          lat: latNum,
          lng: lngNum,
          commonName: commonName.trim(),
          latin: latin.trim(),
          family: family.trim(),
          order: order.trim(),
          iucn: iucn.trim(),
          iucnLabel: iucnLabel.trim(),
          description: description.trim(),
          note: note.trim(),
        }),
      });
      onSaved(res.photo);
    } catch (e) {
      setErr(e.message);
      setBusy(false);
    }
  }

  async function removePhoto() {
    const ok = window.confirm('Delete this photo? This cannot be undone.');
    if (!ok) return;
    setDeleting(true); setErr('');
    try {
      await api(`/api/photos/${photo.id}`, { method: 'DELETE' });
      onDeleted(photo.id);
    } catch (e) {
      setErr(e.message);
      setDeleting(false);
    }
  }

  return (
    <div className="modal-backdrop" onMouseDown={(e) => { if (e.target.classList.contains('modal-backdrop')) onClose(); }}>
      <form className="modal-card fade-in" onSubmit={submit}>
        <div className="head">
          <h3>Edit photo</h3>
          <button type="button" className="btn btn-ghost btn-sm" onClick={onClose}>{I.close}</button>
        </div>
        <div className="body">
          <div className="field">
            <span className="label">Animal name</span>
            <input className="input" value={commonName} onChange={(e) => setCommonName(e.target.value)} autoFocus />
          </div>
          <div className="field">
            <span className="label">Latin name</span>
            <input className="input" value={latin} onChange={(e) => setLatin(e.target.value)} />
          </div>
          <div className="field">
            <span className="label">Coordinates</span>
            <div className="row">
              <input className="input input-mono" value={lat} onChange={(e) => setLat(e.target.value)} placeholder="1.352100" />
              <input className="input input-mono" value={lng} onChange={(e) => setLng(e.target.value)} placeholder="103.819800" />
            </div>
          </div>
          <div className="field">
            <span className="label">Family / Order</span>
            <div className="row">
              <input className="input" value={family} onChange={(e) => setFamily(e.target.value)} placeholder="Felidae" />
              <input className="input" value={order} onChange={(e) => setOrder(e.target.value)} placeholder="Carnivora" />
            </div>
          </div>
          <div className="field">
            <span className="label">IUCN</span>
            <div className="row">
              <input className="input input-mono" value={iucn} onChange={(e) => setIucn(e.target.value)} placeholder="LC" />
              <input className="input" value={iucnLabel} onChange={(e) => setIucnLabel(e.target.value)} placeholder="Least Concern" />
            </div>
          </div>
          <div className="field">
            <span className="label">Species description</span>
            <textarea className="input textarea" value={description} onChange={(e) => setDescription(e.target.value)} rows={6} />
          </div>
          <div className="field">
            <span className="label">Field note</span>
            <textarea className="input textarea" value={note} onChange={(e) => setNote(e.target.value)} rows={4} />
          </div>
          {err && <div className="flash err">{err}</div>}
          <div style={{ display:'flex', justifyContent:'space-between', gap:12, marginTop:18, flexWrap:'wrap' }}>
            <button type="button" className="btn btn-danger" onClick={removePhoto} disabled={busy || deleting}>
              {deleting ? 'Deleting…' : 'Delete'}
            </button>
            <button className="btn btn-moss" type="submit" disabled={busy || deleting || !commonName.trim()}>
              {busy ? 'Saving…' : 'Save changes'}
            </button>
          </div>
        </div>
      </form>
    </div>
  );
}

// ---------- upload modal: EXIF GPS → AI recognition → save ----------

// Hard timeout wrapper — exifr can occasionally hang on malformed metadata.
function withTimeout(p, ms) {
  return Promise.race([
    p,
    new Promise((resolve) => setTimeout(() => resolve(null), ms)),
  ]);
}

async function readExif(file) {
  if (!window.exifr) return { gps: null, takenAt: null };
  // GPS and date are read with separate calls. `pick` filters tags by name,
  // but `latitude`/`longitude` are computed from the raw GPS* tags — picking
  // the computed names alone strips the inputs and yields nothing.
  const gpsP = window.exifr.gps(file).catch(() => null);
  const exifP = window.exifr.parse(file, ['DateTimeOriginal']).catch(() => null);
  const [gpsRes, exifRes] = await Promise.all([
    withTimeout(gpsP, 4000),
    withTimeout(exifP, 4000),
  ]);
  const gps = gpsRes && Number.isFinite(gpsRes.latitude) && Number.isFinite(gpsRes.longitude)
    ? { lat: gpsRes.latitude, lng: gpsRes.longitude } : null;
  const d = exifRes && exifRes.DateTimeOriginal;
  const takenAt = d instanceof Date ? Math.floor(d.getTime() / 1000) : null;
  return { gps, takenAt };
}

// File extensions and MIME types lie; sniff magic bytes.
async function sniffFormat(file) {
  try {
    const head = new Uint8Array(await file.slice(0, 8).arrayBuffer());
    if (head.length < 8) return 'unknown';
    if (head[0] === 0xff && head[1] === 0xd8) return 'jpeg';
    if (head[0] === 0x89 && head[1] === 0x50 && head[2] === 0x4e && head[3] === 0x47) return 'png';
    return 'unknown';
  } catch { return 'unknown'; }
}

function UploadModal({ albumId, onClose, onUploaded }) {
  const [file, setFile] = useState(null);
  const [preview, setPreview] = useState('');
  const [lat, setLat] = useState('');
  const [lng, setLng] = useState('');
  const [needsManualGps, setNeedsManualGps] = useState(false);
  const [takenAt, setTakenAt] = useState(null);

  const [recog, setRecog] = useState(null); // { recognized, common, latin, family, order, iucn, iucnLabel, description }
  const [recogState, setRecogState] = useState('idle'); // idle | running | done | failed
  const [commonName, setCommonName] = useState('');
  const [note, setNote] = useState('');
  const [busy, setBusy] = useState(false);
  const [err, setErr] = useState('');
  // step is the only UI state for picker progress: idle | sniffing | reading | recognizing
  const [step, setStep] = useState('idle');

  async function pickFile(input) {
    setErr('');
    setRecog(null); setRecogState('idle'); setCommonName('');
    setStep('sniffing');

    try {
      const fmt = await sniffFormat(input);
      if (fmt !== 'jpeg' && fmt !== 'png') {
        throw new Error('Only JPEG and PNG are supported. Please convert HEIC/other formats to JPEG before uploading.');
      }

      // Show preview as soon as we have something the browser can render.
      setFile(input);
      setPreview(URL.createObjectURL(input));
      const f = input;

      // EXIF — read from original (heic2any drops metadata). Timeout-guarded so it can't hang the UI.
      setStep('reading');
      const exif = await readExif(input);
      if (exif.gps) {
        setLat(String(exif.gps.lat.toFixed(6)));
        setLng(String(exif.gps.lng.toFixed(6)));
        setNeedsManualGps(false);
      } else {
        setLat(''); setLng(''); setNeedsManualGps(true);
      }
      setTakenAt(exif.takenAt);

      // Kick off animal recognition.
      setStep('recognizing');
      setRecogState('running');
      const res = await fetch('/api/recognize', {
        method: 'POST',
        headers: { 'content-type': f.type || 'image/jpeg' },
        body: f,
      });
      const data = await res.json().catch(() => ({}));
      if (!res.ok) {
        setRecogState('failed');
        setErr(data.error || `recognition failed (${res.status})`);
      } else {
        setRecog(data);
        setRecogState('done');
        if (data.recognized && data.common) setCommonName(data.common);
      }
      setStep('idle');
    } catch (e) {
      setStep('idle');
      setErr((e && e.message) || String(e));
    }
  }

  async function submit(e) {
    e.preventDefault();
    if (!file) return;
    const latNum = parseFloat(lat), lngNum = parseFloat(lng);
    if (!Number.isFinite(latNum) || latNum < -90 || latNum > 90) { setErr('Latitude must be between −90 and 90'); return; }
    if (!Number.isFinite(lngNum) || lngNum < -180 || lngNum > 180) { setErr('Longitude must be between −180 and 180'); return; }
    if (!commonName.trim()) { setErr('Please name the animal'); return; }

    setBusy(true); setErr('');
    const fd = new FormData();
    fd.append('image', file);
    fd.append('lat', String(latNum));
    fd.append('lng', String(lngNum));
    fd.append('commonName', commonName.trim());
    if (note.trim()) fd.append('note', note.trim());
    if (takenAt) fd.append('takenAt', String(takenAt));

    if (recog && recog.recognized && recog.common && recog.common === commonName.trim()) {
      fd.append('aiRecognized', '1');
      if (recog.latin) fd.append('latin', recog.latin);
      if (recog.family) fd.append('family', recog.family);
      if (recog.order) fd.append('order', recog.order);
      if (recog.iucn) fd.append('iucn', recog.iucn);
      if (recog.iucnLabel) fd.append('iucnLabel', recog.iucnLabel);
      if (recog.description) fd.append('description', recog.description);
    }

    try {
      const res = await fetch(`/api/albums/${albumId}/photos`, { method: 'POST', body: fd, credentials: 'include' });
      const d = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error(d.error || 'upload failed');
      onUploaded();
    } catch (e) {
      setErr(e.message); setBusy(false);
    }
  }

  return (
    <div className="modal-backdrop" onMouseDown={(e) => { if (e.target.classList.contains('modal-backdrop')) onClose(); }}>
      <form className="modal-card fade-in" onSubmit={submit}>
        <div className="head">
          <h3>Add a sighting</h3>
          <button type="button" className="btn btn-ghost btn-sm" onClick={onClose}>{I.close}</button>
        </div>
        <div className="body">

          {!file ? (
            <>
              <label className="sheet" style={{ display:'block', padding:'40px 20px', textAlign:'center', cursor: step !== 'idle' ? 'wait' : 'pointer', borderStyle:'dashed' }}>
                <input type="file" accept="image/jpeg,image/png,.jpg,.jpeg,.png" hidden
                       disabled={step !== 'idle'}
                       onChange={(e) => e.target.files?.[0] && pickFile(e.target.files[0])} />
                <div style={{ display:'flex', flexDirection:'column', alignItems:'center', gap:8, color:'var(--ink-3)' }}>
                  {step !== 'idle' ? <div className="loader" /> : I.upload}
                  <span className="label">
                    {step === 'sniffing' && 'Inspecting file…'}
                    {step === 'reading' && 'Reading EXIF…'}
                    {step === 'recognizing' && 'Identifying animal…'}
                    {step === 'idle' && 'Click to pick a single photo'}
                  </span>
                  <span style={{ fontStyle:'italic', fontSize:14 }}>JPEG / PNG · ≤ 15 MB</span>
                </div>
              </label>
              {err && <div className="flash err" style={{ marginTop:14 }}>{err}</div>}
            </>
          ) : (
            <>
              <div style={{ display:'grid', gridTemplateColumns:'140px 1fr', gap:16 }}>
                <img src={preview} alt="" style={{ width:140, height:140, objectFit:'cover', border:'1px solid var(--rule)' }} />
                <div>
                  <div className="label">Photo</div>
                  <div style={{ fontSize:14, color:'var(--ink-2)', marginTop:4, wordBreak:'break-all' }}>{file.name}</div>
                  {takenAt && (
                    <div className="mono" style={{ fontSize:11, color:'var(--ink-3)', marginTop:6 }}>
                      Taken {fmtDate(takenAt)}
                    </div>
                  )}
                  <button type="button" className="btn btn-ghost btn-sm" style={{ marginTop:10 }}
                          onClick={() => { setFile(null); setPreview(''); setRecog(null); setRecogState('idle'); setLat(''); setLng(''); setCommonName(''); setErr(''); }}>
                    Replace
                  </button>
                </div>
              </div>

              <hr className="rule-dotted" />

              <div className="field">
                <span className="label">Location</span>
                {needsManualGps && (
                  <div className="flash">No GPS data found — enter the coordinates manually.</div>
                )}
                <div className="row">
                  <input className="input input-mono" placeholder="Latitude · −90 to 90"
                         value={lat} onChange={(e) => setLat(e.target.value)} />
                  <input className="input input-mono" placeholder="Longitude · −180 to 180"
                         value={lng} onChange={(e) => setLng(e.target.value)} />
                </div>
              </div>

              <hr className="rule-dotted" />

              <div className="field">
                <span className="label" style={{ display:'flex', alignItems:'center', gap:8 }}>
                  Animal
                  {recogState === 'running' && <span className="loader" style={{ width:12, height:12 }} />}
                  {recogState === 'done' && recog?.recognized && (
                    <span className="chip moss">{I.sparkle} ID'd by GPT</span>
                  )}
                  {(recogState === 'done' && !recog?.recognized) && (
                    <span className="chip rust">Couldn't identify · type it in</span>
                  )}
                  {recogState === 'failed' && (
                    <span className="chip rust">Recognition failed · type it in</span>
                  )}
                </span>
                <input className="input" placeholder="Common name (English)"
                       value={commonName} onChange={(e) => setCommonName(e.target.value)} />
                {recog?.recognized && recog.latin && (
                  <div style={{ marginTop:6, fontStyle:'italic', color:'var(--ink-3)', fontSize:14 }}>
                    {recog.latin}
                    {recog.family && <> · <span className="mono" style={{ fontSize:11 }}>{recog.family}</span></>}
                    {recog.iucn && <> · <span className={`mono iucn-${recog.iucn}`} style={{ fontSize:11, fontWeight:600 }}>
                      {recog.iucn} · {recog.iucnLabel}
                    </span></>}
                  </div>
                )}
                {recog?.description && (
                  <div style={{ marginTop:6, color:'var(--ink-2)', fontSize:14 }}>{recog.description}</div>
                )}
              </div>

              <div className="field">
                <span className="label">Field note (optional)</span>
                <input className="input" value={note} onChange={(e) => setNote(e.target.value)}
                       placeholder="Pride at dawn, below the lookout ridge." />
              </div>

              {err && <div className="flash err">{err}</div>}

              <div style={{ display:'flex', justifyContent:'flex-end', gap:10, marginTop:20 }}>
                <button type="button" className="btn btn-ghost" onClick={onClose}>Cancel</button>
                <button className="btn btn-moss" type="submit" disabled={busy || !commonName.trim() || !lat || !lng}>
                  {busy ? 'Saving…' : 'Save sighting'}
                </button>
              </div>
            </>
          )}
        </div>
      </form>
    </div>
  );
}

// ---------- public profile ----------

function ProfileScreen({ username, me, navigate, onMeUpdate }) {
  const [data, setData] = useState(undefined);
  const [editingProfile, setEditingProfile] = useState(false);
  const [selectedId, setSelectedId] = useState(null);
  const [previewPhoto, setPreviewPhoto] = useState(null);
  useEffect(() => {
    api(`/api/u/${encodeURIComponent(username)}`).then(setData).catch(() => setData(null));
  }, [username]);

  const profilePhotos = data?.photos || [];
  const allPins = useMemo(() => profilePhotos.map((p) => ({
    id: p.id, lat: p.lat, lng: p.lng, url: p.url,
    date: p.takenAt ? fmtDate(p.takenAt) : '',
    speciesData: p.speciesData,
  })), [profilePhotos]);

  if (data === undefined) return <CenterLoader />;
  if (data === null) {
    return (
      <div className="shell">
        <TopBar me={me} navigate={navigate} />
        <div className="sheet" style={{ padding:'40px', textAlign:'center' }}>
          <h2 style={{ marginTop:0 }}>No journal here</h2>
          <p style={{ color:'var(--ink-3)', fontStyle:'italic' }}>
            <span className="mono">@{username}</span> doesn't exist (yet).
          </p>
        </div>
      </div>
    );
  }

  const speciesCount = new Set(profilePhotos.map((p) => p.speciesData.common)).size;
  const isOwnProfile = !!me && me.username === data.user.username;

  return (
    <div className="shell">
      <TopBar me={me} navigate={navigate} />

      <div className="masthead" style={{ textAlign:'left', paddingTop:0 }}>
        <div style={{ display:'flex', gap:18, alignItems:'center', justifyContent:'space-between', flexWrap:'wrap', width:'100%' }}>
        <div style={{ display:'flex', gap:18, alignItems:'center' }}>
          {data.user.avatarUrl
            ? <img src={data.user.avatarUrl} alt="" style={{ width:62, height:62, borderRadius:'50%', border:'1px solid var(--rule)' }} />
            : <div style={{ width:62, height:62, borderRadius:'50%', background:'var(--paper-3)', display:'grid', placeItems:'center', color:'var(--ink-3)' }}>{I.user}</div>}
          <div>
            <span className="label">Field journalist</span>
            <h1 style={{ margin:'4px 0 0', fontFamily:'var(--serif)', fontWeight:500, fontSize:34 }}>
              @{data.user.username}
            </h1>
            {data.user.displayName && (
              <div style={{ color:'var(--ink-3)', fontStyle:'italic' }}>{data.user.displayName}</div>
            )}
            <div style={{ display:'flex', gap:8, marginTop:8 }}>
              <span className="chip">{data.albums.length} album{data.albums.length===1?'':'s'}</span>
              <span className="chip moss">{data.photos.length} sighting{data.photos.length===1?'':'s'}</span>
              <span className="chip blue">{speciesCount} species</span>
            </div>
          </div>
        </div>
        {isOwnProfile && (
          <button className="btn btn-ghost" onClick={() => setEditingProfile(true)}>Edit profile</button>
        )}
        </div>
      </div>

      <hr className="rule-double" />

      <h2 style={{ fontFamily:'var(--serif)', fontWeight:500, marginBottom:6 }}>Life list, mapped</h2>
      <span className="label">Public sightings · public albums</span>
      <div style={{ height:'52vh', minHeight:380, marginTop:14 }}>
        <SatelliteMap
          pins={allPins}
          selectedId={selectedId}
          onSelect={setSelectedId}
          onPreviewPhoto={setPreviewPhoto}
        />
      </div>

      <hr className="rule-double" />

      <h2 style={{ fontFamily:'var(--serif)', fontWeight:500, marginBottom:6 }}>Albums</h2>
      {data.albums.length === 0 ? (
        <div className="sheet" style={{ padding:'30px', textAlign:'center', color:'var(--ink-3)', fontStyle:'italic', marginTop:14 }}>
          No public albums yet.
        </div>
      ) : (
        <div className="album-grid">
          {data.albums.map((a) => (
            <button key={a.id} className="album-card" onClick={() => navigate(`/albums/${a.id}`)}>
              <div className="cover">
                {a.coverUrl
                  ? <img src={a.coverUrl} alt="" />
                  : <span className="empty">no photos yet</span>}
              </div>
              <div className="meta">
                <h3>{a.name}</h3>
                {a.subtitle && <div className="sub">{a.subtitle}</div>}
                <div className="row">
                  <span className="chip">{a.photoCount} sighting{a.photoCount===1?'':'s'}</span>
                  <span className="chip blue">Public</span>
                  <span className="chip moss">{fmtDate(a.createdAt)}</span>
                </div>
              </div>
            </button>
          ))}
        </div>
      )}

      {editingProfile && isOwnProfile && (
        <EditProfileModal
          profile={data.user}
          onClose={() => setEditingProfile(false)}
          onSaved={(nextUser) => {
            setEditingProfile(false);
            setData((prev) => prev ? { ...prev, user: { ...prev.user, ...nextUser } } : prev);
            onMeUpdate((prev) => prev ? { ...prev, ...nextUser } : prev);
          }}
        />
      )}

      {previewPhoto && (
        <PhotoLightbox
          photo={previewPhoto}
          canEdit={false}
          onClose={() => setPreviewPhoto(null)}
        />
      )}
    </div>
  );
}

function EditProfileModal({ profile, onClose, onSaved }) {
  const [displayName, setDisplayName] = useState(profile.displayName || '');
  const [avatarUrl, setAvatarUrl] = useState(profile.avatarUrl || '');
  const [busy, setBusy] = useState(false);
  const [err, setErr] = useState('');

  async function submit(e) {
    e.preventDefault();
    setBusy(true); setErr('');
    try {
      const res = await api('/api/me', {
        method: 'PATCH',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({
          displayName: displayName.trim(),
          avatarUrl: avatarUrl.trim(),
        }),
      });
      onSaved({
        displayName: res.user.displayName,
        avatarUrl: res.user.avatarUrl,
      });
    } catch (e) {
      setErr(e.message);
      setBusy(false);
    }
  }

  return (
    <div className="modal-backdrop" onMouseDown={(e) => { if (e.target.classList.contains('modal-backdrop')) onClose(); }}>
      <form className="modal-card fade-in" onSubmit={submit}>
        <div className="head">
          <h3>Edit profile</h3>
          <button type="button" className="btn btn-ghost btn-sm" onClick={onClose}>{I.close}</button>
        </div>
        <div className="body">
          <div className="field">
            <span className="label">Display name</span>
            <input className="input" value={displayName} onChange={(e) => setDisplayName(e.target.value)} autoFocus placeholder="Your name in the field" />
          </div>
          <div className="field">
            <span className="label">Avatar URL</span>
            <input className="input input-mono" value={avatarUrl} onChange={(e) => setAvatarUrl(e.target.value)} placeholder="https://..." />
          </div>
          {err && <div className="flash err">{err}</div>}
          <div style={{ display:'flex', justifyContent:'flex-end', marginTop:18 }}>
            <button className="btn btn-moss" type="submit" disabled={busy}>
              {busy ? 'Saving…' : 'Save changes'}
            </button>
          </div>
        </div>
      </form>
    </div>
  );
}

// ---------- mount ----------

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