/* ===================================================================
   map.jsx — the shared battle map (the hero of the prototype)
   Grid · hand-drawn cavern · fog of war · sprites · ink drawing
   =================================================================== */
const { useState, useRef, useEffect, useCallback } = React;

const CELL = 46;
const FEET_PER_CELL = 5;
const key = (x, y) => `${x},${y}`;

/* ---------- Hand-inked cavern map (the floor the fog conceals) ---------- */
function DungeonArt({ W, H }) {
  const w = W * CELL, h = H * CELL;
  const room = (x, y, rw, rh, rx = 26) => (
    <rect x={x*CELL} y={y*CELL} width={rw*CELL} height={rh*CELL} rx={rx} ry={rx}
      fill="#d9c49b" stroke="#2a2017" strokeWidth="6" strokeLinejoin="round" />
  );
  return (
    <svg className="dungeon" width={w} height={h} viewBox={`0 0 ${w} ${h}`}>
      <defs>
        <pattern id="floorgrit" width="9" height="9" patternUnits="userSpaceOnUse">
          <circle cx="2" cy="3" r=".6" fill="#9c855c" opacity=".5" />
          <circle cx="6" cy="7" r=".5" fill="#9c855c" opacity=".4" />
        </pattern>
        <filter id="rough"><feTurbulence baseFrequency="0.04" numOctaves="2" result="n" />
          <feDisplacementMap in="SourceGraphic" in2="n" scale="7" /></filter>
      </defs>

      {/* corridors (under rooms so joins look carved) */}
      <g filter="url(#rough)" stroke="#2a2017" strokeWidth="6" fill="#d9c49b" strokeLinejoin="round">
        <rect x={6.4*CELL} y={10.2*CELL} width={3.2*CELL} height={1.9*CELL} />
        <rect x={8.2*CELL} y={6.2*CELL}  width={1.8*CELL} height={5*CELL} />
        <rect x={15.4*CELL} y={3.1*CELL} width={2.6*CELL} height={1.8*CELL} />
      </g>

      {/* chambers */}
      <g filter="url(#rough)">
        {room(1.4, 8.6, 6.2, 6.0)}      {/* entry hall */}
        {room(8.2, 3.4, 8.4, 7.2, 40)}  {/* mid cavern */}
        {room(16.4, 0.7, 6.8, 6.0)}     {/* boss hall */}
      </g>

      {/* floor grit + faint glow */}
      <g style={{ mixBlendMode: 'multiply' }}>
        <rect x="0" y="0" width={w} height={h} fill="url(#floorgrit)" opacity=".5" />
      </g>

      {/* features */}
      {/* underground pool in mid cavern */}
      <g>
        <ellipse cx={13.3*CELL} cy={8.4*CELL} rx={2.5*CELL} ry={1.5*CELL} fill="#5f879b" opacity=".55" />
        <ellipse cx={13.3*CELL} cy={8.4*CELL} rx={2.5*CELL} ry={1.5*CELL} fill="none" stroke="#2a2017" strokeWidth="2.5" opacity=".5" />
        {[0,1,2].map(i => <path key={i} d={`M ${(11.3+i*0.7)*CELL} ${(8.2+i*0.25)*CELL} q ${0.4*CELL} ${-0.2*CELL} ${0.8*CELL} 0`} stroke="#e9f1f4" strokeWidth="1.5" fill="none" opacity=".6" />)}
      </g>
      {/* campfire in entry */}
      <g transform={`translate(${4.4*CELL} ${11.5*CELL})`}>
        <circle r="13" fill="#7d2330" opacity=".25" />
        <path d="M0 -10 q 7 6 0 14 q -7 -6 0 -14" fill="#e8a13a" />
        <path d="M0 -5 q 4 4 0 9 q -4 -4 0 -9" fill="#f6d97a" />
      </g>
      {/* columns in mid cavern */}
      {[[9.4,5.0],[15.2,5.0],[9.4,9.4]].map(([cx,cy],i)=>(
        <g key={i}><circle cx={cx*CELL} cy={cy*CELL} r="11" fill="#bda478" stroke="#2a2017" strokeWidth="3" />
        <circle cx={cx*CELL} cy={cy*CELL} r="4" fill="#2a2017" opacity=".3" /></g>
      ))}
      {/* treasure chest in boss hall */}
      <g transform={`translate(${21.4*CELL} ${2.0*CELL})`}>
        <rect x="-13" y="-9" width="26" height="18" rx="3" fill="#8a5a2a" stroke="#2a2017" strokeWidth="3" />
        <rect x="-13" y="-9" width="26" height="6" rx="3" fill="#6b4220" stroke="#2a2017" strokeWidth="3" />
        <rect x="-3" y="-4" width="6" height="8" fill="#e8c372" stroke="#2a2017" strokeWidth="1.5" />
      </g>
      {/* stairs out of boss hall */}
      <g transform={`translate(${18.0*CELL} ${1.4*CELL})`} stroke="#2a2017" strokeWidth="2.5" opacity=".7">
        {[0,1,2,3].map(i=> <line key={i} x1="-16" y1={i*6-9} x2="16" y2={i*6-9} />)}
      </g>
      {/* rubble near corridor */}
      {[[7.6,10.7],[8.0,11.4],[16.0,3.7]].map(([cx,cy],i)=>(
        <g key={i} fill="#a88c5f" stroke="#2a2017" strokeWidth="1.5">
          <circle cx={cx*CELL} cy={cy*CELL} r="4" /><circle cx={cx*CELL+7} cy={cy*CELL+4} r="3" />
        </g>
      ))}
    </svg>
  );
}

/* ---------- A single token / sprite ---------- */
function Token({ data, x, y, style, hiddenToPlayers, isDM, veiled, dragging, dead, dim }) {
  const sz = CELL * 0.92;
  const left = x * CELL + (CELL - sz) / 2;
  const top  = y * CELL + (CELL - sz) / 2;
  const ring = data.color || '#7d2330';
  const isMonster = !data.isDM && (data.reveal !== undefined);

  let inner;
  const face = data.portrait
    ? <img className="tk-portrait" src={data.portrait} alt="" draggable="false" />
    : <span className="tk-emoji" style={{ fontSize: sz*0.52 }}>{data.sprite}</span>;
  if (style === 'pixel') {
    inner = (
      <div className="tk-pixel" style={{ '--ring': ring }}>
        {data.portrait ? face : <span className="tk-emoji" style={{ fontSize: sz*0.5 }}>{data.sprite}</span>}
      </div>
    );
  } else if (style === 'mini') {
    inner = (
      <div className="tk-mini" style={{ '--ring': ring }}>
        <div className="tk-mini-base" />
        {data.portrait ? face : <span className="tk-emoji" style={{ fontSize: sz*0.62 }}>{data.sprite}</span>}
      </div>
    );
  } else { /* disc */
    inner = (
      <div className="tk-disc" style={{ '--ring': ring }}>
        {face}
      </div>
    );
  }

  const hpPct = data.hpMax ? Math.max(0, (data.hp / data.hpMax) * 100) : null;
  return (
    <div className={`token ${dragging ? 'drag' : ''} ${veiled ? 'veiled' : ''} ${dead ? 'dead' : ''}`}
         data-ref={data.id}
         style={{ left, top, width: sz, height: sz, opacity: dim ? .5 : 1, zIndex: dragging ? 50 : 10 }}>
      {inner}
      {veiled && isDM && <div className="veil-badge" title="Hidden from players">🜖</div>}
      {hpPct !== null && !hiddenToPlayers && (
        <div className="tk-hp"><i style={{ width: `${hpPct}%`, background: hpPct > 50 ? '#3f7d4a' : hpPct > 25 ? '#c1933f' : '#a23644' }} /></div>
      )}
      <div className="tk-name" style={{ fontSize: sz*0.2 }}>{data.char ? data.char.split(' ')[0] : data.name}</div>
    </div>
  );
}

/* ---------- The map canvas ---------- */
function MapCanvas({ isDM, tweaks, round, tokensState, setTokensState, addRoll, activeRef, currentMap, revealed, setRevealed, getEnt, monsters, geofences, onCreateGeo, onEditGeo, onGeoEnter }) {
  const resolve = getEnt || byId;
  const MONS = monsters || (typeof MONSTERS !== 'undefined' ? MONSTERS : []);
  const geos = geofences || [];
  const W = currentMap.cells?.w || 24, H = currentMap.cells?.h || 16;
  const boardRef = useRef(null);
  const fogRef = useRef(null);
  const drawRef = useRef(null);
  const inkUndo = useRef([]);              // snapshots of the ink canvas, one per stroke
  const [inkSteps, setInkSteps] = useState(0); // drives the Undo button's enabled state

  const [tool, setTool]   = useState('select');
  const [zoom, setZoom]   = useState(0.7);
  const [activePoi, setActivePoi] = useState(null);

  // fit the whole board into the viewport whenever the map (size) changes
  useEffect(() => {
    const fit = () => {
      const scroll = boardRef.current && boardRef.current.closest('.map-scroll');
      if (!scroll) return;
      const availW = scroll.clientWidth - 48, availH = scroll.clientHeight - 48;
      const z = Math.min(availW / (W*CELL), availH / (H*CELL));
      setZoom(Math.max(0.3, Math.min(1.4, z)));
    };
    fit();
    window.addEventListener('resize', fit);
    return () => window.removeEventListener('resize', fit);
  }, [W, H]);

  const [explored, setExplored] = useState(() => new Set());
  const [dmReveal, setDmReveal] = useState(() => new Set()); // cells DM lifted
  const [notes, setNotes] = useState([]);
  const noteSeq = useRef(0);
  const [measure, setMeasure] = useState(null);
  const penColor = tweaks.inkColor || '#7d2330';

  const fogMode = tweaks.fogMode || 'sight';   // sight | manual | open
  const spriteStyle = tweaks.spriteStyle || 'disc';
  const geoStyle = tweaks.geoStyle || 'ring';
  const [bursts, setBursts] = useState([]);    // transient trigger flashes (seen by all)
  const insideRef = useRef({});                // geofence occupancy for rising-edge
  const [curtain, setCurtain] = useState(0);   // DM fog-reveal curtain (0–100%)
  const [curtainDir, setCurtainDir] = useState('lr');
  const visionR = 4; // cells of player line-of-sight
  const proxR = 3;   // proximity trigger radius

  // ---- compute lit cells ----
  function isMon(ref){ return !!MONS.find(m=>m.id===ref); }
  const playerToks = tokensState.filter(t => !isMon(t.ref));

  // accumulate explored memory from current player vision
  useEffect(() => {
    if (fogMode !== 'sight') return;
    setExplored(prev => {
      const next = new Set(prev);
      playerToks.forEach(t => {
        for (let dx=-visionR; dx<=visionR; dx++)
          for (let dy=-visionR; dy<=visionR; dy++)
            if (dx*dx+dy*dy <= visionR*visionR) next.add(key(t.x+dx, t.y+dy));
      });
      return next;
    });
  }, [tokensState, fogMode]);

  const monsterVisible = (m) => revealed.has(m.id) || dmRevealHas(m);
  function dmRevealHas(m){ const tk = tokensState.find(t=>t.ref===m.id); return tk && dmReveal.has(key(tk.x,tk.y)); }

  // ---- draw fog onto canvas ----
  useEffect(() => {
    const cv = fogRef.current; if (!cv) return;
    const ctx = cv.getContext('2d');
    ctx.clearRect(0,0,cv.width,cv.height);
    if (fogMode === 'open') return;
    const dark = getComputedStyle(document.documentElement).getPropertyValue('--fog').trim() || '#14100a';
    ctx.globalAlpha = isDM ? 0.46 : 0.99;
    ctx.fillStyle = dark;
    ctx.fillRect(0,0,cv.width,cv.height);
    ctx.globalAlpha = 1;
    ctx.globalCompositeOperation = 'destination-out';
    const erase = (cx, cy, r, a) => {
      const g = ctx.createRadialGradient(cx,cy,r*0.35,cx,cy,r);
      g.addColorStop(0,`rgba(0,0,0,${a})`); g.addColorStop(1,'rgba(0,0,0,0)');
      ctx.fillStyle = g; ctx.beginPath(); ctx.arc(cx,cy,r,0,7); ctx.fill();
    };
    if (fogMode === 'sight') {
      // remembered (dim) areas
      explored.forEach(k => { const [x,y]=k.split(',').map(Number); erase(x*CELL+CELL/2, y*CELL+CELL/2, CELL*1.0, 0.55); });
      // active vision (full)
      playerToks.forEach(t => erase(t.x*CELL+CELL/2, t.y*CELL+CELL/2, visionR*CELL, 1));
    }
    // DM hand-lifted cells always fully clear
    dmReveal.forEach(k => { const [x,y]=k.split(',').map(Number); erase(x*CELL+CELL/2, y*CELL+CELL/2, CELL*1.3, 1); });
    // DM reveal curtain — peels fog back across the map with a soft leading edge
    if (curtain > 0) {
      const f = Math.min(1, curtain / 100), Wc = cv.width, Hc = cv.height, fr = 70;
      const feather = (x0, y0, x1, y1) => { const g = ctx.createLinearGradient(x0, y0, x1, y1);
        g.addColorStop(0, 'rgba(0,0,0,1)'); g.addColorStop(1, 'rgba(0,0,0,0)'); ctx.fillStyle = g; };
      ctx.fillStyle = 'rgba(0,0,0,1)';
      if (curtainDir === 'lr')      { const e = Wc*f;     ctx.fillRect(0,0,Math.max(0,e-fr),Hc); feather(e-fr,0,e,0); ctx.fillRect(Math.max(0,e-fr),0,fr,Hc); }
      else if (curtainDir === 'rl') { const e = Wc*(1-f); ctx.fillRect(Math.min(Wc,e+fr),0,Wc,Hc); feather(e+fr,0,e,0); ctx.fillRect(e,0,fr,Hc); }
      else if (curtainDir === 'tb') { const e = Hc*f;     ctx.fillRect(0,0,Wc,Math.max(0,e-fr)); feather(0,e-fr,0,e); ctx.fillRect(0,Math.max(0,e-fr),Wc,fr); }
      else                          { const e = Hc*(1-f); ctx.fillRect(0,Math.min(Hc,e+fr),Wc,Hc); feather(0,e+fr,0,e); ctx.fillRect(0,e,Wc,fr); }
    }
    ctx.globalCompositeOperation = 'source-over';
  }, [tokensState, explored, dmReveal, fogMode, isDM, zoom, curtain, curtainDir]);

  // ---- geofence rising-edge detection (a hero steps into a zone) ----
  useEffect(() => {
    if (!geos.length) return;
    const players = tokensState.filter(t => !isMon(t.ref));
    geos.forEach(g => {
      if (!g.armed || (g.once && g.triggered)) { return; }
      const inside = players.some(p => Math.hypot(p.x - g.x, p.y - g.y) <= g.r);
      const was = insideRef.current[g.id];
      insideRef.current[g.id] = inside;
      if (inside && !was) {
        if (g.revealRef) {
          const tk = tokensState.find(t => t.ref === g.revealRef);
          if (tk) setDmReveal(s => { const n = new Set(s); paintDisc(tk.x, tk.y, Math.max(2, g.r - 1), n); return n; });
        }
        setBursts(b => [...b, { key: g.id + ':' + Date.now(), x: g.x, y: g.y, r: g.r }]);
        onGeoEnter && onGeoEnter(g);
      }
    });
  }, [tokensState, geos]);
  useEffect(() => {
    if (!bursts.length) return;
    const id = setTimeout(() => setBursts([]), 1700);
    return () => clearTimeout(id);
  }, [bursts]);

  // ---- pointer → cell ----
  const toCell = (e) => {
    const r = boardRef.current.getBoundingClientRect();
    return { x: Math.floor((e.clientX - r.left) / r.width * W), y: Math.floor((e.clientY - r.top) / r.height * H) };
  };
  const toCanvas = (e, cv) => {
    const r = boardRef.current.getBoundingClientRect();
    return { x: (e.clientX-r.left)/r.width*cv.width, y: (e.clientY-r.top)/r.height*cv.height };
  };

  // ---- drag tokens ----
  const dragInfo = useRef(null);
  const [draggingRef, setDraggingRef] = useState(null);
  const penDown = useRef(false);

  const onBoardPointerDown = (e) => {
    // Capture on the board itself so dragging a sprite into any area (even off the
    // board edge) keeps firing move/up here instead of dropping the token early.
    try { boardRef.current?.setPointerCapture?.(e.pointerId); } catch {}
    const tEl = e.target.closest('.token');
    if (tool === 'select' && tEl) {
      const ref = tEl.getAttribute('data-ref');
      // The DM controls monsters/NPCs; a player token may be moved only by the
      // player who owns it — never by another player and never by the DM.
      const canMove = isMon(ref) ? isDM : (!isDM && ref === activeRef);
      if (!canMove) return;
      dragInfo.current = { ref };
      setDraggingRef(ref);
      return;
    }
    if (tool === 'pen' || tool === 'erase') {
      penDown.current = true;
      const cv = drawRef.current, ctx = cv.getContext('2d');
      // Snapshot the canvas BEFORE this stroke so Undo can roll it back. Cap the
      // history so a long doodling session can't balloon memory.
      try {
        inkUndo.current.push(ctx.getImageData(0, 0, cv.width, cv.height));
        if (inkUndo.current.length > 30) inkUndo.current.shift();
        setInkSteps(inkUndo.current.length);
      } catch {}
      const p = toCanvas(e, cv);
      ctx.globalCompositeOperation = tool === 'erase' ? 'destination-out' : 'source-over';
      ctx.strokeStyle = penColor; ctx.lineWidth = tool === 'erase' ? 26 : 4.5;
      ctx.lineCap = 'round'; ctx.lineJoin = 'round';
      ctx.beginPath(); ctx.moveTo(p.x, p.y);
      return;
    }
    if (tool === 'note') {
      const c = toCell(e);
      const txt = prompt('Note on the map:');
      if (txt) setNotes(n => {
        // never stack two notes on the same cell — nudge down (then across) to the next free cell
        const taken = new Set(n.map(o => `${o.x},${o.y}`));
        let x = c.x, y = c.y, guard = 0;
        while (taken.has(`${x},${y}`) && guard < 200) { y++; if (y >= H) { y = 0; x = (x + 1) % W; } guard++; }
        return [...n, { id: ++noteSeq.current, x, y, text: txt.trim(), color: penColor, by: isDM ? 'dm' : activeRef }];
      });
      return;
    }
    if (isDM && tool === 'geofence') {
      const c = toCell(e);
      const hit = geos.find(g => Math.hypot(g.x - c.x, g.y - c.y) <= Math.max(1, g.r));
      if (hit) onEditGeo && onEditGeo(hit.id);
      else onCreateGeo && onCreateGeo(c);
      return;
    }
    if (isDM && tool === 'reveal') {
      const c = toCell(e);
      setDmReveal(s => { const n = new Set(s); paintDisc(c.x, c.y, 1, n); return n; });
      dragInfo.current = { reveal: true };
      return;
    }
    if (isDM && tool === 'veil') {
      // un-reveal (re-fog) around click
      const c = toCell(e);
      setDmReveal(s => { const n = new Set(s); paintDisc(c.x,c.y,1,n,true); return n; });
      setExplored(s => { const n = new Set(s); paintDisc(c.x,c.y,1,n,true); return n; });
      return;
    }
    if (tool === 'measure') {
      const c = toCell(e); setMeasure({ a: c, b: c }); dragInfo.current = { measure: true };
    }
  };
  const undoInk = () => {
    const snap = inkUndo.current.pop();
    if (!snap) return;
    const cv = drawRef.current; if (!cv) return;
    cv.getContext('2d').putImageData(snap, 0, 0);
    setInkSteps(inkUndo.current.length);
  };
  const paintDisc = (cx, cy, r, set, remove) => {
    for (let dx=-r; dx<=r; dx++) for (let dy=-r; dy<=r; dy++)
      if (dx*dx+dy*dy <= r*r+1) { const k = key(cx+dx,cy+dy); remove ? set.delete(k) : set.add(k); }
  };

  const onBoardPointerMove = (e) => {
    if (dragInfo.current?.ref) {
      // Capture the ref NOW. React 18 batches state updaters and runs them after
      // this handler returns, by which point a racing pointerup may have nulled
      // dragInfo.current — reading it inside the updater would throw and unmount
      // the whole table ("page reset").
      const ref = dragInfo.current.ref;
      const r = boardRef.current.getBoundingClientRect();
      const fx = (e.clientX-r.left)/r.width*W - 0.5, fy = (e.clientY-r.top)/r.height*H - 0.5;
      setTokensState(ts => ts.map(t => t.ref === ref ? { ...t, fx, fy } : t));
    } else if (penDown.current) {
      const cv = drawRef.current, ctx = cv.getContext('2d');
      const p = toCanvas(e, cv); ctx.lineTo(p.x, p.y); ctx.stroke();
    } else if (dragInfo.current?.reveal && tool === 'reveal') {
      const c = toCell(e); setDmReveal(s => { const n = new Set(s); paintDisc(c.x,c.y,1,n); return n; });
    } else if (dragInfo.current?.measure) {
      const c = toCell(e); setMeasure(m => m ? { ...m, b: c } : m);
    }
  };
  const onBoardPointerUp = () => {
    if (dragInfo.current?.ref) {
      const ref = dragInfo.current.ref;
      setTokensState(ts => ts.map(t => {
        if (t.ref !== ref) return t;
        const nx = Math.max(0, Math.min(W-1, Math.round(t.fx ?? t.x)));
        const ny = Math.max(0, Math.min(H-1, Math.round(t.fy ?? t.y)));
        const { fx, fy, ...rest } = t; return { ...rest, x: nx, y: ny };
      }));
    }
    dragInfo.current = null; penDown.current = false; setDraggingRef(null);
    if (tool === 'measure') setTimeout(()=>setMeasure(null), 1400);
  };

  const tools = [
    { id:'select', icon:'✥', label:'Select / Move' },
    { id:'pen',    icon:'✒', label:'Ink pen' },
    { id:'erase',  icon:'⌫', label:'Erase ink' },
    { id:'note',   icon:'❝', label:'Drop note' },
    { id:'measure',icon:'📏', label:'Measure (ft)' },
  ];
  const dmTools = [
    { id:'reveal', icon:'🜂', label:'Reveal fog — brush or curtain' },
    { id:'veil',   icon:'🌑', label:'Re-cloak fog' },
    { id:'geofence', icon:'◎', label:'Trigger zone' },
  ];

  const dist = measure ? Math.round(Math.hypot(measure.b.x-measure.a.x, measure.b.y-measure.a.y)) * FEET_PER_CELL : 0;

  return (
    <div className="map-wrap">
      {/* floating map toolbar */}
      <div className="map-toolbar leather stitched">
        {tools.map(t => (
          <button key={t.id} className={`mtool ${tool===t.id?'on':''}`} aria-label={t.label} aria-pressed={tool===t.id} title={t.label} onClick={()=>setTool(t.id)}>{t.icon}</button>
        ))}
        <button className="mtool" aria-label="Undo last ink stroke" title="Undo last ink stroke"
          disabled={inkSteps===0} onClick={undoInk}>↶</button>
        {isDM && <span className="mtool-sep" />}
        {isDM && dmTools.map(t => (
          <button key={t.id} className={`mtool dm ${tool===t.id?'on':''}`} aria-label={t.label} aria-pressed={tool===t.id} title={t.label} onClick={()=>setTool(t.id)}>{t.icon}</button>
        ))}
        <span className="mtool-sep" />
        <button className="mtool" title="Zoom out" onClick={()=>setZoom(z=>Math.max(0.45,z-0.08))}>－</button>
        <button className="mtool" title="Zoom in"  onClick={()=>setZoom(z=>Math.min(1.4,z+0.08))}>＋</button>
      </div>

      {/* color swatch for pen/note */}
      {(tool==='pen'||tool==='note') && (
        <div className="ink-swatches leather stitched">
          {['#7d2330','#2a2017','#3f5d42','#5b4b8a','#c1933f','#e9dcbf'].map(c=>(
            <button key={c} aria-label={`Ink color ${c}`} className={`ink-sw ${penColor===c?'on':''}`} style={{background:c}} onClick={()=>tweaks._setInk?.(c)} />
          ))}
        </div>
      )}

      {/* DM fog-reveal curtain — slide to peel fog back across the map */}
      {isDM && tool==='reveal' && fogMode!=='open' && (
        <div className="fog-curtain leather stitched">
          <span className="fc-label">Reveal<br/>curtain</span>
          <div className="fc-dirs" role="group" aria-label="Curtain direction">
            {[['lr','→','from the left'],['rl','←','from the right'],['tb','↓','from the top'],['bt','↑','from the bottom']].map(([d,ic,t])=>(
              <button key={d} className={`fc-dir ${curtainDir===d?'on':''}`} aria-pressed={curtainDir===d} title={`Reveal ${t}`} onClick={()=>setCurtainDir(d)}>{ic}</button>
            ))}
          </div>
          <input className="fc-slider" type="range" min="0" max="100" value={curtain}
            onChange={e=>setCurtain(+e.target.value)} aria-label="Fog reveal amount" />
          <span className="fc-pct">{curtain}%</span>
          <div className="fc-quick">
            <button onClick={()=>setCurtain(0)} title="Re-cloak the whole map">None</button>
            <button onClick={()=>setCurtain(100)} title="Reveal the whole map">All</button>
          </div>
        </div>
      )}

      <div className="map-scroll">
        <div className="board-fit" style={{ width: W*CELL*zoom, height: H*CELL*zoom }}>
        <div className="board" ref={boardRef}
          style={{ width: W*CELL, height: H*CELL, transform:`scale(${zoom})`, transformOrigin:'top left' }}
          onPointerDown={onBoardPointerDown} onPointerMove={onBoardPointerMove} onPointerUp={onBoardPointerUp} onPointerCancel={onBoardPointerUp}>
          {currentMap.url
            ? <img className="map-image" src={currentMap.url} alt="" draggable="false" />
            : <MapArt id={currentMap.id} W={W} H={H} />}

          {/* interactive points-of-interest (overview maps) */}
          {currentMap.pois && currentMap.pois.map(p => (
            <div key={p.n} className="poi-pin-wrap" style={{ left: p.x*CELL, top: p.y*CELL }}>
              <button className={`poi-pin ${activePoi===p.n?'on':''}`}
                style={{ transform:`translate(-50%,-50%) scale(${1/zoom})` }}
                onPointerDown={e=>e.stopPropagation()}
                onClick={e=>{ e.stopPropagation(); setActivePoi(a=>a===p.n?null:p.n); }}
                title={`${p.n}. ${p.name}`} aria-label={`${p.n}. ${p.name}`}>
                <span className="poi-num">{p.n}</span>
              </button>
              {activePoi===p.n && (
                <div className="poi-card" style={{ transform:`translate(-50%, -100%) scale(${1/zoom})` }}>
                  <div className="poi-card-hd"><span className="poi-card-n">{p.n}</span><b>{p.name}</b></div>
                  <p>{p.text}</p>
                  <span className="poi-card-tip" aria-hidden="true"></span>
                </div>
              )}
            </div>
          ))}
          {tweaks.showGrid !== false && (
            <svg className="grid-layer" width={W*CELL} height={H*CELL}>
              {Array.from({length:W+1}).map((_,i)=><line key={'v'+i} x1={i*CELL} y1="0" x2={i*CELL} y2={H*CELL} />)}
              {Array.from({length:H+1}).map((_,i)=><line key={'h'+i} x1="0" y1={i*CELL} x2={W*CELL} y2={i*CELL} />)}
            </svg>
          )}

          {/* notes — compact pins; full text in a bubble on hover/focus (keyboard accessible) */}
          {notes.map((n,i)=>{
            const flip = n.x > W - 7;
            const author = byId(n.by);
            const canDelete = isDM || n.by === activeRef;
            return (
              <button key={n.id ?? i} className={`map-note ${flip?'flip':''}`}
                style={{ left:n.x*CELL+CELL/2, top:n.y*CELL+CELL/2, '--nc':n.color }}
                onClick={()=>{ if (canDelete && confirm('Remove this note?')) setNotes(ns=>ns.filter((_,j)=>j!==i)); }}>
                <span className="map-note-pin" aria-hidden="true" style={{background:n.color}}>❝</span>
                <span className="map-note-bubble">
                  <span className="mnb-by">{author.name}</span>
                  {n.text}
                </span>
              </button>
            );
          })}

          {/* measure line */}
          {measure && (
            <svg className="measure-layer" width={W*CELL} height={H*CELL}>
              <line x1={(measure.a.x+.5)*CELL} y1={(measure.a.y+.5)*CELL} x2={(measure.b.x+.5)*CELL} y2={(measure.b.y+.5)*CELL}
                stroke="#7d2330" strokeWidth="3" strokeDasharray="7 6" />
              <circle cx={(measure.a.x+.5)*CELL} cy={(measure.a.y+.5)*CELL} r="5" fill="#7d2330" />
              <text x={(measure.b.x+.5)*CELL+8} y={(measure.b.y+.5)*CELL-8} className="measure-text">{dist} ft</text>
            </svg>
          )}

          {/* tokens */}
          {tokensState.map(t => {
            const mon = MONS.find(m=>m.id===t.ref);
            const data = resolve(t.ref);
            if (mon) {
              const vis = monsterVisible(mon);
              if (!isDM && !vis) return null; // players can't see hidden monsters
              return <Token key={t.ref} data={resolve(t.ref)} x={t.fx ?? t.x} y={t.fy ?? t.y} style={spriteStyle}
                isDM={isDM} veiled={!vis} dragging={draggingRef===t.ref} dim={!vis && isDM ? false : false} />;
            }
            return <Token key={t.ref} data={resolve(t.ref)} x={t.fx ?? t.x} y={t.fy ?? t.y} style={spriteStyle}
              isDM={isDM} dragging={draggingRef===t.ref} dim={activeRef && t.ref===activeRef ? false : false} />;
          })}

          {/* geofence trigger zones — DM-only, hidden from players */}
          {isDM && geos.map(g => {
            const cx = (g.x + 0.5) * CELL, cy = (g.y + 0.5) * CELL, rad = g.r * CELL + CELL / 2;
            const acts = [g.ytUrl && '♪', g.revealRef && '👁', g.narration && '❧'].filter(Boolean).join(' ');
            return (
              <div key={g.id} className={`geofence geo-${geoStyle} ${g.armed ? '' : 'draft'} ${g.once && g.triggered ? 'spent' : ''}`}
                style={{ left: cx, top: cy, width: rad * 2, height: rad * 2 }}>
                <span className="geo-core" />
                <span className="geo-tag">{g.once && g.triggered ? 'spent' : (acts || 'empty')}</span>
              </div>
            );
          })}

          {/* fog + drawing canvases */}
          <canvas ref={fogRef}  className="fog-canvas"  width={W*CELL} height={H*CELL} style={{pointerEvents:'none'}} />
          <canvas ref={drawRef} className="draw-canvas" width={W*CELL} height={H*CELL}
            style={{ pointerEvents: (tool==='pen'||tool==='erase') ? 'auto':'none' }} />

          {/* trigger bursts — a shimmer everyone sees as a sprite emerges */}
          {bursts.map(b => (
            <span key={b.key} className="geo-burst"
              style={{ left: (b.x + 0.5) * CELL, top: (b.y + 0.5) * CELL, '--gr': `${b.r * CELL + CELL}px` }} />
          ))}
        </div>
        </div>
      </div>

      {/* map footer caption */}
      <div className="map-caption">
        <span className="display">{currentMap.name}</span>
        <em>{currentMap.sub}</em>
        <span className="fog-tag">{fogMode==='open'?'Fog lifted':fogMode==='manual'?'Manual reveal':'Line-of-sight fog'}</span>
      </div>
    </div>
  );
}

window.MapCanvas = MapCanvas;
